mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 08:12:00 +02:00

* chore: actions typing * fix(actions): implement request executor pattern to prevent concurrent execution issues BREAKING CHANGE: ActionRequest now uses a RequestExecutor pattern for isolated request state - Introduce RequestConfig class to store immutable configuration - Add RequestExecutor class to handle isolated request state for each execution - Modify ActionRequest to act as a facade creating new executors for each operation - Maintain backward compatibility through delegation and getters - Add TypeScript types for better type safety - Fix race conditions in concurrent executions with auth and params This change prevents state mutation issues when the same action is called multiple times concurrently, particularly when using authentication. Each request now gets its own isolated state through a new executor instance, solving race conditions while maintaining the existing API interface. * ci: test isolation/immutatability * chore: Update version to 0.7.51 in data-provider package * refactor(actions): refactor createActionTool to use request executor pattern
253 lines
8.2 KiB
JavaScript
253 lines
8.2 KiB
JavaScript
const {
|
|
CacheKeys,
|
|
Constants,
|
|
AuthTypeEnum,
|
|
actionDelimiter,
|
|
isImageVisionTool,
|
|
actionDomainSeparator,
|
|
} = require('librechat-data-provider');
|
|
const { tool } = require('@langchain/core/tools');
|
|
const { encryptV2, decryptV2 } = require('~/server/utils/crypto');
|
|
const { getActions, deleteActions } = require('~/models/Action');
|
|
const { deleteAssistant } = require('~/models/Assistant');
|
|
const { getLogStores } = require('~/cache');
|
|
const { logger } = require('~/config');
|
|
|
|
const toolNameRegex = /^[a-zA-Z0-9_-]+$/;
|
|
|
|
/**
|
|
* Validates tool name against regex pattern and updates if necessary.
|
|
* @param {object} params - The parameters for the function.
|
|
* @param {object} params.req - Express Request.
|
|
* @param {FunctionTool} params.tool - The tool object.
|
|
* @param {string} params.assistant_id - The assistant ID
|
|
* @returns {object|null} - Updated tool object or null if invalid and not an action.
|
|
*/
|
|
const validateAndUpdateTool = async ({ req, tool, assistant_id }) => {
|
|
let actions;
|
|
if (isImageVisionTool(tool)) {
|
|
return null;
|
|
}
|
|
if (!toolNameRegex.test(tool.function.name)) {
|
|
const [functionName, domain] = tool.function.name.split(actionDelimiter);
|
|
actions = await getActions({ assistant_id, user: req.user.id }, true);
|
|
const matchingActions = actions.filter((action) => {
|
|
const metadata = action.metadata;
|
|
return metadata && metadata.domain === domain;
|
|
});
|
|
const action = matchingActions[0];
|
|
if (!action) {
|
|
return null;
|
|
}
|
|
|
|
const parsedDomain = await domainParser(req, domain, true);
|
|
|
|
if (!parsedDomain) {
|
|
return null;
|
|
}
|
|
|
|
tool.function.name = `${functionName}${actionDelimiter}${parsedDomain}`;
|
|
}
|
|
return tool;
|
|
};
|
|
|
|
/**
|
|
* Encodes or decodes a domain name to/from base64, or replacing periods with a custom separator.
|
|
*
|
|
* Necessary due to `[a-zA-Z0-9_-]*` Regex Validation, limited to a 64-character maximum.
|
|
*
|
|
* @param {Express.Request} req - The Express Request object.
|
|
* @param {string} domain - The domain name to encode/decode.
|
|
* @param {boolean} inverse - False to decode from base64, true to encode to base64.
|
|
* @returns {Promise<string>} Encoded or decoded domain string.
|
|
*/
|
|
async function domainParser(req, domain, inverse = false) {
|
|
if (!domain) {
|
|
return;
|
|
}
|
|
|
|
const domainsCache = getLogStores(CacheKeys.ENCODED_DOMAINS);
|
|
const cachedDomain = await domainsCache.get(domain);
|
|
if (inverse && cachedDomain) {
|
|
return domain;
|
|
}
|
|
|
|
if (inverse && domain.length <= Constants.ENCODED_DOMAIN_LENGTH) {
|
|
return domain.replace(/\./g, actionDomainSeparator);
|
|
}
|
|
|
|
if (inverse) {
|
|
const modifiedDomain = Buffer.from(domain).toString('base64');
|
|
const key = modifiedDomain.substring(0, Constants.ENCODED_DOMAIN_LENGTH);
|
|
await domainsCache.set(key, modifiedDomain);
|
|
return key;
|
|
}
|
|
|
|
const replaceSeparatorRegex = new RegExp(actionDomainSeparator, 'g');
|
|
|
|
if (!cachedDomain) {
|
|
return domain.replace(replaceSeparatorRegex, '.');
|
|
}
|
|
|
|
try {
|
|
return Buffer.from(cachedDomain, 'base64').toString('utf-8');
|
|
} catch (error) {
|
|
logger.error(`Failed to parse domain (possibly not base64): ${domain}`, error);
|
|
return domain;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads action sets based on the user and assistant ID.
|
|
*
|
|
* @param {Object} searchParams - The parameters for loading action sets.
|
|
* @param {string} searchParams.user - The user identifier.
|
|
* @param {string} [searchParams.agent_id]- The agent identifier.
|
|
* @param {string} [searchParams.assistant_id]- The assistant identifier.
|
|
* @returns {Promise<Action[] | null>} A promise that resolves to an array of actions or `null` if no match.
|
|
*/
|
|
async function loadActionSets(searchParams) {
|
|
return await getActions(searchParams, true);
|
|
}
|
|
|
|
/**
|
|
* Creates a general tool for an entire action set.
|
|
*
|
|
* @param {Object} params - The parameters for loading action sets.
|
|
* @param {Action} params.action - The action set. Necessary for decrypting authentication values.
|
|
* @param {ActionRequest} params.requestBuilder - The ActionRequest builder class to execute the API call.
|
|
* @param {string | undefined} [params.name] - The name of the tool.
|
|
* @param {string | undefined} [params.description] - The description for the tool.
|
|
* @param {import('zod').ZodTypeAny | undefined} [params.zodSchema] - The Zod schema for tool input validation/definition
|
|
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
|
|
*/
|
|
async function createActionTool({ action, requestBuilder, zodSchema, name, description }) {
|
|
action.metadata = await decryptMetadata(action.metadata);
|
|
/** @type {(toolInput: Object | string) => Promise<unknown>} */
|
|
const _call = async (toolInput) => {
|
|
try {
|
|
const executor = requestBuilder.createExecutor();
|
|
|
|
// Chain the operations
|
|
const preparedExecutor = executor.setParams(toolInput);
|
|
|
|
if (action.metadata.auth && action.metadata.auth.type !== AuthTypeEnum.None) {
|
|
await preparedExecutor.setAuth(action.metadata);
|
|
}
|
|
|
|
const res = await preparedExecutor.execute();
|
|
|
|
if (typeof res.data === 'object') {
|
|
return JSON.stringify(res.data);
|
|
}
|
|
return res.data;
|
|
} catch (error) {
|
|
logger.error(`API call to ${action.metadata.domain} failed`, error);
|
|
if (error.response) {
|
|
const { status, data } = error.response;
|
|
return `API call to ${
|
|
action.metadata.domain
|
|
} failed with status ${status}: ${JSON.stringify(data)}`;
|
|
}
|
|
|
|
return `API call to ${action.metadata.domain} failed.`;
|
|
}
|
|
};
|
|
|
|
if (name) {
|
|
return tool(_call, {
|
|
name,
|
|
description: description || '',
|
|
schema: zodSchema,
|
|
});
|
|
}
|
|
|
|
return {
|
|
_call,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Encrypts sensitive metadata values for an action.
|
|
*
|
|
* @param {ActionMetadata} metadata - The action metadata to encrypt.
|
|
* @returns {Promise<ActionMetadata>} The updated action metadata with encrypted values.
|
|
*/
|
|
async function encryptMetadata(metadata) {
|
|
const encryptedMetadata = { ...metadata };
|
|
|
|
// ServiceHttp
|
|
if (metadata.auth && metadata.auth.type === AuthTypeEnum.ServiceHttp) {
|
|
if (metadata.api_key) {
|
|
encryptedMetadata.api_key = await encryptV2(metadata.api_key);
|
|
}
|
|
}
|
|
|
|
// OAuth
|
|
else if (metadata.auth && metadata.auth.type === AuthTypeEnum.OAuth) {
|
|
if (metadata.oauth_client_id) {
|
|
encryptedMetadata.oauth_client_id = await encryptV2(metadata.oauth_client_id);
|
|
}
|
|
if (metadata.oauth_client_secret) {
|
|
encryptedMetadata.oauth_client_secret = await encryptV2(metadata.oauth_client_secret);
|
|
}
|
|
}
|
|
|
|
return encryptedMetadata;
|
|
}
|
|
|
|
/**
|
|
* Decrypts sensitive metadata values for an action.
|
|
*
|
|
* @param {ActionMetadata} metadata - The action metadata to decrypt.
|
|
* @returns {Promise<ActionMetadata>} The updated action metadata with decrypted values.
|
|
*/
|
|
async function decryptMetadata(metadata) {
|
|
const decryptedMetadata = { ...metadata };
|
|
|
|
// ServiceHttp
|
|
if (metadata.auth && metadata.auth.type === AuthTypeEnum.ServiceHttp) {
|
|
if (metadata.api_key) {
|
|
decryptedMetadata.api_key = await decryptV2(metadata.api_key);
|
|
}
|
|
}
|
|
|
|
// OAuth
|
|
else if (metadata.auth && metadata.auth.type === AuthTypeEnum.OAuth) {
|
|
if (metadata.oauth_client_id) {
|
|
decryptedMetadata.oauth_client_id = await decryptV2(metadata.oauth_client_id);
|
|
}
|
|
if (metadata.oauth_client_secret) {
|
|
decryptedMetadata.oauth_client_secret = await decryptV2(metadata.oauth_client_secret);
|
|
}
|
|
}
|
|
|
|
return decryptedMetadata;
|
|
}
|
|
|
|
/**
|
|
* Deletes an action and its corresponding assistant.
|
|
* @param {Object} params - The parameters for the function.
|
|
* @param {OpenAIClient} params.req - The Express Request object.
|
|
* @param {string} params.assistant_id - The ID of the assistant.
|
|
*/
|
|
const deleteAssistantActions = async ({ req, assistant_id }) => {
|
|
try {
|
|
await deleteActions({ assistant_id, user: req.user.id });
|
|
await deleteAssistant({ assistant_id, user: req.user.id });
|
|
} catch (error) {
|
|
const message = 'Trouble deleting Assistant Actions for Assistant ID: ' + assistant_id;
|
|
logger.error(message, error);
|
|
throw new Error(message);
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
deleteAssistantActions,
|
|
validateAndUpdateTool,
|
|
createActionTool,
|
|
encryptMetadata,
|
|
decryptMetadata,
|
|
loadActionSets,
|
|
domainParser,
|
|
};
|