diff --git a/api/app/clients/tools/DALL-E.js b/api/app/clients/tools/DALL-E.js index 4eca7f7932..d3cdaa7134 100644 --- a/api/app/clients/tools/DALL-E.js +++ b/api/app/clients/tools/DALL-E.js @@ -8,15 +8,6 @@ const { processFileURL } = require('~/server/services/Files/process'); const extractBaseURL = require('~/utils/extractBaseURL'); const { logger } = require('~/config'); -const { - DALLE2_SYSTEM_PROMPT, - DALLE_REVERSE_PROXY, - PROXY, - DALLE2_AZURE_API_VERSION, - DALLE2_BASEURL, - DALLE2_API_KEY, - DALLE_API_KEY, -} = process.env; class OpenAICreateImage extends Tool { constructor(fields = {}) { super(); @@ -26,19 +17,22 @@ class OpenAICreateImage extends Tool { let apiKey = fields.DALLE2_API_KEY ?? fields.DALLE_API_KEY ?? this.getApiKey(); const config = { apiKey }; - if (DALLE_REVERSE_PROXY) { - config.baseURL = extractBaseURL(DALLE_REVERSE_PROXY); + if (process.env.DALLE_REVERSE_PROXY) { + config.baseURL = extractBaseURL(process.env.DALLE_REVERSE_PROXY); } - if (DALLE2_AZURE_API_VERSION && DALLE2_BASEURL) { - config.baseURL = DALLE2_BASEURL; - config.defaultQuery = { 'api-version': DALLE2_AZURE_API_VERSION }; - config.defaultHeaders = { 'api-key': DALLE2_API_KEY, 'Content-Type': 'application/json' }; - config.apiKey = DALLE2_API_KEY; + if (process.env.DALLE2_AZURE_API_VERSION && process.env.DALLE2_BASEURL) { + config.baseURL = process.env.DALLE2_BASEURL; + config.defaultQuery = { 'api-version': process.env.DALLE2_AZURE_API_VERSION }; + config.defaultHeaders = { + 'api-key': process.env.DALLE2_API_KEY, + 'Content-Type': 'application/json', + }; + config.apiKey = process.env.DALLE2_API_KEY; } - if (PROXY) { - config.httpAgent = new HttpsProxyAgent(PROXY); + if (process.env.PROXY) { + config.httpAgent = new HttpsProxyAgent(process.env.PROXY); } this.openai = new OpenAI(config); @@ -51,7 +45,7 @@ Guidelines: "Subject: [subject], Style: [style], Color: [color], Details: [details], Emotion: [emotion]" - Generate images only once per human query unless explicitly requested by the user`; this.description_for_model = - DALLE2_SYSTEM_PROMPT ?? + process.env.DALLE2_SYSTEM_PROMPT ?? `// Whenever a description of an image is given, generate prompts (following these rules), and use dalle to create the image. If the user does not ask for a specific number of images, default to creating 2 prompts to send to dalle that are written to be as diverse as possible. All prompts sent to dalle must abide by the following policies: // 1. Prompts must be in English. Translate to English if needed. // 2. One image per function call. Create only 1 image per request unless explicitly told to generate more than 1 image. @@ -67,7 +61,7 @@ Guidelines: } getApiKey() { - const apiKey = DALLE2_API_KEY ?? DALLE_API_KEY ?? ''; + const apiKey = process.env.DALLE2_API_KEY ?? process.env.DALLE_API_KEY ?? ''; if (!apiKey) { throw new Error('Missing DALLE_API_KEY environment variable.'); } diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index bde4c8a87a..1a03895a85 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -89,7 +89,7 @@ "icon": "https://i.imgur.com/u2TzXzH.png", "authConfig": [ { - "authField": "DALLE2_API_KEY", + "authField": "DALLE2_API_KEY||DALLE_API_KEY", "label": "OpenAI API Key", "description": "You can use DALL-E with your API Key from OpenAI." } @@ -102,7 +102,7 @@ "icon": "https://i.imgur.com/u2TzXzH.png", "authConfig": [ { - "authField": "DALLE3_API_KEY", + "authField": "DALLE3_API_KEY||DALLE_API_KEY", "label": "OpenAI API Key", "description": "You can use DALL-E with your API Key from OpenAI." } diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index 33df93e7fc..92cf5b2a76 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -9,14 +9,6 @@ const { processFileURL } = require('~/server/services/Files/process'); const extractBaseURL = require('~/utils/extractBaseURL'); const { logger } = require('~/config'); -const { - DALLE3_SYSTEM_PROMPT, - DALLE_REVERSE_PROXY, - PROXY, - DALLE3_AZURE_API_VERSION, - DALLE3_BASEURL, - DALLE3_API_KEY, -} = process.env; class DALLE3 extends Tool { constructor(fields = {}) { super(); @@ -25,19 +17,22 @@ class DALLE3 extends Tool { this.fileStrategy = fields.fileStrategy; let apiKey = fields.DALLE3_API_KEY ?? fields.DALLE_API_KEY ?? this.getApiKey(); const config = { apiKey }; - if (DALLE_REVERSE_PROXY) { - config.baseURL = extractBaseURL(DALLE_REVERSE_PROXY); + if (process.env.DALLE_REVERSE_PROXY) { + config.baseURL = extractBaseURL(process.env.DALLE_REVERSE_PROXY); } - if (DALLE3_AZURE_API_VERSION && DALLE3_BASEURL) { - config.baseURL = DALLE3_BASEURL; - config.defaultQuery = { 'api-version': DALLE3_AZURE_API_VERSION }; - config.defaultHeaders = { 'api-key': DALLE3_API_KEY, 'Content-Type': 'application/json' }; - config.apiKey = DALLE3_API_KEY; + if (process.env.DALLE3_AZURE_API_VERSION && process.env.DALLE3_BASEURL) { + config.baseURL = process.env.DALLE3_BASEURL; + config.defaultQuery = { 'api-version': process.env.DALLE3_AZURE_API_VERSION }; + config.defaultHeaders = { + 'api-key': process.env.DALLE3_API_KEY, + 'Content-Type': 'application/json', + }; + config.apiKey = process.env.DALLE3_API_KEY; } - if (PROXY) { - config.httpAgent = new HttpsProxyAgent(PROXY); + if (process.env.PROXY) { + config.httpAgent = new HttpsProxyAgent(process.env.PROXY); } this.openai = new OpenAI(config); @@ -47,7 +42,7 @@ class DALLE3 extends Tool { - Create only one image, without repeating or listing descriptions outside the "prompts" field. - Maintains the original intent of the description, with parameters for image style, quality, and size to tailor the output.`; this.description_for_model = - DALLE3_SYSTEM_PROMPT ?? + process.env.DALLE3_SYSTEM_PROMPT ?? `// Whenever a description of an image is given, generate prompts (following these rules), and use dalle to create the image. If the user does not ask for a specific number of images, default to creating 2 prompts to send to dalle that are written to be as diverse as possible. All prompts sent to dalle must abide by the following policies: // 1. Prompts must be in English. Translate to English if needed. // 2. One image per function call. Create only 1 image per request unless explicitly told to generate more than 1 image. diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 1d9a3a0074..4a1e4e09b0 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -30,6 +30,14 @@ const getOpenAIKey = async (options, user) => { return openAIApiKey || (await getUserPluginAuthValue(user, 'OPENAI_API_KEY')); }; +/** + * Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values. + * Tools without required authentication or with valid authentication are considered valid. + * + * @param {Object} user The user object for whom to validate tool access. + * @param {Array} tools An array of tool identifiers to validate. Defaults to an empty array. + * @returns {Promise>} A promise that resolves to an array of valid tool identifiers. + */ const validateTools = async (user, tools = []) => { try { const validToolsSet = new Set(tools); @@ -37,16 +45,34 @@ const validateTools = async (user, tools = []) => { validToolsSet.has(tool.pluginKey), ); + /** + * Validates the credentials for a given auth field or set of alternate auth fields for a tool. + * If valid admin or user authentication is found, the function returns early. Otherwise, it removes the tool from the set of valid tools. + * + * @param {string} authField The authentication field or fields (separated by "||" for alternates) to validate. + * @param {string} toolName The identifier of the tool being validated. + */ const validateCredentials = async (authField, toolName) => { - const adminAuth = process.env[authField]; - if (adminAuth && adminAuth.length > 0) { - return; + const fields = authField.split('||'); + for (const field of fields) { + const adminAuth = process.env[field]; + if (adminAuth && adminAuth.length > 0) { + return; + } + + let userAuth = null; + try { + userAuth = await getUserPluginAuthValue(user, field); + } catch (err) { + if (field === fields[fields.length - 1] && !userAuth) { + throw err; + } + } + if (userAuth && userAuth.length > 0) { + return; + } } - const userAuth = await getUserPluginAuthValue(user, authField); - if (userAuth && userAuth.length > 0) { - return; - } validToolsSet.delete(toolName); }; @@ -63,20 +89,55 @@ const validateTools = async (user, tools = []) => { return Array.from(validToolsSet.values()); } catch (err) { logger.error('[validateTools] There was a problem validating tools', err); - throw new Error(err); + throw new Error('There was a problem validating tools'); } }; -const loadToolWithAuth = async (userId, authFields, ToolConstructor, options = {}) => { +/** + * Initializes a tool with authentication values for the given user, supporting alternate authentication fields. + * Authentication fields can have alternates separated by "||", and the first defined variable will be used. + * + * @param {string} userId The user ID for which the tool is being loaded. + * @param {Array} authFields Array of strings representing the authentication fields. Supports alternate fields delimited by "||". + * @param {typeof import('langchain/tools').Tool} ToolConstructor The constructor function for the tool to be initialized. + * @param {Object} options Optional parameters to be passed to the tool constructor alongside authentication values. + * @returns {Function} An Async function that, when called, asynchronously initializes and returns an instance of the tool with authentication. + */ +const loadToolWithAuth = (userId, authFields, ToolConstructor, options = {}) => { return async function () { let authValues = {}; - for (const authField of authFields) { - let authValue = process.env[authField]; - if (!authValue) { - authValue = await getUserPluginAuthValue(userId, authField); + /** + * Finds the first non-empty value for the given authentication field, supporting alternate fields. + * @param {string[]} fields Array of strings representing the authentication fields. Supports alternate fields delimited by "||". + * @returns {Promise<{ authField: string, authValue: string} | null>} An object containing the authentication field and value, or null if not found. + */ + const findAuthValue = async (fields) => { + for (const field of fields) { + let value = process.env[field]; + if (value) { + return { authField: field, authValue: value }; + } + try { + value = await getUserPluginAuthValue(userId, field); + } catch (err) { + if (field === fields[fields.length - 1] && !value) { + throw err; + } + } + if (value) { + return { authField: field, authValue: value }; + } + } + return null; + }; + + for (let authField of authFields) { + const fields = authField.split('||'); + const result = await findAuthValue(fields); + if (result) { + authValues[result.authField] = result.authValue; } - authValues[authField] = authValue; } return new ToolConstructor({ ...options, ...authValues, userId }); @@ -194,7 +255,7 @@ const loadTools = async ({ if (toolConstructors[tool]) { const options = toolOptions[tool] || {}; - const toolInstance = await loadToolWithAuth( + const toolInstance = loadToolWithAuth( user, toolAuthFields[tool], toolConstructors[tool], @@ -250,6 +311,7 @@ const loadTools = async ({ }; module.exports = { + loadToolWithAuth, validateTools, loadTools, }; diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js index 3586495515..2c97771427 100644 --- a/api/app/clients/tools/util/handleTools.test.js +++ b/api/app/clients/tools/util/handleTools.test.js @@ -4,26 +4,33 @@ const mockUser = { findByIdAndDelete: jest.fn(), }; -var mockPluginService = { +const mockPluginService = { updateUserPluginAuth: jest.fn(), deleteUserPluginAuth: jest.fn(), getUserPluginAuthValue: jest.fn(), }; -jest.mock('../../../../models/User', () => { +jest.mock('~/models/User', () => { return function () { return mockUser; }; }); -jest.mock('../../../../server/services/PluginService', () => mockPluginService); +jest.mock('~/server/services/PluginService', () => mockPluginService); -const User = require('../../../../models/User'); -const { validateTools, loadTools } = require('./'); -const PluginService = require('../../../../server/services/PluginService'); -const { BaseChatModel } = require('langchain/chat_models/openai'); const { Calculator } = require('langchain/tools/calculator'); -const { availableTools, OpenAICreateImage, GoogleSearchAPI, StructuredSD } = require('../'); +const { BaseChatModel } = require('langchain/chat_models/openai'); + +const User = require('~/models/User'); +const PluginService = require('~/server/services/PluginService'); +const { validateTools, loadTools, loadToolWithAuth } = require('./handleTools'); +const { + availableTools, + OpenAICreateImage, + GoogleSearchAPI, + StructuredSD, + WolframAlphaAPI, +} = require('../'); describe('Tool Handlers', () => { let fakeUser; @@ -44,7 +51,10 @@ describe('Tool Handlers', () => { }); mockPluginService.updateUserPluginAuth.mockImplementation( (userId, authField, _pluginKey, credential) => { - userAuthValues[`${userId}-${authField}`] = credential; + const fields = authField.split('||'); + fields.forEach((field) => { + userAuthValues[`${userId}-${field}`] = credential; + }); }, ); @@ -134,6 +144,18 @@ describe('Tool Handlers', () => { loadTool2 = toolFunctions[sampleTools[1]]; loadTool3 = toolFunctions[sampleTools[2]]; }); + + let originalEnv; + + beforeEach(() => { + originalEnv = process.env; + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + it('returns the expected load functions for requested tools', async () => { expect(loadTool1).toBeDefined(); expect(loadTool2).toBeDefined(); @@ -150,6 +172,86 @@ describe('Tool Handlers', () => { expect(authTool).toBeInstanceOf(ToolClass); expect(tool).toBeInstanceOf(ToolClass2); }); + + it('should initialize an authenticated tool with primary auth field', async () => { + process.env.DALLE2_API_KEY = 'mocked_api_key'; + const initToolFunction = loadToolWithAuth( + 'userId', + ['DALLE2_API_KEY||DALLE_API_KEY'], + ToolClass, + ); + const authTool = await initToolFunction(); + + expect(authTool).toBeInstanceOf(ToolClass); + expect(mockPluginService.getUserPluginAuthValue).not.toHaveBeenCalled(); + }); + + it('should initialize an authenticated tool with alternate auth field when primary is missing', async () => { + delete process.env.DALLE2_API_KEY; // Ensure the primary key is not set + process.env.DALLE_API_KEY = 'mocked_alternate_api_key'; + const initToolFunction = loadToolWithAuth( + 'userId', + ['DALLE2_API_KEY||DALLE_API_KEY'], + ToolClass, + ); + const authTool = await initToolFunction(); + + expect(authTool).toBeInstanceOf(ToolClass); + expect(mockPluginService.getUserPluginAuthValue).toHaveBeenCalledTimes(1); + expect(mockPluginService.getUserPluginAuthValue).toHaveBeenCalledWith( + 'userId', + 'DALLE2_API_KEY', + ); + }); + + it('should fallback to getUserPluginAuthValue when env vars are missing', async () => { + mockPluginService.updateUserPluginAuth('userId', 'DALLE_API_KEY', 'dalle', 'mocked_api_key'); + const initToolFunction = loadToolWithAuth( + 'userId', + ['DALLE2_API_KEY||DALLE_API_KEY'], + ToolClass, + ); + const authTool = await initToolFunction(); + + expect(authTool).toBeInstanceOf(ToolClass); + expect(mockPluginService.getUserPluginAuthValue).toHaveBeenCalledTimes(2); + }); + + it('should initialize an authenticated tool with singular auth field', async () => { + process.env.WOLFRAM_APP_ID = 'mocked_app_id'; + const initToolFunction = loadToolWithAuth('userId', ['WOLFRAM_APP_ID'], WolframAlphaAPI); + const authTool = await initToolFunction(); + + expect(authTool).toBeInstanceOf(WolframAlphaAPI); + expect(mockPluginService.getUserPluginAuthValue).not.toHaveBeenCalled(); + }); + + it('should initialize an authenticated tool when env var is set', async () => { + process.env.WOLFRAM_APP_ID = 'mocked_app_id'; + const initToolFunction = loadToolWithAuth('userId', ['WOLFRAM_APP_ID'], WolframAlphaAPI); + const authTool = await initToolFunction(); + + expect(authTool).toBeInstanceOf(WolframAlphaAPI); + expect(mockPluginService.getUserPluginAuthValue).not.toHaveBeenCalledWith( + 'userId', + 'WOLFRAM_APP_ID', + ); + }); + + it('should fallback to getUserPluginAuthValue when singular env var is missing', async () => { + delete process.env.WOLFRAM_APP_ID; // Ensure the environment variable is not set + mockPluginService.getUserPluginAuthValue.mockResolvedValue('mocked_user_auth_value'); + const initToolFunction = loadToolWithAuth('userId', ['WOLFRAM_APP_ID'], WolframAlphaAPI); + const authTool = await initToolFunction(); + + expect(authTool).toBeInstanceOf(WolframAlphaAPI); + expect(mockPluginService.getUserPluginAuthValue).toHaveBeenCalledTimes(1); + expect(mockPluginService.getUserPluginAuthValue).toHaveBeenCalledWith( + 'userId', + 'WOLFRAM_APP_ID', + ); + }); + it('should throw an error for an unauthenticated tool', async () => { try { await loadTool2(); diff --git a/api/app/clients/tools/util/loadToolSuite.js b/api/app/clients/tools/util/loadToolSuite.js index 2b4500a4f7..ddfd621ea6 100644 --- a/api/app/clients/tools/util/loadToolSuite.js +++ b/api/app/clients/tools/util/loadToolSuite.js @@ -1,17 +1,48 @@ -const { getUserPluginAuthValue } = require('../../../../server/services/PluginService'); +const { getUserPluginAuthValue } = require('~/server/services/PluginService'); const { availableTools } = require('../'); -const loadToolSuite = async ({ pluginKey, tools, user, options }) => { +/** + * Loads a suite of tools with authentication values for a given user, supporting alternate authentication fields. + * Authentication fields can have alternates separated by "||", and the first defined variable will be used. + * + * @param {Object} params Parameters for loading the tool suite. + * @param {string} params.pluginKey Key identifying the plugin whose tools are to be loaded. + * @param {Array} params.tools Array of tool constructor functions. + * @param {Object} params.user User object for whom the tools are being loaded. + * @param {Object} [params.options={}] Optional parameters to be passed to each tool constructor. + * @returns {Promise} A promise that resolves to an array of instantiated tools. + */ +const loadToolSuite = async ({ pluginKey, tools, user, options = {} }) => { const authConfig = availableTools.find((tool) => tool.pluginKey === pluginKey).authConfig; const suite = []; const authValues = {}; - for (const auth of authConfig) { - let authValue = process.env[auth.authField]; - if (!authValue) { - authValue = await getUserPluginAuthValue(user, auth.authField); + const findAuthValue = async (authField) => { + const fields = authField.split('||'); + for (const field of fields) { + let value = process.env[field]; + if (value) { + return value; + } + try { + value = await getUserPluginAuthValue(user, field); + if (value) { + return value; + } + } catch (err) { + console.error(`Error fetching plugin auth value for ${field}: ${err.message}`); + } + } + return null; + }; + + for (const auth of authConfig) { + const authValue = await findAuthValue(auth.authField); + if (authValue !== null) { + authValues[auth.authField] = authValue; + } else { + console.warn(`No auth value found for ${auth.authField}`); } - authValues[auth.authField] = authValue; } for (const tool of tools) { diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index c37b36974e..b3c4d31ae7 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -4,6 +4,12 @@ const { CacheKeys } = require('librechat-data-provider'); const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs'); const { getLogStores } = require('~/cache'); +/** + * Filters out duplicate plugins from the list of plugins. + * + * @param {TPlugin[]} plugins The list of plugins to filter. + * @returns {TPlugin[]} The list of plugins with duplicates removed. + */ const filterUniquePlugins = (plugins) => { const seen = new Set(); return plugins.filter((plugin) => { @@ -13,17 +19,31 @@ const filterUniquePlugins = (plugins) => { }); }; +/** + * Determines if a plugin is authenticated by checking if all required authentication fields have non-empty values. + * Supports alternate authentication fields, allowing validation against multiple possible environment variables. + * + * @param {TPlugin} plugin The plugin object containing the authentication configuration. + * @returns {boolean} True if the plugin is authenticated for all required fields, false otherwise. + */ const isPluginAuthenticated = (plugin) => { if (!plugin.authConfig || plugin.authConfig.length === 0) { return false; } return plugin.authConfig.every((authFieldObj) => { - const envValue = process.env[authFieldObj.authField]; - if (envValue === 'user_provided') { - return false; + const authFieldOptions = authFieldObj.authField.split('||'); + let isFieldAuthenticated = false; + + for (const fieldOption of authFieldOptions) { + const envValue = process.env[fieldOption]; + if (envValue && envValue.trim() !== '' && envValue !== 'user_provided') { + isFieldAuthenticated = true; + break; + } } - return envValue && envValue.trim() !== ''; + + return isFieldAuthenticated; }); }; diff --git a/api/typedefs.js b/api/typedefs.js index dce3db037f..bb1f68cc8b 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -26,6 +26,12 @@ * @memberof typedefs */ +/** + * @exports TPlugin + * @typedef {import('librechat-data-provider').TPlugin} TPlugin + * @memberof typedefs + */ + /** * @exports TCustomConfig * @typedef {import('librechat-data-provider').TCustomConfig} TCustomConfig diff --git a/client/src/components/Plugins/Store/PluginAuthForm.tsx b/client/src/components/Plugins/Store/PluginAuthForm.tsx index 2b08bb9924..06accc794f 100644 --- a/client/src/components/Plugins/Store/PluginAuthForm.tsx +++ b/client/src/components/Plugins/Store/PluginAuthForm.tsx @@ -26,44 +26,47 @@ function PluginAuthForm({ plugin, onSubmit }: TPluginAuthFormProps) { onSubmit({ pluginKey: plugin?.pluginKey ?? '', action: 'install', auth }), )} > - {plugin?.authConfig?.map((config: TPluginAuthConfig, i: number) => ( -
- - - - - - - - {errors[config.authField] && ( - - {/* @ts-ignore - Type 'string | FieldError | Merge> | undefined' is not assignable to type 'ReactNode' */} - {errors[config.authField].message} - - )} -
- ))} + {plugin?.authConfig?.map((config: TPluginAuthConfig, i: number) => { + const authField = config.authField.split('||')[0]; + return ( +
+ + + + + + + + {errors[authField] && ( + + {/* @ts-ignore - Type 'string | FieldError | Merge> | undefined' is not assignable to type 'ReactNode' */} + {errors[authField].message} + + )} +
+ ); + })}