diff --git a/.env.example b/.env.example index 6303808412..f2ffe103b8 100644 --- a/.env.example +++ b/.env.example @@ -565,9 +565,9 @@ HELP_AND_FAQ_URL=https://librechat.ai # users always get the latest version. Customize # # only if you understand caching implications. # -# INDEX_HTML_CACHE_CONTROL=no-cache, no-store, must-revalidate -# INDEX_HTML_PRAGMA=no-cache -# INDEX_HTML_EXPIRES=0 +# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate +# INDEX_PRAGMA=no-cache +# INDEX_EXPIRES=0 # no-cache: Forces validation with server before using cached version # no-store: Prevents storing the response entirely diff --git a/.github/workflows/helmcharts.yml b/.github/workflows/helmcharts.yml index bc715557e4..a8e3ef9b72 100644 --- a/.github/workflows/helmcharts.yml +++ b/.github/workflows/helmcharts.yml @@ -29,5 +29,8 @@ jobs: - name: Run chart-releaser uses: helm/chart-releaser-action@v1.6.0 + with: + charts_dir: helm + skip_existing: true env: CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/i18n-unused-keys.yml b/.github/workflows/i18n-unused-keys.yml index f720a61783..6bcf824946 100644 --- a/.github/workflows/i18n-unused-keys.yml +++ b/.github/workflows/i18n-unused-keys.yml @@ -22,7 +22,7 @@ jobs: # Define paths I18N_FILE="client/src/locales/en/translation.json" - SOURCE_DIRS=("client/src" "api") + SOURCE_DIRS=("client/src" "api" "packages/data-provider/src") # Check if translation file exists if [[ ! -f "$I18N_FILE" ]]; then diff --git a/.gitignore b/.gitignore index a4d2d8fc7e..0b64a284b5 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,11 @@ uploads/ # owner release/ + +# Helm +helm/librechat/Chart.lock +helm/**/charts/ +helm/**/.values.yaml + !/client/src/@types/i18next.d.ts + diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4c65c3ab..f39c86cfa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,23 +5,38 @@ All notable changes to this project will be documented in this file. + ## [Unreleased] ### ✨ New Features - ✨ feat: implement search parameter updates by **@mawburn** in [#7151](https://github.com/danny-avila/LibreChat/pull/7151) - 🎏 feat: Add MCP support for Streamable HTTP Transport by **@benverhees** in [#7353](https://github.com/danny-avila/LibreChat/pull/7353) +- 🔒 feat: Add Content Security Policy using Helmet middleware by **@rubentalstra** in [#7377](https://github.com/danny-avila/LibreChat/pull/7377) +- ✨ feat: Add Normalization for MCP Server Names by **@danny-avila** in [#7421](https://github.com/danny-avila/LibreChat/pull/7421) +- 📊 feat: Improve Helm Chart by **@hofq** in [#3638](https://github.com/danny-avila/LibreChat/pull/3638) + +### 🌍 Internationalization + +- 🌍 i18n: Add `Danish` and `Czech` and `Catalan` localization support by **@rubentalstra** in [#7373](https://github.com/danny-avila/LibreChat/pull/7373) +- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7375](https://github.com/danny-avila/LibreChat/pull/7375) ### 🔧 Fixes - 💬 fix: update aria-label for accessibility in ConvoLink component by **@berry-13** in [#7320](https://github.com/danny-avila/LibreChat/pull/7320) - 🔑 fix: use `apiKey` instead of `openAIApiKey` in OpenAI-like Config by **@danny-avila** in [#7337](https://github.com/danny-avila/LibreChat/pull/7337) - 🔄 fix: update navigation logic in `useFocusChatEffect` to ensure correct search parameters are used by **@mawburn** in [#7340](https://github.com/danny-avila/LibreChat/pull/7340) +- 🔄 fix: Improve MCP Connection Cleanup by **@danny-avila** in [#7400](https://github.com/danny-avila/LibreChat/pull/7400) +- 🛡️ fix: Preset and Validation Logic for URL Query Params by **@danny-avila** in [#7407](https://github.com/danny-avila/LibreChat/pull/7407) +- 🌘 fix: artifact of preview text is illegible in dark mode by **@nhtruong** in [#7405](https://github.com/danny-avila/LibreChat/pull/7405) +- 🛡️ fix: Temporarily Remove CSP until Configurable by **@danny-avila** in [#7419](https://github.com/danny-avila/LibreChat/pull/7419) +- 💽 fix: Exclude index page `/` from static cache settings by **@sbruel** in [#7382](https://github.com/danny-avila/LibreChat/pull/7382) ### ⚙️ Other Changes - 📜 docs: CHANGELOG for release v0.7.8 by **@github-actions[bot]** in [#7290](https://github.com/danny-avila/LibreChat/pull/7290) - 📦 chore: Update API Package Dependencies by **@danny-avila** in [#7359](https://github.com/danny-avila/LibreChat/pull/7359) +- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7321](https://github.com/danny-avila/LibreChat/pull/7321) @@ -67,7 +82,6 @@ Changes from v0.7.8-rc1 to v0.7.8. --- ## [v0.7.8-rc1] - -## [v0.7.8-rc1] - Changes from v0.7.7 to v0.7.8-rc1. diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js index 85941a779a..afea9dfd55 100644 --- a/api/app/clients/tools/structured/OpenAIImageTools.js +++ b/api/app/clients/tools/structured/OpenAIImageTools.js @@ -30,7 +30,7 @@ const DEFAULT_IMAGE_EDIT_DESCRIPTION = When to use \`image_edit_oai\`: - The user wants to modify, extend, or remix one **or more** uploaded images, either: - - Previously generated, or in the current request (both to be included in the \`image_ids\` array). +- Previously generated, or in the current request (both to be included in the \`image_ids\` array). - Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements. - Any current or existing images are to be used as visual guides. - If there are any files in the current request, they are more likely than not expected as references for image edit requests. diff --git a/api/package.json b/api/package.json index bcf94a6cad..1f2e326f7c 100644 --- a/api/package.json +++ b/api/package.json @@ -86,7 +86,7 @@ "mime": "^3.0.0", "module-alias": "^2.2.3", "mongoose": "^8.12.1", - "multer": "^1.4.5-lts.1", + "multer": "^2.0.0", "nanoid": "^3.3.7", "nodemailer": "^6.9.15", "ollama": "^0.5.0", diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js index 5fa10e9e37..9129a6a1c1 100644 --- a/api/server/controllers/assistants/chatV1.js +++ b/api/server/controllers/assistants/chatV1.js @@ -326,8 +326,15 @@ const chatV1 = async (req, res) => { file_ids = files.map(({ file_id }) => file_id); if (file_ids.length || thread_file_ids.length) { - userMessage.file_ids = file_ids; attachedFileIds = new Set([...file_ids, ...thread_file_ids]); + if (endpoint === EModelEndpoint.azureAssistants) { + userMessage.attachments = Array.from(attachedFileIds).map((file_id) => ({ + file_id, + tools: [{ type: 'file_search' }], + })); + } else { + userMessage.file_ids = Array.from(attachedFileIds); + } } }; diff --git a/api/server/index.js b/api/server/index.js index cd0bdd3f88..f7548f840b 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -24,10 +24,13 @@ const routes = require('./routes'); const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {}; -const port = Number(PORT) || 3080; +// Allow PORT=0 to be used for automatic free port assignment +const port = isNaN(Number(PORT)) ? 3080 : Number(PORT); const host = HOST || 'localhost'; const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */ +const app = express(); + const startServer = async () => { if (typeof Bun !== 'undefined') { axios.defaults.headers.common['Accept-Encoding'] = 'gzip'; @@ -36,8 +39,9 @@ const startServer = async () => { logger.info('Connected to MongoDB'); await indexSync(); - const app = express(); app.disable('x-powered-by'); + app.set('trust proxy', trusted_proxy); + await AppService(app); const indexPath = path.join(app.locals.paths.dist, 'index.html'); @@ -49,23 +53,24 @@ const startServer = async () => { app.use(noIndex); app.use(errorController); app.use(express.json({ limit: '3mb' })); - app.use(mongoSanitize()); app.use(express.urlencoded({ extended: true, limit: '3mb' })); - app.use(staticCache(app.locals.paths.dist)); - app.use(staticCache(app.locals.paths.fonts)); - app.use(staticCache(app.locals.paths.assets)); - app.set('trust proxy', trusted_proxy); + app.use(mongoSanitize()); app.use(cors()); app.use(cookieParser()); if (!isEnabled(DISABLE_COMPRESSION)) { app.use(compression()); + } else { + console.warn('Response compression has been disabled via DISABLE_COMPRESSION.'); } + // Serve static assets with aggressive caching + app.use(staticCache(app.locals.paths.dist)); + app.use(staticCache(app.locals.paths.fonts)); + app.use(staticCache(app.locals.paths.assets)); + if (!ALLOW_SOCIAL_LOGIN) { - console.warn( - 'Social logins are disabled. Set Environment Variable "ALLOW_SOCIAL_LOGIN" to true to enable them.', - ); + console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.'); } /* OAUTH */ @@ -128,7 +133,7 @@ const startServer = async () => { }); app.listen(port, host, () => { - if (host == '0.0.0.0') { + if (host === '0.0.0.0') { logger.info( `Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`, ); @@ -176,3 +181,6 @@ process.on('uncaughtException', (err) => { process.exit(1); }); + +// export app for easier testing purposes +module.exports = app; diff --git a/api/server/index.spec.js b/api/server/index.spec.js new file mode 100644 index 0000000000..493229c2f4 --- /dev/null +++ b/api/server/index.spec.js @@ -0,0 +1,78 @@ +const fs = require('fs'); +const path = require('path'); +const request = require('supertest'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mongoose = require('mongoose'); + +describe('Server Configuration', () => { + // Increase the default timeout to allow for Mongo cleanup + jest.setTimeout(30_000); + + let mongoServer; + let app; + + /** Mocked fs.readFileSync for index.html */ + const originalReadFileSync = fs.readFileSync; + beforeAll(() => { + fs.readFileSync = function (filepath, options) { + if (filepath.includes('index.html')) { + return 'LibreChat
'; + } + return originalReadFileSync(filepath, options); + }; + }); + + afterAll(() => { + // Restore original fs.readFileSync + fs.readFileSync = originalReadFileSync; + }); + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URI = mongoServer.getUri(); + process.env.PORT = '0'; // Use a random available port + app = require('~/server'); + + // Wait for the app to be healthy + await healthCheckPoll(app); + }); + + afterAll(async () => { + await mongoServer.stop(); + await mongoose.disconnect(); + }); + + it('should return OK for /health', async () => { + const response = await request(app).get('/health'); + expect(response.status).toBe(200); + expect(response.text).toBe('OK'); + }); + + it('should not cache index page', async () => { + const response = await request(app).get('/'); + expect(response.status).toBe(200); + expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate'); + expect(response.headers['pragma']).toBe('no-cache'); + expect(response.headers['expires']).toBe('0'); + }); +}); + +// Polls the /health endpoint every 30ms for up to 10 seconds to wait for the server to start completely +async function healthCheckPoll(app, retries = 0) { + const maxRetries = Math.floor(10000 / 30); // 10 seconds / 30ms + try { + const response = await request(app).get('/health'); + if (response.status === 200) { + return; // App is healthy + } + } catch (error) { + // Ignore connection errors during polling + } + + if (retries < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 30)); + await healthCheckPoll(app, retries + 1); + } else { + throw new Error('App did not become healthy within 10 seconds.'); + } +} diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 07a32b9161..d5958c2b5c 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -74,6 +74,7 @@ router.get('/', async function (req, res) { process.env.SHOW_BIRTHDAY_ICON === '', helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai', interface: req.app.locals.interfaceConfig, + turnstile: req.app.locals.turnstileConfig, modelSpecs: req.app.locals.modelSpecs, balance: req.app.locals.balance, sharedLinksEnabled, diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 5a520bdb65..d2914825b0 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -121,6 +121,14 @@ router.delete('/', async (req, res) => { await processDeleteRequest({ req, files: assistantFiles }); res.status(200).json({ message: 'File associations removed successfully from assistant' }); return; + } else if ( + req.body.assistant_id && + req.body.files?.[0]?.filepath === EModelEndpoint.azureAssistants + ) { + await processDeleteRequest({ req, files: req.body.files }); + return res + .status(200) + .json({ message: 'File associations removed successfully from Azure Assistant' }); } await processDeleteRequest({ req, files: dbFiles }); diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index 1ad3aaace6..5f119e67aa 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -12,6 +12,7 @@ const { initializeFirebase } = require('./Files/Firebase/initialize'); const loadCustomConfig = require('./Config/loadCustomConfig'); const handleRateLimits = require('./Config/handleRateLimits'); const { loadDefaultInterface } = require('./start/interface'); +const { loadTurnstileConfig } = require('./start/turnstile'); const { azureConfigSetup } = require('./start/azureOpenAI'); const { processModelSpecs } = require('./start/modelSpecs'); const { initializeS3 } = require('./Files/S3/initialize'); @@ -23,7 +24,6 @@ const { getMCPManager } = require('~/config'); const paths = require('~/config/paths'); /** - * * Loads custom config and initializes app-wide variables. * @function AppService * @param {Express.Application} app - The Express application object. @@ -74,6 +74,7 @@ const AppService = async (app) => { const socialLogins = config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins; const interfaceConfig = await loadDefaultInterface(config, configDefaults); + const turnstileConfig = loadTurnstileConfig(config, configDefaults); const defaultLocals = { ocr, @@ -85,6 +86,7 @@ const AppService = async (app) => { availableTools, imageOutputType, interfaceConfig, + turnstileConfig, balance, }; diff --git a/api/server/services/AppService.spec.js b/api/server/services/AppService.spec.js index 465ec9fdd6..81a017e41e 100644 --- a/api/server/services/AppService.spec.js +++ b/api/server/services/AppService.spec.js @@ -46,6 +46,12 @@ jest.mock('./ToolService', () => ({ }, }), })); +jest.mock('./start/turnstile', () => ({ + loadTurnstileConfig: jest.fn(() => ({ + siteKey: 'default-site-key', + options: {}, + })), +})); const azureGroups = [ { @@ -86,6 +92,10 @@ const azureGroups = [ describe('AppService', () => { let app; + const mockedTurnstileConfig = { + siteKey: 'default-site-key', + options: {}, + }; beforeEach(() => { app = { locals: {} }; @@ -107,6 +117,7 @@ describe('AppService', () => { sidePanel: true, presets: true, }), + turnstileConfig: mockedTurnstileConfig, modelSpecs: undefined, availableTools: { ExampleTool: { diff --git a/api/server/services/Config/getCustomConfig.js b/api/server/services/Config/getCustomConfig.js index fdd84878eb..74828789fc 100644 --- a/api/server/services/Config/getCustomConfig.js +++ b/api/server/services/Config/getCustomConfig.js @@ -10,17 +10,7 @@ const getLogStores = require('~/cache/getLogStores'); * */ async function getCustomConfig() { const cache = getLogStores(CacheKeys.CONFIG_STORE); - let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG); - - if (!customConfig) { - customConfig = await loadCustomConfig(); - } - - if (!customConfig) { - return null; - } - - return customConfig; + return (await cache.get(CacheKeys.CUSTOM_CONFIG)) || (await loadCustomConfig()); } /** diff --git a/api/server/services/Config/loadConfigEndpoints.js b/api/server/services/Config/loadConfigEndpoints.js index 03d8c22367..2e80fb42be 100644 --- a/api/server/services/Config/loadConfigEndpoints.js +++ b/api/server/services/Config/loadConfigEndpoints.js @@ -29,7 +29,14 @@ async function loadConfigEndpoints(req) { for (let i = 0; i < customEndpoints.length; i++) { const endpoint = customEndpoints[i]; - const { baseURL, apiKey, name: configName, iconURL, modelDisplayLabel } = endpoint; + const { + baseURL, + apiKey, + name: configName, + iconURL, + modelDisplayLabel, + customParams, + } = endpoint; const name = normalizeEndpointName(configName); const resolvedApiKey = extractEnvVariable(apiKey); @@ -41,6 +48,7 @@ async function loadConfigEndpoints(req) { userProvideURL: isUserProvided(resolvedBaseURL), modelDisplayLabel, iconURL, + customParams, }; } } diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 2127ec239e..18f3a44748 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -1,10 +1,18 @@ const path = require('path'); -const { CacheKeys, configSchema, EImageOutputType } = require('librechat-data-provider'); +const { + CacheKeys, + configSchema, + EImageOutputType, + validateSettingDefinitions, + agentParamSettings, + paramSettings, +} = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); const loadYaml = require('~/utils/loadYaml'); const { logger } = require('~/config'); const axios = require('axios'); const yaml = require('js-yaml'); +const keyBy = require('lodash/keyBy'); const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml'); @@ -105,6 +113,10 @@ https://www.librechat.ai/docs/configuration/stt_tts`); logger.debug('Custom config:', customConfig); } + (customConfig.endpoints?.custom ?? []) + .filter((endpoint) => endpoint.customParams) + .forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams)); + if (customConfig.cache) { const cache = getLogStores(CacheKeys.CONFIG_STORE); await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig); @@ -117,4 +129,52 @@ https://www.librechat.ai/docs/configuration/stt_tts`); return customConfig; } +// Validate and fill out missing values for custom parameters +function parseCustomParams(endpointName, customParams) { + const paramEndpoint = customParams.defaultParamsEndpoint; + customParams.paramDefinitions = customParams.paramDefinitions || []; + + // Checks if `defaultParamsEndpoint` is a key in `paramSettings`. + const validEndpoints = new Set([ + ...Object.keys(paramSettings), + ...Object.keys(agentParamSettings), + ]); + if (!validEndpoints.has(paramEndpoint)) { + throw new Error( + `defaultParamsEndpoint of "${endpointName}" endpoint is invalid. ` + + `Valid options are ${Array.from(validEndpoints).join(', ')}`, + ); + } + + // creates default param maps + const regularParams = paramSettings[paramEndpoint] ?? []; + const agentParams = agentParamSettings[paramEndpoint] ?? []; + const defaultParams = regularParams.concat(agentParams); + const defaultParamsMap = keyBy(defaultParams, 'key'); + + // TODO: Remove this check once we support new parameters not part of default parameters. + // Checks if every key in `paramDefinitions` is valid. + const validKeys = new Set(Object.keys(defaultParamsMap)); + const paramKeys = customParams.paramDefinitions.map((param) => param.key); + if (paramKeys.some((key) => !validKeys.has(key))) { + throw new Error( + `paramDefinitions of "${endpointName}" endpoint contains invalid key(s). ` + + `Valid parameter keys are ${Array.from(validKeys).join(', ')}`, + ); + } + + // Fill out missing values for custom param definitions + customParams.paramDefinitions = customParams.paramDefinitions.map((param) => { + return { ...defaultParamsMap[param.key], ...param, optionType: 'custom' }; + }); + + try { + validateSettingDefinitions(customParams.paramDefinitions); + } catch (e) { + throw new Error( + `Custom parameter definitions for "${endpointName}" endpoint is malformed: ${e.message}`, + ); + } +} + module.exports = loadCustomConfig; diff --git a/api/server/services/Config/loadCustomConfig.spec.js b/api/server/services/Config/loadCustomConfig.spec.js index 24553b9f3e..ed698e57f1 100644 --- a/api/server/services/Config/loadCustomConfig.spec.js +++ b/api/server/services/Config/loadCustomConfig.spec.js @@ -1,6 +1,34 @@ jest.mock('axios'); jest.mock('~/cache/getLogStores'); jest.mock('~/utils/loadYaml'); +jest.mock('librechat-data-provider', () => { + const actual = jest.requireActual('librechat-data-provider'); + return { + ...actual, + paramSettings: { foo: {}, bar: {}, custom: {} }, + agentParamSettings: { + custom: [], + google: [ + { + key: 'pressure', + type: 'string', + component: 'input', + }, + { + key: 'temperature', + type: 'number', + component: 'slider', + default: 0.5, + range: { + min: 0, + max: 2, + step: 0.01, + }, + }, + ], + }, + }; +}); const axios = require('axios'); const loadCustomConfig = require('./loadCustomConfig'); @@ -150,4 +178,126 @@ describe('loadCustomConfig', () => { expect(logger.info).toHaveBeenCalledWith(JSON.stringify(mockConfig, null, 2)); expect(logger.debug).toHaveBeenCalledWith('Custom config:', mockConfig); }); + + describe('parseCustomParams', () => { + const mockConfig = { + version: '1.0', + cache: false, + endpoints: { + custom: [ + { + name: 'Google', + apiKey: 'user_provided', + customParams: {}, + }, + ], + }, + }; + + async function loadCustomParams(customParams) { + mockConfig.endpoints.custom[0].customParams = customParams; + loadYaml.mockReturnValue(mockConfig); + return await loadCustomConfig(); + } + + beforeEach(() => { + jest.resetAllMocks(); + process.env.CONFIG_PATH = 'validConfig.yaml'; + }); + + it('returns no error when customParams is undefined', async () => { + const result = await loadCustomParams(undefined); + expect(result).toEqual(mockConfig); + }); + + it('returns no error when customParams is valid', async () => { + const result = await loadCustomParams({ + defaultParamsEndpoint: 'google', + paramDefinitions: [ + { + key: 'temperature', + default: 0.5, + }, + ], + }); + expect(result).toEqual(mockConfig); + }); + + it('throws an error when paramDefinitions contain unsupported keys', async () => { + const malformedCustomParams = { + defaultParamsEndpoint: 'google', + paramDefinitions: [ + { key: 'temperature', default: 0.5 }, + { key: 'unsupportedKey', range: 0.5 }, + ], + }; + await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow( + 'paramDefinitions of "Google" endpoint contains invalid key(s). Valid parameter keys are pressure, temperature', + ); + }); + + it('throws an error when paramDefinitions is malformed', async () => { + const malformedCustomParams = { + defaultParamsEndpoint: 'google', + paramDefinitions: [ + { + key: 'temperature', + type: 'noomba', + component: 'inpoot', + optionType: 'custom', + }, + ], + }; + await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow( + /Custom parameter definitions for "Google" endpoint is malformed:/, + ); + }); + + it('throws an error when defaultParamsEndpoint is not provided', async () => { + const malformedCustomParams = { defaultParamsEndpoint: undefined }; + await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow( + 'defaultParamsEndpoint of "Google" endpoint is invalid. Valid options are foo, bar, custom, google', + ); + }); + + it('fills the paramDefinitions with missing values', async () => { + const customParams = { + defaultParamsEndpoint: 'google', + paramDefinitions: [ + { key: 'temperature', default: 0.7, range: { min: 0.1, max: 0.9, step: 0.1 } }, + { key: 'pressure', component: 'textarea' }, + ], + }; + + const parsedConfig = await loadCustomParams(customParams); + const paramDefinitions = parsedConfig.endpoints.custom[0].customParams.paramDefinitions; + expect(paramDefinitions).toEqual([ + { + columnSpan: 1, + component: 'slider', + default: 0.7, // overridden + includeInput: true, + key: 'temperature', + label: 'temperature', + optionType: 'custom', + range: { + // overridden + max: 0.9, + min: 0.1, + step: 0.1, + }, + type: 'number', + }, + { + columnSpan: 1, + component: 'textarea', // overridden + key: 'pressure', + label: 'pressure', + optionType: 'custom', + placeholder: '', + type: 'string', + }, + ]); + }); + }); }); diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index 592440db54..39def8d0d5 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -105,6 +105,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid headers: resolvedHeaders, addParams: endpointConfig.addParams, dropParams: endpointConfig.dropParams, + customParams: endpointConfig.customParams, titleConvo: endpointConfig.titleConvo, titleModel: endpointConfig.titleModel, forcePrompt: endpointConfig.forcePrompt, diff --git a/api/server/services/Files/OpenAI/crud.js b/api/server/services/Files/OpenAI/crud.js index 64478ce7b3..a55485fe4b 100644 --- a/api/server/services/Files/OpenAI/crud.js +++ b/api/server/services/Files/OpenAI/crud.js @@ -54,7 +54,7 @@ async function deleteOpenAIFile(req, file, openai) { throw new Error('OpenAI returned `false` for deleted status'); } logger.debug( - `[deleteOpenAIFile] User ${req.user.id} successfully deleted ${file.file_id} from OpenAI`, + `[deleteOpenAIFile] User ${req.user.id} successfully deleted file "${file.file_id}" from OpenAI`, ); } catch (error) { logger.error('[deleteOpenAIFile] Error deleting file from OpenAI: ' + error.message); diff --git a/api/server/services/Files/images/resize.js b/api/server/services/Files/images/resize.js index 50bec1ef3b..c2cdaacb63 100644 --- a/api/server/services/Files/images/resize.js +++ b/api/server/services/Files/images/resize.js @@ -5,9 +5,10 @@ const { EModelEndpoint } = require('librechat-data-provider'); * Resizes an image from a given buffer based on the specified resolution. * * @param {Buffer} inputBuffer - The buffer of the image to be resized. - * @param {'low' | 'high'} resolution - The resolution to resize the image to. + * @param {'low' | 'high' | {percentage?: number, px?: number}} resolution - The resolution to resize the image to. * 'low' for a maximum of 512x512 resolution, - * 'high' for a maximum of 768x2000 resolution. + * 'high' for a maximum of 768x2000 resolution, + * or a custom object with percentage or px values. * @param {EModelEndpoint} endpoint - Identifier for specific endpoint handling * @returns {Promise<{buffer: Buffer, width: number, height: number}>} An object containing the resized image buffer and its dimensions. * @throws Will throw an error if the resolution parameter is invalid. @@ -17,10 +18,32 @@ async function resizeImageBuffer(inputBuffer, resolution, endpoint) { const maxShortSideHighRes = 768; const maxLongSideHighRes = endpoint === EModelEndpoint.anthropic ? 1568 : 2000; + let customPercent, customPx; + if (resolution && typeof resolution === 'object') { + if (typeof resolution.percentage === 'number') { + customPercent = resolution.percentage; + } else if (typeof resolution.px === 'number') { + customPx = resolution.px; + } + } + let newWidth, newHeight; let resizeOptions = { fit: 'inside', withoutEnlargement: true }; - if (resolution === 'low') { + if (customPercent != null || customPx != null) { + // percentage-based resize + const metadata = await sharp(inputBuffer).metadata(); + if (customPercent != null) { + newWidth = Math.round(metadata.width * (customPercent / 100)); + newHeight = Math.round(metadata.height * (customPercent / 100)); + } else { + // pixel max on both sides + newWidth = Math.min(metadata.width, customPx); + newHeight = Math.min(metadata.height, customPx); + } + resizeOptions.width = newWidth; + resizeOptions.height = newHeight; + } else if (resolution === 'low') { resizeOptions.width = maxLowRes; resizeOptions.height = maxLowRes; } else if (resolution === 'high') { diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 81a4f52855..94b1bc4dad 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -137,11 +137,13 @@ const processDeleteRequest = async ({ req, files }) => { /** @type {Record} */ const client = { [FileSources.openai]: undefined, [FileSources.azure]: undefined }; const initializeClients = async () => { - const openAIClient = await getOpenAIClient({ - req, - overrideEndpoint: EModelEndpoint.assistants, - }); - client[FileSources.openai] = openAIClient.openai; + if (req.app.locals[EModelEndpoint.assistants]) { + const openAIClient = await getOpenAIClient({ + req, + overrideEndpoint: EModelEndpoint.assistants, + }); + client[FileSources.openai] = openAIClient.openai; + } if (!req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) { return; @@ -693,7 +695,7 @@ const processOpenAIFile = async ({ const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileExt }) => { const currentDate = new Date(); const formattedDate = currentDate.toISOString(); - const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`); + const _file = await convertImage(req, buffer, undefined, `${file_id}${fileExt}`); const file = { ..._file, usage: 1, @@ -838,8 +840,9 @@ function base64ToBuffer(base64String) { async function saveBase64Image( url, - { req, file_id: _file_id, filename: _filename, endpoint, context, resolution = 'high' }, + { req, file_id: _file_id, filename: _filename, endpoint, context, resolution }, ) { + const effectiveResolution = resolution ?? req.app.locals.fileConfig?.imageGeneration ?? 'high'; const file_id = _file_id ?? v4(); let filename = `${file_id}-${_filename}`; const { buffer: inputBuffer, type } = base64ToBuffer(url); @@ -852,7 +855,7 @@ async function saveBase64Image( } } - const image = await resizeImageBuffer(inputBuffer, resolution, endpoint); + const image = await resizeImageBuffer(inputBuffer, effectiveResolution, endpoint); const source = req.app.locals.fileStrategy; const { saveBuffer } = getStrategyFunctions(source); const filepath = await saveBuffer({ diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 1d4fc5112c..b9baef462e 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -1,5 +1,6 @@ const { z } = require('zod'); const { tool } = require('@langchain/core/tools'); +const { normalizeServerName } = require('librechat-mcp'); const { Constants: AgentConstants, Providers } = require('@librechat/agents'); const { Constants, @@ -38,6 +39,7 @@ async function createMCPTool({ req, toolKey, provider: _provider }) { } const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter); + const normalizedToolKey = `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`; if (!req.user?.id) { logger.error( @@ -83,7 +85,7 @@ async function createMCPTool({ req, toolKey, provider: _provider }) { const toolInstance = tool(_call, { schema, - name: toolKey, + name: normalizedToolKey, description: description || '', responseFormat: AgentConstants.CONTENT_AND_ARTIFACT, }); diff --git a/api/server/services/start/turnstile.js b/api/server/services/start/turnstile.js new file mode 100644 index 0000000000..be9e5f83c7 --- /dev/null +++ b/api/server/services/start/turnstile.js @@ -0,0 +1,44 @@ +const { removeNullishValues } = require('librechat-data-provider'); +const { logger } = require('~/config'); + +/** + * 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/server/utils/staticCache.js b/api/server/utils/staticCache.js index 23713ddf6f..5925a56be5 100644 --- a/api/server/utils/staticCache.js +++ b/api/server/utils/staticCache.js @@ -14,6 +14,7 @@ const staticCache = (staticPath) => res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`); } }, + index: false, }); module.exports = staticCache; diff --git a/api/strategies/ldapStrategy.js b/api/strategies/ldapStrategy.js index 5ec279b982..beb9b8c2fd 100644 --- a/api/strategies/ldapStrategy.js +++ b/api/strategies/ldapStrategy.js @@ -23,7 +23,7 @@ const { // Check required environment variables if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) { - return null; + module.exports = null; } const searchAttributes = [ diff --git a/api/test/__mocks__/logger.js b/api/test/__mocks__/logger.js index 549c57d5a4..f9f6d78c87 100644 --- a/api/test/__mocks__/logger.js +++ b/api/test/__mocks__/logger.js @@ -8,6 +8,7 @@ jest.mock('winston', () => { mockFormatFunction.printf = jest.fn(); mockFormatFunction.errors = jest.fn(); mockFormatFunction.splat = jest.fn(); + mockFormatFunction.json = jest.fn(); return { format: mockFormatFunction, createLogger: jest.fn().mockReturnValue({ @@ -19,6 +20,7 @@ jest.mock('winston', () => { transports: { Console: jest.fn(), DailyRotateFile: jest.fn(), + File: jest.fn(), }, addColors: jest.fn(), }; diff --git a/api/test/jestSetup.js b/api/test/jestSetup.js index f84b90743a..ed92afd214 100644 --- a/api/test/jestSetup.js +++ b/api/test/jestSetup.js @@ -6,3 +6,7 @@ process.env.BAN_VIOLATIONS = 'true'; process.env.BAN_DURATION = '7200000'; process.env.BAN_INTERVAL = '20'; process.env.CI = 'true'; +process.env.JWT_SECRET = 'test'; +process.env.JWT_REFRESH_SECRET = 'test'; +process.env.CREDS_KEY = 'test'; +process.env.CREDS_IV = 'test'; diff --git a/client/package.json b/client/package.json index 5fd9729a74..1b33c37919 100644 --- a/client/package.json +++ b/client/package.json @@ -34,6 +34,7 @@ "@dicebear/collection": "^9.2.2", "@dicebear/core": "^9.2.2", "@headlessui/react": "^2.1.2", + "@marsidev/react-turnstile": "^1.1.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.2", "@radix-ui/react-checkbox": "^1.0.3", diff --git a/client/src/common/types.ts b/client/src/common/types.ts index cd8b45f6b7..ab52bfb007 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -535,6 +535,7 @@ export type NewConversationParams = { buildDefault?: boolean; keepLatestMessage?: boolean; keepAddedConvos?: boolean; + disableParams?: boolean; }; export type ConvoGenerator = (params: NewConversationParams) => void | t.TConversation; diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index 2cd62d08b9..1e2aa9b3d9 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -1,9 +1,10 @@ import { useForm } from 'react-hook-form'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useContext } from 'react'; +import { Turnstile } from '@marsidev/react-turnstile'; import type { TLoginUser, TStartupConfig } from 'librechat-data-provider'; import type { TAuthContext } from '~/common'; import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider'; -import { useLocalize } from '~/hooks'; +import { ThemeContext, useLocalize } from '~/hooks'; type TLoginFormProps = { onSubmit: (data: TLoginUser) => void; @@ -14,6 +15,8 @@ type TLoginFormProps = { const LoginForm: React.FC = ({ onSubmit, startupConfig, error, setError }) => { const localize = useLocalize(); + const { theme } = useContext(ThemeContext); + const { register, getValues, @@ -21,9 +24,12 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, formState: { errors }, } = useForm(); const [showResendLink, setShowResendLink] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(null); const { data: config } = useGetStartupConfig(); const useUsernameLogin = config?.ldap?.username; + const validTheme = theme === 'dark' ? 'dark' : 'light'; + const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey); useEffect(() => { if (error && error.includes('422') && !showResendLink) { @@ -96,20 +102,12 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, }, })} aria-invalid={!!errors.email} - className=" - webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light - bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none - " + className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none" placeholder=" " />