diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index b7ff50150..6592371f0 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -37,6 +37,10 @@ const messages = isRedisEnabled ? new Keyv({ store: keyvRedis, ttl: Time.ONE_MINUTE }) : new Keyv({ namespace: CacheKeys.MESSAGES, ttl: Time.ONE_MINUTE }); +const flows = isRedisEnabled + ? new Keyv({ store: keyvRedis, ttl: Time.TWO_MINUTES }) + : new Keyv({ namespace: CacheKeys.FLOWS, ttl: Time.ONE_MINUTE * 3 }); + const tokenConfig = isRedisEnabled ? new Keyv({ store: keyvRedis, ttl: Time.THIRTY_MINUTES }) : new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: Time.THIRTY_MINUTES }); @@ -88,6 +92,7 @@ const namespaces = { [CacheKeys.MODEL_QUERIES]: modelQueries, [CacheKeys.AUDIO_RUNS]: audioRuns, [CacheKeys.MESSAGES]: messages, + [CacheKeys.FLOWS]: flows, }; /** diff --git a/api/cache/keyvRedis.js b/api/cache/keyvRedis.js index 9501045e4..d544b50a1 100644 --- a/api/cache/keyvRedis.js +++ b/api/cache/keyvRedis.js @@ -1,6 +1,6 @@ const KeyvRedis = require('@keyv/redis'); -const { logger } = require('~/config'); const { isEnabled } = require('~/server/utils'); +const logger = require('~/config/winston'); const { REDIS_URI, USE_REDIS } = process.env; diff --git a/api/config/index.js b/api/config/index.js index c2b21cfc0..aaf8bb276 100644 --- a/api/config/index.js +++ b/api/config/index.js @@ -1,9 +1,11 @@ const { EventSource } = require('eventsource'); +const { Time, CacheKeys } = require('librechat-data-provider'); const logger = require('./winston'); global.EventSource = EventSource; let mcpManager = null; +let flowManager = null; /** * @returns {Promise} @@ -16,6 +18,21 @@ async function getMCPManager() { return mcpManager; } +/** + * @param {(key: string) => Keyv} getLogStores + * @returns {Promise} + */ +async function getFlowStateManager(getLogStores) { + if (!flowManager) { + const { FlowStateManager } = await import('librechat-mcp'); + flowManager = new FlowStateManager(getLogStores(CacheKeys.FLOWS), { + ttl: Time.ONE_MINUTE * 3, + logger, + }); + } + return flowManager; +} + /** * Sends message data in Server Sent Events format. * @param {ServerResponse} res - The server response. @@ -34,4 +51,5 @@ module.exports = { logger, sendEvent, getMCPManager, + getFlowStateManager, }; diff --git a/api/models/Token.js b/api/models/Token.js index cdd156b6b..210666ddd 100644 --- a/api/models/Token.js +++ b/api/models/Token.js @@ -1,5 +1,6 @@ -const tokenSchema = require('./schema/tokenSchema'); const mongoose = require('mongoose'); +const { encryptV2 } = require('~/server/utils/crypto'); +const tokenSchema = require('./schema/tokenSchema'); const { logger } = require('~/config'); /** @@ -7,6 +8,32 @@ const { logger } = require('~/config'); * @type {mongoose.Model} */ const Token = mongoose.model('Token', tokenSchema); +/** + * Fixes the indexes for the Token collection from legacy TTL indexes to the new expiresAt index. + */ +async function fixIndexes() { + try { + const indexes = await Token.collection.indexes(); + logger.debug('Existing Token Indexes:', JSON.stringify(indexes, null, 2)); + const unwantedTTLIndexes = indexes.filter( + (index) => index.key.createdAt === 1 && index.expireAfterSeconds !== undefined, + ); + if (unwantedTTLIndexes.length === 0) { + logger.debug('No unwanted Token indexes found.'); + return; + } + for (const index of unwantedTTLIndexes) { + logger.debug(`Dropping unwanted Token index: ${index.name}`); + await Token.collection.dropIndex(index.name); + logger.debug(`Dropped Token index: ${index.name}`); + } + logger.debug('Token index cleanup completed successfully.'); + } catch (error) { + logger.error('An error occurred while fixing Token indexes:', error); + } +} + +fixIndexes(); /** * Creates a new Token instance. @@ -29,8 +56,7 @@ async function createToken(tokenData) { expiresAt, }; - const newToken = new Token(newTokenData); - return await newToken.save(); + return await Token.create(newTokenData); } catch (error) { logger.debug('An error occurred while creating token:', error); throw error; @@ -42,7 +68,8 @@ async function createToken(tokenData) { * @param {Object} query - The query to match against. * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. * @param {String} query.token - The token value. - * @param {String} query.email - The email of the user. + * @param {String} [query.email] - The email of the user. + * @param {String} [query.identifier] - Unique, alternative identifier for the token. * @returns {Promise} The matched Token document, or null if not found. * @throws Will throw an error if the find operation fails. */ @@ -59,6 +86,9 @@ async function findToken(query) { if (query.email) { conditions.push({ email: query.email }); } + if (query.identifier) { + conditions.push({ identifier: query.identifier }); + } const token = await Token.findOne({ $and: conditions, @@ -76,6 +106,8 @@ async function findToken(query) { * @param {Object} query - The query to match against. * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. * @param {String} query.token - The token value. + * @param {String} [query.email] - The email of the user. + * @param {String} [query.identifier] - Unique, alternative identifier for the token. * @param {Object} updateData - The data to update the Token with. * @returns {Promise} The updated Token document, or null if not found. * @throws Will throw an error if the update operation fails. @@ -94,14 +126,20 @@ async function updateToken(query, updateData) { * @param {Object} query - The query to match against. * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. * @param {String} query.token - The token value. - * @param {String} query.email - The email of the user. + * @param {String} [query.email] - The email of the user. + * @param {String} [query.identifier] - Unique, alternative identifier for the token. * @returns {Promise} The result of the delete operation. * @throws Will throw an error if the delete operation fails. */ async function deleteTokens(query) { try { return await Token.deleteMany({ - $or: [{ userId: query.userId }, { token: query.token }, { email: query.email }], + $or: [ + { userId: query.userId }, + { token: query.token }, + { email: query.email }, + { identifier: query.identifier }, + ], }); } catch (error) { logger.debug('An error occurred while deleting tokens:', error); @@ -109,9 +147,46 @@ async function deleteTokens(query) { } } +/** + * Handles the OAuth token by creating or updating the token. + * @param {object} fields + * @param {string} fields.userId - The user's ID. + * @param {string} fields.token - The full token to store. + * @param {string} fields.identifier - Unique, alternative identifier for the token. + * @param {number} fields.expiresIn - The number of seconds until the token expires. + * @param {object} fields.metadata - Additional metadata to store with the token. + * @param {string} [fields.type="oauth"] - The type of token. Default is 'oauth'. + */ +async function handleOAuthToken({ + token, + userId, + identifier, + expiresIn, + metadata, + type = 'oauth', +}) { + const encrypedToken = await encryptV2(token); + const tokenData = { + type, + userId, + metadata, + identifier, + token: encrypedToken, + expiresIn: parseInt(expiresIn, 10) || 3600, + }; + + const existingToken = await findToken({ userId, identifier }); + if (existingToken) { + return await updateToken({ identifier }, tokenData); + } else { + return await createToken(tokenData); + } +} + module.exports = { - createToken, findToken, + createToken, updateToken, deleteTokens, + handleOAuthToken, }; diff --git a/api/models/schema/tokenSchema.js b/api/models/schema/tokenSchema.js index bb223ff45..1b45b2ff3 100644 --- a/api/models/schema/tokenSchema.js +++ b/api/models/schema/tokenSchema.js @@ -10,6 +10,10 @@ const tokenSchema = new Schema({ email: { type: String, }, + type: String, + identifier: { + type: String, + }, token: { type: String, required: true, @@ -23,6 +27,10 @@ const tokenSchema = new Schema({ type: Date, required: true, }, + metadata: { + type: Map, + of: Schema.Types.Mixed, + }, }); tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); diff --git a/api/package.json b/api/package.json index 10264309c..668e3bd15 100644 --- a/api/package.json +++ b/api/package.json @@ -45,7 +45,7 @@ "@langchain/google-genai": "^0.1.7", "@langchain/google-vertexai": "^0.1.8", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.0.2", + "@librechat/agents": "^2.0.3", "@waylaidwanderer/fetch-event-source": "^3.0.1", "axios": "^1.7.7", "bcryptjs": "^2.4.3", diff --git a/api/server/controllers/AskController.js b/api/server/controllers/AskController.js index b952ab004..55fe2fa71 100644 --- a/api/server/controllers/AskController.js +++ b/api/server/controllers/AskController.js @@ -155,6 +155,8 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { sender, messageId: responseMessageId, parentMessageId: userMessageId ?? parentMessageId, + }).catch((err) => { + logger.error('[AskController] Error in `handleAbortError`', err); }); } }; diff --git a/api/server/controllers/EditController.js b/api/server/controllers/EditController.js index ec618eabc..2a2f8c28d 100644 --- a/api/server/controllers/EditController.js +++ b/api/server/controllers/EditController.js @@ -140,6 +140,8 @@ const EditController = async (req, res, next, initializeClient) => { sender, messageId: responseMessageId, parentMessageId: userMessageId ?? parentMessageId, + }).catch((err) => { + logger.error('[EditController] Error in `handleAbortError`', err); }); } }; diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 8ceadd977..288ae8f37 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -143,6 +143,8 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => { sender, messageId: responseMessageId, parentMessageId: userMessageId ?? parentMessageId, + }).catch((err) => { + logger.error('[api/server/controllers/agents/request] Error in `handleAbortError`', err); }); } }; diff --git a/api/server/index.js b/api/server/index.js index 727827360..30d36d9a9 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -84,6 +84,7 @@ const startServer = async () => { app.use('/oauth', routes.oauth); /* API Endpoints */ app.use('/api/auth', routes.auth); + app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/user', routes.user); app.use('/api/search', routes.search); diff --git a/api/server/routes/actions.js b/api/server/routes/actions.js new file mode 100644 index 000000000..454f4be6c --- /dev/null +++ b/api/server/routes/actions.js @@ -0,0 +1,136 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const { getAccessToken } = require('~/server/services/TokenService'); +const { logger, getFlowStateManager } = require('~/config'); +const { getLogStores } = require('~/cache'); + +const router = express.Router(); +const JWT_SECRET = process.env.JWT_SECRET; + +/** + * Handles the OAuth callback and exchanges the authorization code for tokens. + * + * @route GET /actions/:action_id/oauth/callback + * @param {string} req.params.action_id - The ID of the action. + * @param {string} req.query.code - The authorization code returned by the provider. + * @param {string} req.query.state - The state token to verify the authenticity of the request. + * @returns {void} Sends a success message after updating the action with OAuth tokens. + */ +router.get('/:action_id/oauth/callback', async (req, res) => { + const { action_id } = req.params; + const { code, state } = req.query; + + const flowManager = await getFlowStateManager(getLogStores); + let identifier = action_id; + try { + let decodedState; + try { + decodedState = jwt.verify(state, JWT_SECRET); + } catch (err) { + await flowManager.failFlow(identifier, 'oauth', 'Invalid or expired state parameter'); + return res.status(400).send('Invalid or expired state parameter'); + } + + if (decodedState.action_id !== action_id) { + await flowManager.failFlow(identifier, 'oauth', 'Mismatched action ID in state parameter'); + return res.status(400).send('Mismatched action ID in state parameter'); + } + + if (!decodedState.user) { + await flowManager.failFlow(identifier, 'oauth', 'Invalid user ID in state parameter'); + return res.status(400).send('Invalid user ID in state parameter'); + } + identifier = `${decodedState.user}:${action_id}`; + const flowState = await flowManager.getFlowState(identifier, 'oauth'); + if (!flowState) { + throw new Error('OAuth flow not found'); + } + + const tokenData = await getAccessToken({ + code, + userId: decodedState.user, + identifier, + client_url: flowState.metadata.client_url, + redirect_uri: flowState.metadata.redirect_uri, + /** Encrypted values */ + encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id, + encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret, + }); + await flowManager.completeFlow(identifier, 'oauth', tokenData); + res.send(` + + + + Authentication Successful + + + + + +
+

Authentication Successful

+

+ Your authentication was successful. This window will close in + 3 seconds. +

+
+ + + + `); + } catch (error) { + logger.error('Error in OAuth callback:', error); + await flowManager.failFlow(identifier, 'oauth', error); + res.status(500).send('Authentication failed. Please try again.'); + } +}); + +module.exports = router; diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index 5d5456c29..786f44dd8 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -1,6 +1,6 @@ const express = require('express'); const { nanoid } = require('nanoid'); -const { actionDelimiter, SystemRoles } = require('librechat-data-provider'); +const { actionDelimiter, SystemRoles, removeNullishValues } = require('librechat-data-provider'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { updateAction, getActions, deleteAction } = require('~/models/Action'); const { isActionDomainAllowed } = require('~/server/services/domains'); @@ -51,7 +51,7 @@ router.post('/:agent_id', async (req, res) => { return res.status(400).json({ message: 'No functions provided' }); } - let metadata = await encryptMetadata(_metadata); + let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); const isDomainAllowed = await isActionDomainAllowed(metadata.domain); if (!isDomainAllowed) { return res.status(400).json({ message: 'Domain not allowed' }); @@ -117,10 +117,7 @@ router.post('/:agent_id', async (req, res) => { } /** @type {[Action]} */ - const updatedAction = await updateAction( - { action_id }, - actionUpdateData, - ); + const updatedAction = await updateAction({ action_id }, actionUpdateData); const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret']; for (let field of sensitiveFields) { diff --git a/api/server/routes/assistants/actions.js b/api/server/routes/assistants/actions.js index eb6574908..9f4db5d6b 100644 --- a/api/server/routes/assistants/actions.js +++ b/api/server/routes/assistants/actions.js @@ -1,6 +1,6 @@ const express = require('express'); const { nanoid } = require('nanoid'); -const { actionDelimiter, EModelEndpoint } = require('librechat-data-provider'); +const { actionDelimiter, EModelEndpoint, removeNullishValues } = require('librechat-data-provider'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { updateAction, getActions, deleteAction } = require('~/models/Action'); @@ -29,7 +29,7 @@ router.post('/:assistant_id', async (req, res) => { return res.status(400).json({ message: 'No functions provided' }); } - let metadata = await encryptMetadata(_metadata); + let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); const isDomainAllowed = await isActionDomainAllowed(metadata.domain); if (!isDomainAllowed) { return res.status(400).json({ message: 'Domain not allowed' }); diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 4aba91e95..4b34029c7 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -9,6 +9,7 @@ const prompts = require('./prompts'); const balance = require('./balance'); const plugins = require('./plugins'); const bedrock = require('./bedrock'); +const actions = require('./actions'); const search = require('./search'); const models = require('./models'); const convos = require('./convos'); @@ -45,6 +46,7 @@ module.exports = { config, models, plugins, + actions, presets, balance, messages, diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index 712157bf2..660e7aeb0 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -1,20 +1,28 @@ +const jwt = require('jsonwebtoken'); +const { nanoid } = require('nanoid'); +const { tool } = require('@langchain/core/tools'); +const { GraphEvents, sleep } = require('@librechat/agents'); const { + Time, CacheKeys, + StepTypes, Constants, AuthTypeEnum, actionDelimiter, isImageVisionTool, actionDomainSeparator, } = require('librechat-data-provider'); -const { tool } = require('@langchain/core/tools'); +const { refreshAccessToken } = require('~/server/services/TokenService'); const { isActionDomainAllowed } = require('~/server/services/domains'); +const { logger, getFlowStateManager, sendEvent } = require('~/config'); const { encryptV2, decryptV2 } = require('~/server/utils/crypto'); const { getActions, deleteActions } = require('~/models/Action'); const { deleteAssistant } = require('~/models/Assistant'); +const { findToken } = require('~/models/Token'); const { logAxiosError } = require('~/utils'); const { getLogStores } = require('~/cache'); -const { logger } = require('~/config'); +const JWT_SECRET = process.env.JWT_SECRET; const toolNameRegex = /^[a-zA-Z0-9_-]+$/; const replaceSeparatorRegex = new RegExp(actionDomainSeparator, 'g'); @@ -115,6 +123,8 @@ async function loadActionSets(searchParams) { * Creates a general tool for an entire action set. * * @param {Object} params - The parameters for loading action sets. + * @param {ServerRequest} params.req + * @param {ServerResponse} params.res * @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. @@ -122,33 +132,185 @@ async function loadActionSets(searchParams) { * @param {import('zod').ZodTypeAny | undefined} [params.zodSchema] - The Zod schema for tool input validation/definition * @returns { Promise 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); +async function createActionTool({ + req, + res, + action, + requestBuilder, + zodSchema, + name, + description, +}) { const isDomainAllowed = await isActionDomainAllowed(action.metadata.domain); if (!isDomainAllowed) { return null; } - /** @type {(toolInput: Object | string) => Promise} */ - const _call = async (toolInput) => { - try { - const executor = requestBuilder.createExecutor(); + const encrypted = { + oauth_client_id: action.metadata.oauth_client_id, + oauth_client_secret: action.metadata.oauth_client_secret, + }; + action.metadata = await decryptMetadata(action.metadata); - // Chain the operations + /** @type {(toolInput: Object | string, config: GraphRunnableConfig) => Promise} */ + const _call = async (toolInput, config) => { + try { + /** @type {import('librechat-data-provider').ActionMetadataRuntime} */ + const metadata = action.metadata; + const executor = requestBuilder.createExecutor(); const preparedExecutor = executor.setParams(toolInput); - if (action.metadata.auth && action.metadata.auth.type !== AuthTypeEnum.None) { - await preparedExecutor.setAuth(action.metadata); + if (metadata.auth && metadata.auth.type !== AuthTypeEnum.None) { + try { + const action_id = action.action_id; + const identifier = `${req.user.id}:${action.action_id}`; + if (metadata.auth.type === AuthTypeEnum.OAuth && metadata.auth.authorization_url) { + const requestLogin = async () => { + const { args: _args, stepId, ...toolCall } = config.toolCall ?? {}; + if (!stepId) { + throw new Error('Tool call is missing stepId'); + } + const statePayload = { + nonce: nanoid(), + user: req.user.id, + action_id, + }; + + const stateToken = jwt.sign(statePayload, JWT_SECRET, { expiresIn: '10m' }); + try { + const redirectUri = `${process.env.DOMAIN_CLIENT}/api/actions/${action_id}/oauth/callback`; + const params = new URLSearchParams({ + client_id: metadata.oauth_client_id, + scope: metadata.auth.scope, + redirect_uri: redirectUri, + access_type: 'offline', + response_type: 'code', + state: stateToken, + }); + + const authURL = `${metadata.auth.authorization_url}?${params.toString()}`; + /** @type {{ id: string; delta: AgentToolCallDelta }} */ + const data = { + id: stepId, + delta: { + type: StepTypes.TOOL_CALLS, + tool_calls: [{ ...toolCall, args: '' }], + auth: authURL, + expires_at: Date.now() + Time.TWO_MINUTES, + }, + }; + const flowManager = await getFlowStateManager(getLogStores); + await flowManager.createFlowWithHandler( + `${identifier}:login`, + 'oauth_login', + async () => { + sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data }); + logger.debug('Sent OAuth login request to client', { action_id, identifier }); + return true; + }, + ); + logger.debug('Waiting for OAuth Authorization response', { action_id, identifier }); + const result = await flowManager.createFlow(identifier, 'oauth', { + state: stateToken, + userId: req.user.id, + client_url: metadata.auth.client_url, + redirect_uri: `${process.env.DOMAIN_CLIENT}/api/actions/${action_id}/oauth/callback`, + /** Encrypted values */ + encrypted_oauth_client_id: encrypted.oauth_client_id, + encrypted_oauth_client_secret: encrypted.oauth_client_secret, + }); + logger.debug('Received OAuth Authorization response', { action_id, identifier }); + data.delta.auth = undefined; + data.delta.expires_at = undefined; + sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data }); + await sleep(3000); + metadata.oauth_access_token = result.access_token; + metadata.oauth_refresh_token = result.refresh_token; + const expiresAt = new Date(Date.now() + result.expires_in * 1000); + metadata.oauth_token_expires_at = expiresAt.toISOString(); + } catch (error) { + const errorMessage = 'Failed to authenticate OAuth tool'; + logger.error(errorMessage, error); + throw new Error(errorMessage); + } + }; + + const tokenPromises = []; + tokenPromises.push(findToken({ userId: req.user.id, type: 'oauth', identifier })); + tokenPromises.push( + findToken({ + userId: req.user.id, + type: 'oauth_refresh', + identifier: `${identifier}:refresh`, + }), + ); + const [tokenData, refreshTokenData] = await Promise.all(tokenPromises); + + if (tokenData) { + // Valid token exists, add it to metadata for setAuth + metadata.oauth_access_token = await decryptV2(tokenData.token); + if (refreshTokenData) { + metadata.oauth_refresh_token = await decryptV2(refreshTokenData.token); + } + metadata.oauth_token_expires_at = tokenData.expiresAt.toISOString(); + } else if (!refreshTokenData) { + // No tokens exist, need to authenticate + await requestLogin(); + } else if (refreshTokenData) { + // Refresh token is still valid, use it to get new access token + try { + const refresh_token = await decryptV2(refreshTokenData.token); + const refreshTokens = async () => + await refreshAccessToken({ + identifier, + refresh_token, + userId: req.user.id, + client_url: metadata.auth.client_url, + encrypted_oauth_client_id: encrypted.oauth_client_id, + encrypted_oauth_client_secret: encrypted.oauth_client_secret, + }); + const flowManager = await getFlowStateManager(getLogStores); + const refreshData = await flowManager.createFlowWithHandler( + `${identifier}:refresh`, + 'oauth_refresh', + refreshTokens, + ); + metadata.oauth_access_token = refreshData.access_token; + if (refreshData.refresh_token) { + metadata.oauth_refresh_token = refreshData.refresh_token; + } + const expiresAt = new Date(Date.now() + refreshData.expires_in * 1000); + metadata.oauth_token_expires_at = expiresAt.toISOString(); + } catch (error) { + logger.error('Failed to refresh token, requesting new login:', error); + await requestLogin(); + } + } else { + await requestLogin(); + } + } + + await preparedExecutor.setAuth(metadata); + } catch (error) { + if ( + error.message.includes('No access token found') || + error.message.includes('Access token is expired') + ) { + throw error; + } + throw new Error(`Authentication failed: ${error.message}`); + } } - const res = await preparedExecutor.execute(); + const response = await preparedExecutor.execute(); - if (typeof res.data === 'object') { - return JSON.stringify(res.data); + if (typeof response.data === 'object') { + return JSON.stringify(response.data); } - return res.data; + return response.data; } catch (error) { const logMessage = `API call to ${action.metadata.domain} failed`; logAxiosError({ message: logMessage, error }); + throw error; } }; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index d1b691685..15a310030 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -82,6 +82,7 @@ const initializeAgentOptions = async ({ }) => { const { tools, toolContextMap } = await loadAgentTools({ req, + res, agent, tool_resources, }); diff --git a/api/server/services/TokenService.js b/api/server/services/TokenService.js new file mode 100644 index 000000000..ec0f990a4 --- /dev/null +++ b/api/server/services/TokenService.js @@ -0,0 +1,170 @@ +const axios = require('axios'); +const { handleOAuthToken } = require('~/models/Token'); +const { decryptV2 } = require('~/server/utils/crypto'); +const { logAxiosError } = require('~/utils'); +const { logger } = require('~/config'); + +/** + * Processes the access tokens and stores them in the database. + * @param {object} tokenData + * @param {string} tokenData.access_token + * @param {number} tokenData.expires_in + * @param {string} [tokenData.refresh_token] + * @param {number} [tokenData.refresh_token_expires_in] + * @param {object} metadata + * @param {string} metadata.userId + * @param {string} metadata.identifier + * @returns {Promise} + */ +async function processAccessTokens(tokenData, { userId, identifier }) { + const { access_token, expires_in = 3600, refresh_token, refresh_token_expires_in } = tokenData; + if (!access_token) { + logger.error('Access token not found: ', tokenData); + throw new Error('Access token not found'); + } + await handleOAuthToken({ + identifier, + token: access_token, + expiresIn: expires_in, + userId, + }); + + if (refresh_token != null) { + logger.debug('Processing refresh token'); + await handleOAuthToken({ + token: refresh_token, + type: 'oauth_refresh', + userId, + identifier: `${identifier}:refresh`, + expiresIn: refresh_token_expires_in ?? null, + }); + } + logger.debug('Access tokens processed'); +} + +/** + * Refreshes the access token using the refresh token. + * @param {object} fields + * @param {string} fields.userId - The ID of the user. + * @param {string} fields.client_url - The URL of the OAuth provider. + * @param {string} fields.identifier - The identifier for the token. + * @param {string} fields.refresh_token - The refresh token to use. + * @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider. + * @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider. + * @returns {Promise<{ + * access_token: string, + * expires_in: number, + * refresh_token?: string, + * refresh_token_expires_in?: number, + * }>} + */ +const refreshAccessToken = async ({ + userId, + client_url, + identifier, + refresh_token, + encrypted_oauth_client_id, + encrypted_oauth_client_secret, +}) => { + try { + const oauth_client_id = await decryptV2(encrypted_oauth_client_id); + const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret); + const params = new URLSearchParams({ + client_id: oauth_client_id, + client_secret: oauth_client_secret, + grant_type: 'refresh_token', + refresh_token, + }); + + const response = await axios({ + method: 'POST', + url: client_url, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + data: params.toString(), + }); + await processAccessTokens(response.data, { + userId, + identifier, + }); + logger.debug(`Access token refreshed successfully for ${identifier}`); + return response.data; + } catch (error) { + const message = 'Error refreshing OAuth tokens'; + logAxiosError({ + message, + error, + }); + throw new Error(message); + } +}; + +/** + * Handles the OAuth callback and exchanges the authorization code for tokens. + * @param {object} fields + * @param {string} fields.code - The authorization code returned by the provider. + * @param {string} fields.userId - The ID of the user. + * @param {string} fields.identifier - The identifier for the token. + * @param {string} fields.client_url - The URL of the OAuth provider. + * @param {string} fields.redirect_uri - The redirect URI for the OAuth provider. + * @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider. + * @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider. + * @returns {Promise<{ + * access_token: string, + * expires_in: number, + * refresh_token?: string, + * refresh_token_expires_in?: number, + * }>} + */ +const getAccessToken = async ({ + code, + userId, + identifier, + client_url, + redirect_uri, + encrypted_oauth_client_id, + encrypted_oauth_client_secret, +}) => { + const oauth_client_id = await decryptV2(encrypted_oauth_client_id); + const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret); + const params = new URLSearchParams({ + code, + client_id: oauth_client_id, + client_secret: oauth_client_secret, + grant_type: 'authorization_code', + redirect_uri, + }); + + try { + const response = await axios({ + method: 'POST', + url: client_url, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + data: params.toString(), + }); + + await processAccessTokens(response.data, { + userId, + identifier, + }); + logger.debug(`Access tokens successfully created for ${identifier}`); + return response.data; + } catch (error) { + const message = 'Error exchanging OAuth code'; + logAxiosError({ + message, + error, + }); + throw new Error(message); + } +}; + +module.exports = { + getAccessToken, + refreshAccessToken, +}; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index cf88c0b19..f3e4efb6e 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -409,11 +409,12 @@ async function processRequiredActions(client, requiredActions) { * Processes the runtime tool calls and returns the tool classes. * @param {Object} params - Run params containing user and request information. * @param {ServerRequest} params.req - The request object. + * @param {ServerResponse} params.res - The request object. * @param {Agent} params.agent - The agent to load tools for. * @param {string | undefined} [params.openAIApiKey] - The OpenAI API key. * @returns {Promise<{ tools?: StructuredTool[] }>} The agent tools. */ -async function loadAgentTools({ req, agent, tool_resources, openAIApiKey }) { +async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey }) { if (!agent.tools || agent.tools.length === 0) { return {}; } @@ -546,6 +547,8 @@ async function loadAgentTools({ req, agent, tool_resources, openAIApiKey }) { if (requestBuilder) { const tool = await createActionTool({ + req, + res, action: actionSet, requestBuilder, zodSchema, diff --git a/api/typedefs.js b/api/typedefs.js index c88e57719..bd97bd93f 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -98,6 +98,12 @@ * @memberof typedefs */ +/** + * @exports LangChainToolCall + * @typedef {import('@langchain/core/messages/tool').ToolCall} LangChainToolCall + * @memberof typedefs + */ + /** * @exports GraphRunnableConfig * @typedef {import('@langchain/core/runnables').RunnableConfig<{ @@ -109,7 +115,9 @@ * agent_index: number; * last_agent_index: number; * hide_sequential_outputs: boolean; - * }>} GraphRunnableConfig + * }> & { + * toolCall?: LangChainToolCall & { stepId?: string }; + * }} GraphRunnableConfig * @memberof typedefs */ @@ -383,6 +391,12 @@ * @memberof typedefs */ +/** + * @exports AgentToolCallDelta + * @typedef {import('librechat-data-provider').Agents.ToolCallDelta} AgentToolCallDelta + * @memberof typedefs + */ + /** Prompts */ /** * @exports TPrompt @@ -947,12 +961,24 @@ * @memberof typedefs */ +/** + * @exports Keyv + * @typedef {import('keyv')} Keyv + * @memberof typedefs + */ + /** * @exports MCPManager * @typedef {import('librechat-mcp').MCPManager} MCPManager * @memberof typedefs */ +/** + * @exports FlowStateManager + * @typedef {import('librechat-mcp').FlowStateManager} FlowStateManager + * @memberof typedefs + */ + /** * @exports LCAvailableTools * @typedef {import('librechat-mcp').LCAvailableTools} LCAvailableTools diff --git a/client/src/components/Chat/Messages/Content/Files.tsx b/client/src/components/Chat/Messages/Content/Files.tsx index 09801d92c..8997d5e82 100644 --- a/client/src/components/Chat/Messages/Content/Files.tsx +++ b/client/src/components/Chat/Messages/Content/Files.tsx @@ -9,14 +9,14 @@ const Files = ({ message }: { message?: TMessage }) => { }, [message?.files]); const otherFiles = useMemo(() => { - return message?.files?.filter((file) => !file.type?.startsWith('image/')) || []; + return message?.files?.filter((file) => !(file.type?.startsWith('image/') === true)) || []; }, [message?.files]); return ( <> {otherFiles.length > 0 && otherFiles.map((file) => )} - {imageFiles && + {imageFiles.length > 0 && imageFiles.map((file) => ( ; } diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index b67fbf617..ced80b1aa 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -81,6 +81,8 @@ const Part = memo(({ part, isSubmitting, attachments, showCursor, isCreatedByUse initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} attachments={attachments} + auth={toolCall.auth} + expires_at={toolCall.expires_at} /> ); } else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) { diff --git a/client/src/components/Chat/Messages/Content/ProgressText.tsx b/client/src/components/Chat/Messages/Content/ProgressText.tsx index ba5031fac..aaeecd600 100644 --- a/client/src/components/Chat/Messages/Content/ProgressText.tsx +++ b/client/src/components/Chat/Messages/Content/ProgressText.tsx @@ -39,16 +39,19 @@ export default function ProgressText({ onClick, inProgressText, finishedText, + authText, hasInput = true, popover = false, }: { progress: number; - onClick: () => void; + onClick?: () => void; inProgressText: string; finishedText: string; + authText?: string; hasInput?: boolean; popover?: boolean; }) { + const text = progress < 1 ? authText ?? inProgressText : finishedText; return ( - -
Cancel
-
- - - - ); -} - -const ApiKey = () => { - const { register, watch, setValue } = useFormContext(); - const authorization_type = watch('authorization_type'); - const type = watch('type'); - return ( - <> - - - - setValue('authorization_type', value)} - value={authorization_type} - role="radiogroup" - aria-required="true" - dir="ltr" - className="mb-2 flex gap-6 overflow-hidden rounded-lg" - tabIndex={0} - style={{ outline: 'none' }} - > -
- -
-
- -
-
- -
-
- {authorization_type === AuthorizationTypeEnum.Custom && ( -
- - -
- )} - - ); -}; - -const OAuth = () => { - const { register, watch, setValue } = useFormContext(); - const token_exchange_method = watch('token_exchange_method'); - const type = watch('type'); - return ( - <> - - - - - - - - - - - - setValue('token_exchange_method', value)} - value={token_exchange_method} - role="radiogroup" - aria-required="true" - dir="ltr" - tabIndex={0} - style={{ outline: 'none' }} - > -
- -
-
- -
-
- - ); -}; diff --git a/client/src/components/SidePanel/Agents/ActionsInput.tsx b/client/src/components/SidePanel/Agents/ActionsInput.tsx index a40be180d..b2c2b9d51 100644 --- a/client/src/components/SidePanel/Agents/ActionsInput.tsx +++ b/client/src/components/SidePanel/Agents/ActionsInput.tsx @@ -14,6 +14,7 @@ import type { } from 'librechat-data-provider'; import type { ActionAuthForm } from '~/common'; import type { Spec } from './ActionsTable'; +import ActionCallback from '~/components/SidePanel/Builder/ActionCallback'; import { ActionsTable, columns } from './ActionsTable'; import { useUpdateAgentAction } from '~/data-provider'; import { useToastContext } from '~/Providers'; @@ -248,8 +249,8 @@ export default function ActionsInput({ {!!data && ( -
-
+
+
@@ -258,6 +259,7 @@ export default function ActionsInput({
)}
+
diff --git a/client/src/components/SidePanel/Agents/ActionsPanel.tsx b/client/src/components/SidePanel/Agents/ActionsPanel.tsx index f6ed94801..514e1b61e 100644 --- a/client/src/components/SidePanel/Agents/ActionsPanel.tsx +++ b/client/src/components/SidePanel/Agents/ActionsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import { AuthTypeEnum, @@ -7,14 +7,14 @@ import { } from 'librechat-data-provider'; import { ChevronLeft } from 'lucide-react'; import type { AgentPanelProps, ActionAuthForm } from '~/common'; -import { Dialog, DialogTrigger, OGDialog, OGDialogTrigger, Label } from '~/components/ui'; +import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth'; +import { OGDialog, OGDialogTrigger, Label } from '~/components/ui'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import { useDeleteAgentAction } from '~/data-provider'; import useLocalize from '~/hooks/useLocalize'; import { useToastContext } from '~/Providers'; import { TrashIcon } from '~/components/svg'; import ActionsInput from './ActionsInput'; -import ActionsAuth from './ActionsAuth'; import { Panel } from '~/common'; export default function ActionsPanel({ @@ -26,8 +26,6 @@ export default function ActionsPanel({ }: AgentPanelProps) { const localize = useLocalize(); const { showToast } = useToastContext(); - - const [openAuthDialog, setOpenAuthDialog] = useState(false); const deleteAgentAction = useDeleteAgentAction({ onSuccess: () => { showToast({ @@ -65,7 +63,6 @@ export default function ActionsPanel({ }); const { reset, watch } = methods; - const type = watch('type'); useEffect(() => { if (action?.metadata.auth) { @@ -156,40 +153,7 @@ export default function ActionsPanel({ Learn more.
*/}
- - -
-
- -
-
-
{type}
-
- -
-
-
- -
+
diff --git a/client/src/components/SidePanel/Agents/ActionsTable/Table.tsx b/client/src/components/SidePanel/Agents/ActionsTable/Table.tsx index 921c9571a..231badf53 100644 --- a/client/src/components/SidePanel/Agents/ActionsTable/Table.tsx +++ b/client/src/components/SidePanel/Agents/ActionsTable/Table.tsx @@ -22,7 +22,7 @@ export default function DataTable({ columns, data }: DataTablePro className="border-token-border-light text-token-text-tertiary border-b text-left text-xs" > {headerGroup.headers.map((header, j) => ( - + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} diff --git a/client/src/components/SidePanel/Builder/ActionCallback.tsx b/client/src/components/SidePanel/Builder/ActionCallback.tsx new file mode 100644 index 000000000..851607f22 --- /dev/null +++ b/client/src/components/SidePanel/Builder/ActionCallback.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { Copy, CopyCheck } from 'lucide-react'; +import { useFormContext } from 'react-hook-form'; +import { AuthTypeEnum } from 'librechat-data-provider'; +import { useLocalize, useCopyToClipboard } from '~/hooks'; +import { useToastContext } from '~/Providers'; +import { Button } from '~/components/ui'; +import { cn } from '~/utils'; + +export default function ActionCallback({ action_id }: { action_id?: string }) { + const localize = useLocalize(); + const { watch } = useFormContext(); + const { showToast } = useToastContext(); + const [isCopying, setIsCopying] = useState(false); + const callbackURL = `${window.location.protocol}//${window.location.host}/api/actions/${action_id}/oauth/callback`; + const copyLink = useCopyToClipboard({ text: callbackURL }); + + if (!action_id) { + return null; + } + const type = watch('type'); + if (type !== AuthTypeEnum.OAuth) { + return null; + } + return ( +
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ ); +} diff --git a/client/src/components/SidePanel/Builder/ActionsAuth.tsx b/client/src/components/SidePanel/Builder/ActionsAuth.tsx index 731068556..c56a48916 100644 --- a/client/src/components/SidePanel/Builder/ActionsAuth.tsx +++ b/client/src/components/SidePanel/Builder/ActionsAuth.tsx @@ -1,144 +1,190 @@ +import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import * as RadioGroup from '@radix-ui/react-radio-group'; -import * as DialogPrimitive from '@radix-ui/react-dialog'; import { AuthTypeEnum, AuthorizationTypeEnum, TokenExchangeMethodEnum, } from 'librechat-data-provider'; -import { DialogContent } from '~/components/ui/'; +import { + OGDialog, + OGDialogClose, + OGDialogTitle, + OGDialogHeader, + OGDialogContent, + OGDialogTrigger, +} from '~/components/ui'; +import { TranslationKeys, useLocalize } from '~/hooks'; +import { cn } from '~/utils'; -export default function ActionsAuth({ - setOpenAuthDialog, -}: { - setOpenAuthDialog: React.Dispatch>; -}) { +export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }) { + const localize = useLocalize(); + const [openAuthDialog, setOpenAuthDialog] = useState(false); const { watch, setValue, trigger } = useFormContext(); const type = watch('type'); + return ( - -
-
-
-
-

- Authentication -

+ + +
+
+ +
+
+
+ {localize(`com_ui_${type}` as TranslationKeys)}
+
+
-
-
-
- - setValue('type', value)} - value={type} - role="radiogroup" - aria-required="false" - dir="ltr" - className="flex gap-4" - tabIndex={0} - style={{ outline: 'none' }} - > -
-
- {type === 'none' ? null : type === 'service_http' ? : } - {/* Cancel/Save */} -
- - -
Cancel
-
-
-
- + + ); } const ApiKey = () => { + const localize = useLocalize(); const { register, watch, setValue } = useFormContext(); const authorization_type = watch('authorization_type'); const type = watch('type'); return ( <> - + - + setValue('authorization_type', value)} @@ -147,7 +193,6 @@ const ApiKey = () => { aria-required="true" dir="ltr" className="mb-2 flex gap-6 overflow-hidden rounded-lg" - tabIndex={0} style={{ outline: 'none' }} >
@@ -157,12 +202,14 @@ const ApiKey = () => { role="radio" value={AuthorizationTypeEnum.Basic} id=":rfu:" - className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-500 dark:bg-gray-500" - tabIndex={-1} + className={cn( + 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', + 'border-border-heavy bg-surface-primary', + )} > - + - Basic + {localize('com_ui_basic')}
@@ -172,12 +219,14 @@ const ApiKey = () => { role="radio" value={AuthorizationTypeEnum.Bearer} id=":rg0:" - className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-500 dark:bg-gray-500" - tabIndex={-1} + className={cn( + 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', + 'border-border-heavy bg-surface-primary', + )} > - + - Bearer + {localize('com_ui_bearer')}
@@ -187,20 +236,28 @@ const ApiKey = () => { role="radio" value={AuthorizationTypeEnum.Custom} id=":rg2:" - className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-500 dark:bg-gray-500" - tabIndex={0} + className={cn( + 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', + 'border-border-heavy bg-surface-primary', + )} > - + - Custom + {localize('com_ui_custom')}
{authorization_type === AuthorizationTypeEnum.Custom && (
- + { }; const OAuth = () => { + const localize = useLocalize(); const { register, watch, setValue } = useFormContext(); const token_exchange_method = watch('token_exchange_method'); const type = watch('type'); + + const inputClasses = cn( + 'mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm', + 'border-border-medium bg-surface-primary outline-none', + 'focus:ring-2 focus:ring-ring', + ); + return ( <> - + - + - + - + - + - + setValue('token_exchange_method', value)} @@ -257,7 +324,6 @@ const OAuth = () => { role="radiogroup" aria-required="true" dir="ltr" - tabIndex={0} style={{ outline: 'none' }} >
@@ -267,12 +333,14 @@ const OAuth = () => { role="radio" value={TokenExchangeMethodEnum.DefaultPost} id=":rj1:" - className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-700 dark:bg-gray-700" - tabIndex={-1} + className={cn( + 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', + 'border-border-heavy bg-surface-primary', + )} > - + - Default (POST request) + {localize('com_ui_default_post_request')}
@@ -282,12 +350,14 @@ const OAuth = () => { role="radio" value={TokenExchangeMethodEnum.BasicAuthHeader} id=":rj3:" - className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-700 dark:bg-gray-700" - tabIndex={-1} + className={cn( + 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', + 'border-border-heavy bg-surface-primary', + )} > - + - Basic authorization header + {localize('com_ui_basic_auth_header')}
diff --git a/client/src/components/SidePanel/Builder/ActionsInput.tsx b/client/src/components/SidePanel/Builder/ActionsInput.tsx index 0d58daaa4..410df8e9a 100644 --- a/client/src/components/SidePanel/Builder/ActionsInput.tsx +++ b/client/src/components/SidePanel/Builder/ActionsInput.tsx @@ -15,6 +15,7 @@ import type { } from 'librechat-data-provider'; import type { ActionAuthForm, ActionWithNullableMetadata } from '~/common'; import type { Spec } from './ActionsTable'; +import ActionCallback from '~/components/SidePanel/Builder/ActionCallback'; import { useAssistantsMapContext, useToastContext } from '~/Providers'; import { ActionsTable, columns } from './ActionsTable'; import { useUpdateAction } from '~/data-provider'; @@ -259,8 +260,8 @@ export default function ActionsInput({
{!!data && ( -
-
+
+
@@ -269,6 +270,7 @@ export default function ActionsInput({
)}
+
diff --git a/client/src/components/SidePanel/Builder/ActionsPanel.tsx b/client/src/components/SidePanel/Builder/ActionsPanel.tsx index f3fdd20de..23071f5c7 100644 --- a/client/src/components/SidePanel/Builder/ActionsPanel.tsx +++ b/client/src/components/SidePanel/Builder/ActionsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import { AuthTypeEnum, @@ -8,7 +8,7 @@ import { import { ChevronLeft } from 'lucide-react'; import type { AssistantPanelProps, ActionAuthForm } from '~/common'; import { useAssistantsMapContext, useToastContext } from '~/Providers'; -import { Dialog, DialogTrigger, OGDialog, OGDialogTrigger, Label } from '~/components/ui'; +import { OGDialog, OGDialogTrigger, Label } from '~/components/ui'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import { useDeleteAction } from '~/data-provider'; import { TrashIcon } from '~/components/svg'; @@ -29,7 +29,6 @@ export default function ActionsPanel({ const localize = useLocalize(); const { showToast } = useToastContext(); const assistantMap = useAssistantsMapContext(); - const [openAuthDialog, setOpenAuthDialog] = useState(false); const deleteAction = useDeleteAction({ onSuccess: () => { showToast({ @@ -68,7 +67,6 @@ export default function ActionsPanel({ }); const { reset, watch } = methods; - const type = watch('type'); useEffect(() => { if (action?.metadata?.auth) { @@ -162,40 +160,7 @@ export default function ActionsPanel({ Learn more.
*/}
- - -
-
- -
-
-
{type}
-
- -
-
-
- -
+ }) { +export default function PanelFileCell({ row }: { row: Row }) { const file = row.original; return (
- {file.type.startsWith('image') ? ( + {file?.type.startsWith('image') === true ? ( }) { alt={file.filename} /> ) : ( - + )}
- {file.filename} + {file?.filename}
diff --git a/client/src/components/ui/OGDialogTemplate.tsx b/client/src/components/ui/OGDialogTemplate.tsx index c0602e60c..6a1fc0279 100644 --- a/client/src/components/ui/OGDialogTemplate.tsx +++ b/client/src/components/ui/OGDialogTemplate.tsx @@ -6,7 +6,6 @@ import { OGDialogHeader, OGDialogContent, OGDialogDescription, - OGDialog, } from './OriginalDialog'; import { useLocalize } from '~/hooks'; import { Spinner } from '../svg'; diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index bc137edbc..06e622fc8 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -125,6 +125,8 @@ export default function useStepHandler({ name, args, type: ToolCallTypes.TOOL_CALL, + auth: contentPart.tool_call.auth, + expires_at: contentPart.tool_call.expires_at, }; if (finalUpdate) { @@ -286,6 +288,11 @@ export default function useStepHandler({ }, }; + if (runStepDelta.delta.auth != null) { + contentPart.tool_call.auth = runStepDelta.delta.auth; + contentPart.tool_call.expires_at = runStepDelta.delta.expires_at; + } + updatedResponse = updateContent(updatedResponse, runStep.index, contentPart); }); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 16f805648..7ea0d8440 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -518,6 +518,8 @@ "com_ui_attach_warn_endpoint": "Non-Assistant files may be ignored without a compatible tool", "com_ui_attachment": "Attachment", "com_ui_authentication": "Authentication", + "com_ui_auth_type": "Auth Type", + "com_ui_authentication_type": "Authentication Type", "com_ui_avatar": "Avatar", "com_ui_back_to_chat": "Back to Chat", "com_ui_back_to_prompts": "Back to Prompts", @@ -622,6 +624,22 @@ "com_ui_endpoint_menu": "LLM Endpoint Menu", "com_ui_endpoints_available": "Available Endpoints", "com_ui_enter": "Enter", + "com_ui_api_key": "API Key", + "com_ui_oauth": "OAuth", + "com_ui_basic": "Basic", + "com_ui_bearer": "Bearer", + "com_ui_custom": "Custom", + "com_ui_custom_header_name": "Custom Header Name", + "com_ui_client_id": "Client ID", + "com_ui_client_secret": "Client Secret", + "com_ui_auth_url": "Authorization URL", + "com_ui_token_url": "Token URL", + "com_ui_scope": "Scope", + "com_ui_service_http": "API Key", + "com_ui_default_post_request": "Default (POST request)", + "com_ui_token_exchange_method": "Token Exchange Method", + "com_ui_basic_auth_header": "Basic authorization header", + "com_ui_callback_url": "Callback URL", "com_ui_enter_api_key": "Enter API Key", "com_ui_enter_openapi_schema": "Enter your OpenAPI schema here", "com_ui_enter_var": "Enter {{0}}", @@ -706,6 +724,7 @@ "com_ui_no_conversation_id": "No conversation ID found", "com_ui_no_prompt_description": "No description found.", "com_ui_no_terms_content": "No terms and conditions content to display", + "com_ui_none": "None", "com_ui_none_selected": "None selected", "com_ui_nothing_found": "Nothing found", "com_ui_of": "of", @@ -832,5 +851,10 @@ "com_ui_yes": "Yes", "com_ui_zoom": "Zoom", "com_user_message": "You", - "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint." + "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.", + "com_assistants_action_attempt": "Assistant wants to talk to {{0}}", + "com_assistants_attempt_info": "Assistant wants to send the following:", + "com_assistants_allow_sites_you_trust": "Only allow sites you trust.", + "com_ui_sign_in_to_domain": "Sign-in to {{0}}", + "com_ui_requires_auth": "Requires Authentication" } \ No newline at end of file diff --git a/client/src/locales/nl/translation.json b/client/src/locales/nl/translation.json index 0df6c1385..a1eef8bdf 100644 --- a/client/src/locales/nl/translation.json +++ b/client/src/locales/nl/translation.json @@ -7,6 +7,7 @@ "com_agents_code_interpreter": "Indien ingeschakeld, kan je agent de LibreChat Code Interpreter API gebruiken om gegenereerde code, inclusief bestandsverwerking, veilig uit te voeren. Vereist een geldige API-sleutel.", "com_agents_code_interpreter_title": "Code-interpreter API", "com_agents_create_error": "Er is een fout opgetreden bij het aanmaken van uw agent.", + "com_agents_instructions_placeholder": "De systeeminstructies die de agent gebruikt", "com_auth_already_have_account": "Heb je al een account?", "com_auth_click": "Klik", "com_auth_click_here": "Klik hier", diff --git a/eslint.config.mjs b/eslint.config.mjs index ee5368db1..6991ac374 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -270,6 +270,7 @@ export default [ })), { files: ['**/*.ts', '**/*.tsx'], + ignores: ['packages/**/*'], plugins: { '@typescript-eslint': typescriptEslintEslintPlugin, jest: fixupPluginRules(jest), @@ -283,7 +284,6 @@ export default [ }, }, rules: { - // TODO: maybe later to error. '@typescript-eslint/no-unused-expressions': 'off', '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-explicit-any': 'off', diff --git a/package-lock.json b/package-lock.json index 397af1011..be35f065b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "@langchain/google-genai": "^0.1.7", "@langchain/google-vertexai": "^0.1.8", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.0.2", + "@librechat/agents": "^2.0.3", "@waylaidwanderer/fetch-event-source": "^3.0.1", "axios": "^1.7.7", "bcryptjs": "^2.4.3", @@ -651,6 +651,565 @@ "@langchain/core": ">=0.2.21 <0.4.0" } }, + "api/node_modules/@librechat/agents": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.0.3.tgz", + "integrity": "sha512-RZ7kNHYtU4udCQv0LOdtmJPX3K1bNJffeVPv4HvIeYZv347ejYh62MoZw3hLxFTbeRUocH19zRb30aEdBdu1MQ==", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/credential-provider-node": "^3.613.0", + "@aws-sdk/types": "^3.609.0", + "@langchain/anthropic": "^0.3.12", + "@langchain/aws": "^0.1.3", + "@langchain/community": "^0.3.27", + "@langchain/core": "^0.3.37", + "@langchain/google-genai": "^0.1.7", + "@langchain/google-vertexai": "^0.1.8", + "@langchain/langgraph": "^0.2.41", + "@langchain/mistralai": "^0.0.26", + "@langchain/ollama": "^0.1.5", + "@langchain/openai": "^0.4.2", + "@smithy/eventstream-codec": "^2.2.0", + "@smithy/protocol-http": "^3.0.6", + "@smithy/signature-v4": "^2.0.10", + "@smithy/util-utf8": "^2.0.0", + "dotenv": "^16.4.5", + "nanoid": "^3.3.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "api/node_modules/@librechat/agents/node_modules/@langchain/community": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.3.29.tgz", + "integrity": "sha512-6XIPGctpH3KziFpdVDEvYBEQMMsNomffqy545shoxOLoMkZqgn1wfMs6R7ltzzS0p3LJUXW1RbwAUEuWp0rbuA==", + "dependencies": { + "@langchain/openai": ">=0.2.0 <0.5.0", + "binary-extensions": "^2.2.0", + "expr-eval": "^2.0.2", + "flat": "^5.0.2", + "js-yaml": "^4.1.0", + "langchain": ">=0.2.3 <0.3.0 || >=0.3.4 <0.4.0", + "langsmith": ">=0.2.8 <0.4.0", + "uuid": "^10.0.0", + "zod": "^3.22.3", + "zod-to-json-schema": "^3.22.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@arcjet/redact": "^v1.0.0-alpha.23", + "@aws-crypto/sha256-js": "^5.0.0", + "@aws-sdk/client-bedrock-agent-runtime": "^3.583.0", + "@aws-sdk/client-bedrock-runtime": "^3.422.0", + "@aws-sdk/client-dynamodb": "^3.310.0", + "@aws-sdk/client-kendra": "^3.352.0", + "@aws-sdk/client-lambda": "^3.310.0", + "@aws-sdk/client-s3": "^3.310.0", + "@aws-sdk/client-sagemaker-runtime": "^3.310.0", + "@aws-sdk/client-sfn": "^3.310.0", + "@aws-sdk/credential-provider-node": "^3.388.0", + "@azure/search-documents": "^12.0.0", + "@azure/storage-blob": "^12.15.0", + "@browserbasehq/sdk": "*", + "@browserbasehq/stagehand": "^1.0.0", + "@clickhouse/client": "^0.2.5", + "@cloudflare/ai": "*", + "@datastax/astra-db-ts": "^1.0.0", + "@elastic/elasticsearch": "^8.4.0", + "@getmetal/metal-sdk": "*", + "@getzep/zep-cloud": "^1.0.6", + "@getzep/zep-js": "^0.9.0", + "@gomomento/sdk": "^1.51.1", + "@gomomento/sdk-core": "^1.51.1", + "@google-ai/generativelanguage": "*", + "@google-cloud/storage": "^6.10.1 || ^7.7.0", + "@gradientai/nodejs-sdk": "^1.2.0", + "@huggingface/inference": "^2.6.4", + "@huggingface/transformers": "^3.2.3", + "@ibm-cloud/watsonx-ai": "*", + "@lancedb/lancedb": "^0.12.0", + "@langchain/core": ">=0.2.21 <0.4.0", + "@layerup/layerup-security": "^1.5.12", + "@libsql/client": "^0.14.0", + "@mendable/firecrawl-js": "^1.4.3", + "@mlc-ai/web-llm": "*", + "@mozilla/readability": "*", + "@neondatabase/serverless": "*", + "@notionhq/client": "^2.2.10", + "@opensearch-project/opensearch": "*", + "@pinecone-database/pinecone": "*", + "@planetscale/database": "^1.8.0", + "@premai/prem-sdk": "^0.3.25", + "@qdrant/js-client-rest": "^1.8.2", + "@raycast/api": "^1.55.2", + "@rockset/client": "^0.9.1", + "@smithy/eventstream-codec": "^2.0.5", + "@smithy/protocol-http": "^3.0.6", + "@smithy/signature-v4": "^2.0.10", + "@smithy/util-utf8": "^2.0.0", + "@spider-cloud/spider-client": "^0.0.21", + "@supabase/supabase-js": "^2.45.0", + "@tensorflow-models/universal-sentence-encoder": "*", + "@tensorflow/tfjs-converter": "*", + "@tensorflow/tfjs-core": "*", + "@upstash/ratelimit": "^1.1.3 || ^2.0.3", + "@upstash/redis": "^1.20.6", + "@upstash/vector": "^1.1.1", + "@vercel/kv": "*", + "@vercel/postgres": "*", + "@writerai/writer-sdk": "^0.40.2", + "@xata.io/client": "^0.28.0", + "@zilliz/milvus2-sdk-node": ">=2.3.5", + "apify-client": "^2.7.1", + "assemblyai": "^4.6.0", + "better-sqlite3": ">=9.4.0 <12.0.0", + "cassandra-driver": "^4.7.2", + "cborg": "^4.1.1", + "cheerio": "^1.0.0-rc.12", + "chromadb": "*", + "closevector-common": "0.1.3", + "closevector-node": "0.1.6", + "closevector-web": "0.1.6", + "cohere-ai": "*", + "convex": "^1.3.1", + "crypto-js": "^4.2.0", + "d3-dsv": "^2.0.0", + "discord.js": "^14.14.1", + "dria": "^0.0.3", + "duck-duck-scrape": "^2.2.5", + "epub2": "^3.0.1", + "faiss-node": "^0.5.1", + "fast-xml-parser": "*", + "firebase-admin": "^11.9.0 || ^12.0.0", + "google-auth-library": "*", + "googleapis": "*", + "hnswlib-node": "^3.0.0", + "html-to-text": "^9.0.5", + "ibm-cloud-sdk-core": "*", + "ignore": "^5.2.0", + "interface-datastore": "^8.2.11", + "ioredis": "^5.3.2", + "it-all": "^3.0.4", + "jsdom": "*", + "jsonwebtoken": "^9.0.2", + "llmonitor": "^0.5.9", + "lodash": "^4.17.21", + "lunary": "^0.7.10", + "mammoth": "^1.6.0", + "mongodb": ">=5.2.0", + "mysql2": "^3.9.8", + "neo4j-driver": "*", + "notion-to-md": "^3.1.0", + "officeparser": "^4.0.4", + "openai": "*", + "pdf-parse": "1.1.1", + "pg": "^8.11.0", + "pg-copy-streams": "^6.0.5", + "pickleparser": "^0.2.1", + "playwright": "^1.32.1", + "portkey-ai": "^0.1.11", + "puppeteer": "*", + "pyodide": ">=0.24.1 <0.27.0", + "redis": "*", + "replicate": "*", + "sonix-speech-recognition": "^2.1.1", + "srt-parser-2": "^1.2.3", + "typeorm": "^0.3.20", + "typesense": "^1.5.3", + "usearch": "^1.1.1", + "voy-search": "0.6.2", + "weaviate-ts-client": "*", + "web-auth-library": "^1.0.3", + "word-extractor": "*", + "ws": "^8.14.2", + "youtubei.js": "*" + }, + "peerDependenciesMeta": { + "@arcjet/redact": { + "optional": true + }, + "@aws-crypto/sha256-js": { + "optional": true + }, + "@aws-sdk/client-bedrock-agent-runtime": { + "optional": true + }, + "@aws-sdk/client-bedrock-runtime": { + "optional": true + }, + "@aws-sdk/client-dynamodb": { + "optional": true + }, + "@aws-sdk/client-kendra": { + "optional": true + }, + "@aws-sdk/client-lambda": { + "optional": true + }, + "@aws-sdk/client-s3": { + "optional": true + }, + "@aws-sdk/client-sagemaker-runtime": { + "optional": true + }, + "@aws-sdk/client-sfn": { + "optional": true + }, + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@azure/search-documents": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@browserbasehq/sdk": { + "optional": true + }, + "@clickhouse/client": { + "optional": true + }, + "@cloudflare/ai": { + "optional": true + }, + "@datastax/astra-db-ts": { + "optional": true + }, + "@elastic/elasticsearch": { + "optional": true + }, + "@getmetal/metal-sdk": { + "optional": true + }, + "@getzep/zep-cloud": { + "optional": true + }, + "@getzep/zep-js": { + "optional": true + }, + "@gomomento/sdk": { + "optional": true + }, + "@gomomento/sdk-core": { + "optional": true + }, + "@google-ai/generativelanguage": { + "optional": true + }, + "@google-cloud/storage": { + "optional": true + }, + "@gradientai/nodejs-sdk": { + "optional": true + }, + "@huggingface/inference": { + "optional": true + }, + "@huggingface/transformers": { + "optional": true + }, + "@lancedb/lancedb": { + "optional": true + }, + "@layerup/layerup-security": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@mendable/firecrawl-js": { + "optional": true + }, + "@mlc-ai/web-llm": { + "optional": true + }, + "@mozilla/readability": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@notionhq/client": { + "optional": true + }, + "@opensearch-project/opensearch": { + "optional": true + }, + "@pinecone-database/pinecone": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@premai/prem-sdk": { + "optional": true + }, + "@qdrant/js-client-rest": { + "optional": true + }, + "@raycast/api": { + "optional": true + }, + "@rockset/client": { + "optional": true + }, + "@smithy/eventstream-codec": { + "optional": true + }, + "@smithy/protocol-http": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "@smithy/util-utf8": { + "optional": true + }, + "@spider-cloud/spider-client": { + "optional": true + }, + "@supabase/supabase-js": { + "optional": true + }, + "@tensorflow-models/universal-sentence-encoder": { + "optional": true + }, + "@tensorflow/tfjs-converter": { + "optional": true + }, + "@tensorflow/tfjs-core": { + "optional": true + }, + "@upstash/ratelimit": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@upstash/vector": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@writerai/writer-sdk": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "@zilliz/milvus2-sdk-node": { + "optional": true + }, + "apify-client": { + "optional": true + }, + "assemblyai": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "cassandra-driver": { + "optional": true + }, + "cborg": { + "optional": true + }, + "cheerio": { + "optional": true + }, + "chromadb": { + "optional": true + }, + "closevector-common": { + "optional": true + }, + "closevector-node": { + "optional": true + }, + "closevector-web": { + "optional": true + }, + "cohere-ai": { + "optional": true + }, + "convex": { + "optional": true + }, + "crypto-js": { + "optional": true + }, + "d3-dsv": { + "optional": true + }, + "discord.js": { + "optional": true + }, + "dria": { + "optional": true + }, + "duck-duck-scrape": { + "optional": true + }, + "epub2": { + "optional": true + }, + "faiss-node": { + "optional": true + }, + "fast-xml-parser": { + "optional": true + }, + "firebase-admin": { + "optional": true + }, + "google-auth-library": { + "optional": true + }, + "googleapis": { + "optional": true + }, + "hnswlib-node": { + "optional": true + }, + "html-to-text": { + "optional": true + }, + "ignore": { + "optional": true + }, + "interface-datastore": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "it-all": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "jsonwebtoken": { + "optional": true + }, + "llmonitor": { + "optional": true + }, + "lodash": { + "optional": true + }, + "lunary": { + "optional": true + }, + "mammoth": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "neo4j-driver": { + "optional": true + }, + "notion-to-md": { + "optional": true + }, + "officeparser": { + "optional": true + }, + "pdf-parse": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-copy-streams": { + "optional": true + }, + "pickleparser": { + "optional": true + }, + "playwright": { + "optional": true + }, + "portkey-ai": { + "optional": true + }, + "puppeteer": { + "optional": true + }, + "pyodide": { + "optional": true + }, + "redis": { + "optional": true + }, + "replicate": { + "optional": true + }, + "sonix-speech-recognition": { + "optional": true + }, + "srt-parser-2": { + "optional": true + }, + "typeorm": { + "optional": true + }, + "typesense": { + "optional": true + }, + "usearch": { + "optional": true + }, + "voy-search": { + "optional": true + }, + "weaviate-ts-client": { + "optional": true + }, + "web-auth-library": { + "optional": true + }, + "word-extractor": { + "optional": true + }, + "ws": { + "optional": true + }, + "youtubei.js": { + "optional": true + } + } + }, + "api/node_modules/@librechat/agents/node_modules/@langchain/openai": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.4.2.tgz", + "integrity": "sha512-Cuj7qbVcycALTP0aqZuPpEc7As8cwiGaU21MhXRyZFs+dnWxKYxZ1Q1z4kcx6cYkq/I+CNwwmk+sP+YruU73Aw==", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^4.77.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.3.29 <0.4.0" + } + }, "api/node_modules/@types/node": { "version": "18.19.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.14.tgz", @@ -10299,577 +10858,6 @@ "@lezer/common": "^1.0.0" } }, - "node_modules/@librechat/agents": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.0.2.tgz", - "integrity": "sha512-ucH1zb2nHpAafXq6YNNFBHl5rwBEoTl5CUZ6M9r5Mp1oyk9vSAz+knOCaUgYMU5GJqY+6ReFWRH9tnvZfrzhTQ==", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-sdk/credential-provider-node": "^3.613.0", - "@aws-sdk/types": "^3.609.0", - "@langchain/anthropic": "^0.3.12", - "@langchain/aws": "^0.1.3", - "@langchain/community": "^0.3.27", - "@langchain/core": "^0.3.37", - "@langchain/google-genai": "^0.1.7", - "@langchain/google-vertexai": "^0.1.8", - "@langchain/langgraph": "^0.2.41", - "@langchain/mistralai": "^0.0.26", - "@langchain/ollama": "^0.1.5", - "@langchain/openai": "^0.4.2", - "@smithy/eventstream-codec": "^2.2.0", - "@smithy/protocol-http": "^3.0.6", - "@smithy/signature-v4": "^2.0.10", - "@smithy/util-utf8": "^2.0.0", - "dotenv": "^16.4.5", - "nanoid": "^3.3.7" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@librechat/agents/node_modules/@langchain/community": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.3.29.tgz", - "integrity": "sha512-6XIPGctpH3KziFpdVDEvYBEQMMsNomffqy545shoxOLoMkZqgn1wfMs6R7ltzzS0p3LJUXW1RbwAUEuWp0rbuA==", - "dependencies": { - "@langchain/openai": ">=0.2.0 <0.5.0", - "binary-extensions": "^2.2.0", - "expr-eval": "^2.0.2", - "flat": "^5.0.2", - "js-yaml": "^4.1.0", - "langchain": ">=0.2.3 <0.3.0 || >=0.3.4 <0.4.0", - "langsmith": ">=0.2.8 <0.4.0", - "uuid": "^10.0.0", - "zod": "^3.22.3", - "zod-to-json-schema": "^3.22.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@arcjet/redact": "^v1.0.0-alpha.23", - "@aws-crypto/sha256-js": "^5.0.0", - "@aws-sdk/client-bedrock-agent-runtime": "^3.583.0", - "@aws-sdk/client-bedrock-runtime": "^3.422.0", - "@aws-sdk/client-dynamodb": "^3.310.0", - "@aws-sdk/client-kendra": "^3.352.0", - "@aws-sdk/client-lambda": "^3.310.0", - "@aws-sdk/client-s3": "^3.310.0", - "@aws-sdk/client-sagemaker-runtime": "^3.310.0", - "@aws-sdk/client-sfn": "^3.310.0", - "@aws-sdk/credential-provider-node": "^3.388.0", - "@azure/search-documents": "^12.0.0", - "@azure/storage-blob": "^12.15.0", - "@browserbasehq/sdk": "*", - "@browserbasehq/stagehand": "^1.0.0", - "@clickhouse/client": "^0.2.5", - "@cloudflare/ai": "*", - "@datastax/astra-db-ts": "^1.0.0", - "@elastic/elasticsearch": "^8.4.0", - "@getmetal/metal-sdk": "*", - "@getzep/zep-cloud": "^1.0.6", - "@getzep/zep-js": "^0.9.0", - "@gomomento/sdk": "^1.51.1", - "@gomomento/sdk-core": "^1.51.1", - "@google-ai/generativelanguage": "*", - "@google-cloud/storage": "^6.10.1 || ^7.7.0", - "@gradientai/nodejs-sdk": "^1.2.0", - "@huggingface/inference": "^2.6.4", - "@huggingface/transformers": "^3.2.3", - "@ibm-cloud/watsonx-ai": "*", - "@lancedb/lancedb": "^0.12.0", - "@langchain/core": ">=0.2.21 <0.4.0", - "@layerup/layerup-security": "^1.5.12", - "@libsql/client": "^0.14.0", - "@mendable/firecrawl-js": "^1.4.3", - "@mlc-ai/web-llm": "*", - "@mozilla/readability": "*", - "@neondatabase/serverless": "*", - "@notionhq/client": "^2.2.10", - "@opensearch-project/opensearch": "*", - "@pinecone-database/pinecone": "*", - "@planetscale/database": "^1.8.0", - "@premai/prem-sdk": "^0.3.25", - "@qdrant/js-client-rest": "^1.8.2", - "@raycast/api": "^1.55.2", - "@rockset/client": "^0.9.1", - "@smithy/eventstream-codec": "^2.0.5", - "@smithy/protocol-http": "^3.0.6", - "@smithy/signature-v4": "^2.0.10", - "@smithy/util-utf8": "^2.0.0", - "@spider-cloud/spider-client": "^0.0.21", - "@supabase/supabase-js": "^2.45.0", - "@tensorflow-models/universal-sentence-encoder": "*", - "@tensorflow/tfjs-converter": "*", - "@tensorflow/tfjs-core": "*", - "@upstash/ratelimit": "^1.1.3 || ^2.0.3", - "@upstash/redis": "^1.20.6", - "@upstash/vector": "^1.1.1", - "@vercel/kv": "*", - "@vercel/postgres": "*", - "@writerai/writer-sdk": "^0.40.2", - "@xata.io/client": "^0.28.0", - "@zilliz/milvus2-sdk-node": ">=2.3.5", - "apify-client": "^2.7.1", - "assemblyai": "^4.6.0", - "better-sqlite3": ">=9.4.0 <12.0.0", - "cassandra-driver": "^4.7.2", - "cborg": "^4.1.1", - "cheerio": "^1.0.0-rc.12", - "chromadb": "*", - "closevector-common": "0.1.3", - "closevector-node": "0.1.6", - "closevector-web": "0.1.6", - "cohere-ai": "*", - "convex": "^1.3.1", - "crypto-js": "^4.2.0", - "d3-dsv": "^2.0.0", - "discord.js": "^14.14.1", - "dria": "^0.0.3", - "duck-duck-scrape": "^2.2.5", - "epub2": "^3.0.1", - "faiss-node": "^0.5.1", - "fast-xml-parser": "*", - "firebase-admin": "^11.9.0 || ^12.0.0", - "google-auth-library": "*", - "googleapis": "*", - "hnswlib-node": "^3.0.0", - "html-to-text": "^9.0.5", - "ibm-cloud-sdk-core": "*", - "ignore": "^5.2.0", - "interface-datastore": "^8.2.11", - "ioredis": "^5.3.2", - "it-all": "^3.0.4", - "jsdom": "*", - "jsonwebtoken": "^9.0.2", - "llmonitor": "^0.5.9", - "lodash": "^4.17.21", - "lunary": "^0.7.10", - "mammoth": "^1.6.0", - "mongodb": ">=5.2.0", - "mysql2": "^3.9.8", - "neo4j-driver": "*", - "notion-to-md": "^3.1.0", - "officeparser": "^4.0.4", - "openai": "*", - "pdf-parse": "1.1.1", - "pg": "^8.11.0", - "pg-copy-streams": "^6.0.5", - "pickleparser": "^0.2.1", - "playwright": "^1.32.1", - "portkey-ai": "^0.1.11", - "puppeteer": "*", - "pyodide": ">=0.24.1 <0.27.0", - "redis": "*", - "replicate": "*", - "sonix-speech-recognition": "^2.1.1", - "srt-parser-2": "^1.2.3", - "typeorm": "^0.3.20", - "typesense": "^1.5.3", - "usearch": "^1.1.1", - "voy-search": "0.6.2", - "weaviate-ts-client": "*", - "web-auth-library": "^1.0.3", - "word-extractor": "*", - "ws": "^8.14.2", - "youtubei.js": "*" - }, - "peerDependenciesMeta": { - "@arcjet/redact": { - "optional": true - }, - "@aws-crypto/sha256-js": { - "optional": true - }, - "@aws-sdk/client-bedrock-agent-runtime": { - "optional": true - }, - "@aws-sdk/client-bedrock-runtime": { - "optional": true - }, - "@aws-sdk/client-dynamodb": { - "optional": true - }, - "@aws-sdk/client-kendra": { - "optional": true - }, - "@aws-sdk/client-lambda": { - "optional": true - }, - "@aws-sdk/client-s3": { - "optional": true - }, - "@aws-sdk/client-sagemaker-runtime": { - "optional": true - }, - "@aws-sdk/client-sfn": { - "optional": true - }, - "@aws-sdk/credential-provider-node": { - "optional": true - }, - "@azure/search-documents": { - "optional": true - }, - "@azure/storage-blob": { - "optional": true - }, - "@browserbasehq/sdk": { - "optional": true - }, - "@clickhouse/client": { - "optional": true - }, - "@cloudflare/ai": { - "optional": true - }, - "@datastax/astra-db-ts": { - "optional": true - }, - "@elastic/elasticsearch": { - "optional": true - }, - "@getmetal/metal-sdk": { - "optional": true - }, - "@getzep/zep-cloud": { - "optional": true - }, - "@getzep/zep-js": { - "optional": true - }, - "@gomomento/sdk": { - "optional": true - }, - "@gomomento/sdk-core": { - "optional": true - }, - "@google-ai/generativelanguage": { - "optional": true - }, - "@google-cloud/storage": { - "optional": true - }, - "@gradientai/nodejs-sdk": { - "optional": true - }, - "@huggingface/inference": { - "optional": true - }, - "@huggingface/transformers": { - "optional": true - }, - "@lancedb/lancedb": { - "optional": true - }, - "@layerup/layerup-security": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@mendable/firecrawl-js": { - "optional": true - }, - "@mlc-ai/web-llm": { - "optional": true - }, - "@mozilla/readability": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@notionhq/client": { - "optional": true - }, - "@opensearch-project/opensearch": { - "optional": true - }, - "@pinecone-database/pinecone": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@premai/prem-sdk": { - "optional": true - }, - "@qdrant/js-client-rest": { - "optional": true - }, - "@raycast/api": { - "optional": true - }, - "@rockset/client": { - "optional": true - }, - "@smithy/eventstream-codec": { - "optional": true - }, - "@smithy/protocol-http": { - "optional": true - }, - "@smithy/signature-v4": { - "optional": true - }, - "@smithy/util-utf8": { - "optional": true - }, - "@spider-cloud/spider-client": { - "optional": true - }, - "@supabase/supabase-js": { - "optional": true - }, - "@tensorflow-models/universal-sentence-encoder": { - "optional": true - }, - "@tensorflow/tfjs-converter": { - "optional": true - }, - "@tensorflow/tfjs-core": { - "optional": true - }, - "@upstash/ratelimit": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@upstash/vector": { - "optional": true - }, - "@vercel/kv": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@writerai/writer-sdk": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "@zilliz/milvus2-sdk-node": { - "optional": true - }, - "apify-client": { - "optional": true - }, - "assemblyai": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "cassandra-driver": { - "optional": true - }, - "cborg": { - "optional": true - }, - "cheerio": { - "optional": true - }, - "chromadb": { - "optional": true - }, - "closevector-common": { - "optional": true - }, - "closevector-node": { - "optional": true - }, - "closevector-web": { - "optional": true - }, - "cohere-ai": { - "optional": true - }, - "convex": { - "optional": true - }, - "crypto-js": { - "optional": true - }, - "d3-dsv": { - "optional": true - }, - "discord.js": { - "optional": true - }, - "dria": { - "optional": true - }, - "duck-duck-scrape": { - "optional": true - }, - "epub2": { - "optional": true - }, - "faiss-node": { - "optional": true - }, - "fast-xml-parser": { - "optional": true - }, - "firebase-admin": { - "optional": true - }, - "google-auth-library": { - "optional": true - }, - "googleapis": { - "optional": true - }, - "hnswlib-node": { - "optional": true - }, - "html-to-text": { - "optional": true - }, - "ignore": { - "optional": true - }, - "interface-datastore": { - "optional": true - }, - "ioredis": { - "optional": true - }, - "it-all": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "jsonwebtoken": { - "optional": true - }, - "llmonitor": { - "optional": true - }, - "lodash": { - "optional": true - }, - "lunary": { - "optional": true - }, - "mammoth": { - "optional": true - }, - "mongodb": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "neo4j-driver": { - "optional": true - }, - "notion-to-md": { - "optional": true - }, - "officeparser": { - "optional": true - }, - "pdf-parse": { - "optional": true - }, - "pg": { - "optional": true - }, - "pg-copy-streams": { - "optional": true - }, - "pickleparser": { - "optional": true - }, - "playwright": { - "optional": true - }, - "portkey-ai": { - "optional": true - }, - "puppeteer": { - "optional": true - }, - "pyodide": { - "optional": true - }, - "redis": { - "optional": true - }, - "replicate": { - "optional": true - }, - "sonix-speech-recognition": { - "optional": true - }, - "srt-parser-2": { - "optional": true - }, - "typeorm": { - "optional": true - }, - "typesense": { - "optional": true - }, - "usearch": { - "optional": true - }, - "voy-search": { - "optional": true - }, - "weaviate-ts-client": { - "optional": true - }, - "web-auth-library": { - "optional": true - }, - "word-extractor": { - "optional": true - }, - "ws": { - "optional": true - }, - "youtubei.js": { - "optional": true - } - } - }, - "node_modules/@librechat/agents/node_modules/@langchain/openai": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.4.2.tgz", - "integrity": "sha512-Cuj7qbVcycALTP0aqZuPpEc7As8cwiGaU21MhXRyZFs+dnWxKYxZ1Q1z4kcx6cYkq/I+CNwwmk+sP+YruU73Aw==", - "dependencies": { - "js-tiktoken": "^1.0.12", - "openai": "^4.77.0", - "zod": "^3.22.4", - "zod-to-json-schema": "^3.22.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@langchain/core": ">=0.3.29 <0.4.0" - } - }, - "node_modules/@librechat/agents/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@librechat/backend": { "resolved": "api", "link": true @@ -36358,7 +36346,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.7.699", + "version": "0.7.6991", "license": "ISC", "dependencies": { "axios": "^1.7.7", @@ -36529,6 +36517,9 @@ "rollup-plugin-typescript2": "^0.35.0", "ts-node": "^10.9.2", "typescript": "^5.0.4" + }, + "peerDependencies": { + "keyv": "^4.5.4" } }, "packages/mcp/node_modules/brace-expansion": { diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 976cf85d1..240d099e0 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.7.699", + "version": "0.7.6991", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/specs/actions.spec.ts b/packages/data-provider/specs/actions.spec.ts index 6a09bb6b0..1bd2b5494 100644 --- a/packages/data-provider/specs/actions.spec.ts +++ b/packages/data-provider/specs/actions.spec.ts @@ -21,6 +21,7 @@ import type { ParametersSchema } from '../src/actions'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; +mockedAxios.create.mockReturnValue(mockedAxios); describe('FunctionSignature', () => { it('creates a function signature and converts to JSON tool', () => { diff --git a/packages/data-provider/src/actions.ts b/packages/data-provider/src/actions.ts index 657268f7a..94b322916 100644 --- a/packages/data-provider/src/actions.ts +++ b/packages/data-provider/src/actions.ts @@ -1,9 +1,15 @@ import { z } from 'zod'; -import axios from 'axios'; +import _axios from 'axios'; import { URL } from 'url'; import crypto from 'crypto'; import { load } from 'js-yaml'; -import type { FunctionTool, Schema, Reference, ActionMetadata } from './types/assistants'; +import type { + FunctionTool, + Schema, + Reference, + ActionMetadata, + ActionMetadataRuntime, +} from './types/assistants'; import type { OpenAPIV3 } from 'openapi-types'; import { Tools, AuthTypeEnum, AuthorizationTypeEnum } from './types/assistants'; @@ -176,7 +182,7 @@ class RequestExecutor { return this; } - async setAuth(metadata: ActionMetadata) { + async setAuth(metadata: ActionMetadataRuntime) { if (!metadata.auth) { return this; } @@ -199,6 +205,8 @@ class RequestExecutor { /* OAuth */ oauth_client_id, oauth_client_secret, + oauth_token_expires_at, + oauth_access_token = '', } = metadata; const isApiKey = api_key != null && api_key.length > 0 && type === AuthTypeEnum.ServiceHttp; @@ -230,22 +238,23 @@ class RequestExecutor { ) { this.authHeaders[custom_auth_header] = api_key; } else if (isOAuth) { - const authToken = this.authToken ?? ''; - if (!authToken) { - const tokenResponse = await axios.post( - client_url, - { - client_id: oauth_client_id, - client_secret: oauth_client_secret, - scope: scope, - grant_type: 'client_credentials', - }, - { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }, - ); - this.authToken = tokenResponse.data.access_token; + // TODO: maybe doing it in a different way later on. but we want that the user needs to folllow the oauth flow. + // If we do not have a valid token, bail or ask user to sign in + const now = new Date(); + + // 1. Check if token is set + if (!oauth_access_token) { + throw new Error('No access token found. Please log in first.'); } + + // 2. Check if token is expired + if (oauth_token_expires_at && now >= new Date(oauth_token_expires_at)) { + // Optionally check refresh_token logic, or just prompt user to re-login + throw new Error('Access token is expired. Please re-login.'); + } + + // If valid, use it + this.authToken = oauth_access_token; this.authHeaders['Authorization'] = `Bearer ${this.authToken}`; } return this; @@ -259,7 +268,7 @@ class RequestExecutor { }; const method = this.config.method.toLowerCase(); - + const axios = _axios.create(); if (method === 'get') { return axios.get(url, { headers, params: this.params }); } else if (method === 'post') { @@ -511,6 +520,7 @@ export function validateAndParseOpenAPISpec(specString: string): ValidationResul spec: parsedSpec, }; } catch (error) { + console.error(error); return { status: false, message: 'Error parsing OpenAPI spec.' }; } } diff --git a/packages/data-provider/src/bedrock.ts b/packages/data-provider/src/bedrock.ts index 6ee2be2fe..be72b6453 100644 --- a/packages/data-provider/src/bedrock.ts +++ b/packages/data-provider/src/bedrock.ts @@ -25,7 +25,7 @@ export const bedrockInputSchema = s.tConversationSchema topK: true, additionalModelRequestFields: true, }) - .transform(s.removeNullishValues) + .transform((obj) => s.removeNullishValues(obj)) .catch(() => ({})); export type BedrockConverseInput = z.infer; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 6a2db199b..008b00911 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -932,6 +932,10 @@ export enum CacheKeys { * Key for in-progress messages. */ MESSAGES = 'messages', + /** + * Key for in-progress flow states. + */ + FLOWS = 'flows', } /** diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 8c984cb87..04eb04917 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -877,7 +877,10 @@ export const gptPluginsSchema = tConversationSchema maxContextTokens: undefined, })); -export function removeNullishValues>(obj: T): Partial { +export function removeNullishValues>( + obj: T, + removeEmptyStrings?: boolean, +): Partial { const newObj: Partial = { ...obj }; (Object.keys(newObj) as Array).forEach((key) => { @@ -885,6 +888,9 @@ export function removeNullishValues>(obj: T): if (value === undefined || value === null) { delete newObj[key]; } + if (removeEmptyStrings && typeof value === 'string' && value === '') { + delete newObj[key]; + } }); return newObj; @@ -935,8 +941,7 @@ export const compactAssistantSchema = tConversationSchema greeting: true, spec: true, }) - // will change after adding temperature - .transform(removeNullishValues) + .transform((obj) => removeNullishValues(obj)) .catch(() => ({})); export const agentsSchema = tConversationSchema @@ -1138,7 +1143,7 @@ export const compactPluginsSchema = tConversationSchema }) .catch(() => ({})); -const tBannerSchema = z.object({ +export const tBannerSchema = z.object({ bannerId: z.string(), message: z.string(), displayFrom: z.string(), @@ -1160,5 +1165,5 @@ export const compactAgentsSchema = tConversationSchema instructions: true, additional_instructions: true, }) - .transform(removeNullishValues) + .transform((obj) => removeNullishValues(obj)) .catch(() => ({})); diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index 745786e3b..b1021d80f 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -52,6 +52,10 @@ export namespace Agents { id?: string; /** If provided, the output of the tool call */ output?: string; + /** Auth URL */ + auth?: string; + /** Expiration time */ + expires_at?: number; }; export type ToolEndEvent = { @@ -190,6 +194,8 @@ export namespace Agents { export type ToolCallDelta = { type: StepTypes.TOOL_CALLS | string; tool_calls?: ToolCallChunk[]; + auth?: string; + expires_at?: number; }; export type AgentToolCall = FunctionToolCall | ToolCall; export interface ExtendedMessageContent { diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index bd5ef9d77..d5b773c36 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -417,6 +417,8 @@ export type PartMetadata = { asset_pointer?: string; status?: string; action?: boolean; + auth?: string; + expires_at?: number; }; export type ContentPart = ( @@ -506,6 +508,12 @@ export type ActionMetadata = { oauth_client_secret?: string; }; +export type ActionMetadataRuntime = ActionMetadata & { + oauth_access_token?: string; + oauth_refresh_token?: string; + oauth_token_expires_at?: Date; +}; + /* Assistant types */ export type Action = { diff --git a/packages/data-provider/tsconfig.json b/packages/data-provider/tsconfig.json index 4de513a42..420a84852 100644 --- a/packages/data-provider/tsconfig.json +++ b/packages/data-provider/tsconfig.json @@ -18,9 +18,8 @@ "isolatedModules": true, "noEmit": true, "sourceMap": true, - "baseUrl": ".", // This should be the root of your package + "baseUrl": ".", "paths": { - // Add path mappings "librechat-data-provider/react-query": ["./src/react-query/index.ts"] } }, diff --git a/packages/mcp/babel.config.js b/packages/mcp/babel.config.cjs similarity index 100% rename from packages/mcp/babel.config.js rename to packages/mcp/babel.config.cjs diff --git a/packages/mcp/jest.config.js b/packages/mcp/jest.config.mjs similarity index 91% rename from packages/mcp/jest.config.js rename to packages/mcp/jest.config.mjs index 6b8c4abe7..f5fb1f20d 100644 --- a/packages/mcp/jest.config.js +++ b/packages/mcp/jest.config.mjs @@ -1,4 +1,4 @@ -module.exports = { +export default { collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!/node_modules/'], coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], coverageReporters: ['text', 'cobertura'], @@ -15,4 +15,5 @@ module.exports = { // }, // }, restoreMocks: true, -}; + testTimeout: 15000, +}; \ No newline at end of file diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 1ab924f01..d76653a00 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -73,5 +73,8 @@ "diff": "^7.0.0", "eventsource": "^3.0.1", "express": "^4.21.2" + }, + "peerDependencies": { + "keyv": "^4.5.4" } } diff --git a/packages/mcp/src/flow/manager.spec.ts b/packages/mcp/src/flow/manager.spec.ts new file mode 100644 index 000000000..2d8897747 --- /dev/null +++ b/packages/mcp/src/flow/manager.spec.ts @@ -0,0 +1,152 @@ +import { FlowStateManager } from './manager'; +import Keyv from 'keyv'; +import type { FlowState } from './types'; + +// Create a mock class without extending Keyv +class MockKeyv { + private store: Map>; + + constructor() { + this.store = new Map(); + } + + async get(key: string): Promise | undefined> { + return this.store.get(key); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async set(key: string, value: FlowState, _ttl?: number): Promise { + this.store.set(key, value); + return true; + } + + async delete(key: string): Promise { + return this.store.delete(key); + } +} + +describe('FlowStateManager', () => { + let flowManager: FlowStateManager; + let store: MockKeyv; + + beforeEach(() => { + store = new MockKeyv(); + // Type assertion here since we know our mock implements the necessary methods + flowManager = new FlowStateManager(store as unknown as Keyv, { ttl: 30000, ci: true }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Concurrency Tests', () => { + it('should handle concurrent flow creation and return same result', async () => { + const flowId = 'test-flow'; + const type = 'test-type'; + + // Start two concurrent flow creations + const flow1Promise = flowManager.createFlowWithHandler(flowId, type, async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return 'result'; + }); + + const flow2Promise = flowManager.createFlowWithHandler(flowId, type, async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return 'different-result'; + }); + + // Both should resolve to the same result from the first handler + const [result1, result2] = await Promise.all([flow1Promise, flow2Promise]); + + expect(result1).toBe('result'); + expect(result2).toBe('result'); + }); + + it('should handle flow timeout correctly', async () => { + const flowId = 'timeout-flow'; + const type = 'test-type'; + + // Create flow with very short TTL + const shortTtlManager = new FlowStateManager(store as unknown as Keyv, { + ttl: 100, + ci: true, + }); + + const flowPromise = shortTtlManager.createFlow(flowId, type); + + await expect(flowPromise).rejects.toThrow('test-type flow timed out'); + }); + + it('should maintain flow state consistency under high concurrency', async () => { + const flowId = 'concurrent-flow'; + const type = 'test-type'; + + // Create multiple concurrent operations + const operations = []; + for (let i = 0; i < 10; i++) { + operations.push( + flowManager.createFlowWithHandler(flowId, type, async () => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 50)); + return `result-${i}`; + }), + ); + } + + // All operations should resolve to the same result + const results = await Promise.all(operations); + const firstResult = results[0]; + results.forEach((result: string) => { + expect(result).toBe(firstResult); + }); + }); + + it('should handle race conditions in flow completion', async () => { + const flowId = 'test-flow'; + const type = 'test-type'; + + // Create initial flow + const flowPromise = flowManager.createFlow(flowId, type); + + // Increase delay to ensure flow is properly created + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Complete the flow + await flowManager.completeFlow(flowId, type, 'result1'); + + const result = await flowPromise; + expect(result).toBe('result1'); + }, 15000); + + it('should handle concurrent flow monitoring', async () => { + const flowId = 'test-flow'; + const type = 'test-type'; + + // Create initial flow + const flowPromise = flowManager.createFlow(flowId, type); + + // Increase delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Complete the flow + await flowManager.completeFlow(flowId, type, 'success'); + + const result = await flowPromise; + expect(result).toBe('success'); + }, 15000); + + it('should handle concurrent success and failure attempts', async () => { + const flowId = 'race-flow'; + const type = 'test-type'; + + const flowPromise = flowManager.createFlow(flowId, type); + + // Increase delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Fail the flow + await flowManager.failFlow(flowId, type, new Error('failure')); + + await expect(flowPromise).rejects.toThrow('failure'); + }, 15000); + }); +}); diff --git a/packages/mcp/src/flow/manager.ts b/packages/mcp/src/flow/manager.ts new file mode 100644 index 000000000..f282bfad8 --- /dev/null +++ b/packages/mcp/src/flow/manager.ts @@ -0,0 +1,241 @@ +import Keyv from 'keyv'; +import type { Logger } from 'winston'; +import type { FlowState, FlowMetadata, FlowManagerOptions } from './types'; + +export class FlowStateManager { + private keyv: Keyv; + private ttl: number; + private logger: Logger; + private intervals: Set; + + private static getDefaultLogger(): Logger { + return { + error: console.error, + warn: console.warn, + info: console.info, + debug: console.debug, + } as Logger; + } + + constructor(store: Keyv, options?: FlowManagerOptions) { + if (!options) { + options = { ttl: 60000 * 3 }; + } + const { ci = false, ttl, logger } = options; + + if (!ci && !(store instanceof Keyv)) { + throw new Error('Invalid store provided to FlowStateManager'); + } + + this.ttl = ttl; + this.keyv = store; + this.logger = logger || FlowStateManager.getDefaultLogger(); + this.intervals = new Set(); + this.setupCleanupHandlers(); + } + + private setupCleanupHandlers() { + const cleanup = () => { + this.logger.info('Cleaning up FlowStateManager intervals...'); + this.intervals.forEach((interval) => clearInterval(interval)); + this.intervals.clear(); + process.exit(0); + }; + + process.on('SIGTERM', cleanup); + process.on('SIGINT', cleanup); + process.on('SIGQUIT', cleanup); + process.on('SIGHUP', cleanup); + } + + private getFlowKey(flowId: string, type: string): string { + return `${type}:${flowId}`; + } + + /** + * Creates a new flow and waits for its completion + */ + async createFlow(flowId: string, type: string, metadata: FlowMetadata = {}): Promise { + const flowKey = this.getFlowKey(flowId, type); + + let existingState = (await this.keyv.get(flowKey)) as FlowState | undefined; + if (existingState) { + this.logger.debug(`[${flowKey}] Flow already exists`); + return this.monitorFlow(flowKey, type); + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + + existingState = (await this.keyv.get(flowKey)) as FlowState | undefined; + if (existingState) { + this.logger.debug(`[${flowKey}] Flow exists on 2nd check`); + return this.monitorFlow(flowKey, type); + } + + const initialState: FlowState = { + type, + status: 'PENDING', + metadata, + createdAt: Date.now(), + }; + + this.logger.debug('Creating initial flow state:', flowKey); + await this.keyv.set(flowKey, initialState, this.ttl); + return this.monitorFlow(flowKey, type); + } + + private monitorFlow(flowKey: string, type: string): Promise { + return new Promise((resolve, reject) => { + const checkInterval = 2000; + let elapsedTime = 0; + + const intervalId = setInterval(async () => { + try { + const flowState = (await this.keyv.get(flowKey)) as FlowState | undefined; + + if (!flowState) { + clearInterval(intervalId); + this.intervals.delete(intervalId); + this.logger.error(`[${flowKey}] Flow state not found`); + reject(new Error(`${type} Flow state not found`)); + return; + } + + if (flowState.status !== 'PENDING') { + clearInterval(intervalId); + this.intervals.delete(intervalId); + this.logger.debug(`[${flowKey}] Flow completed`); + + if (flowState.status === 'COMPLETED' && flowState.result !== undefined) { + resolve(flowState.result); + } else if (flowState.status === 'FAILED') { + await this.keyv.delete(flowKey); + reject(new Error(flowState.error ?? `${type} flow failed`)); + } + return; + } + + elapsedTime += checkInterval; + if (elapsedTime >= this.ttl) { + clearInterval(intervalId); + this.intervals.delete(intervalId); + this.logger.error( + `[${flowKey}] Flow timed out | Elapsed time: ${elapsedTime} | TTL: ${this.ttl}`, + ); + await this.keyv.delete(flowKey); + reject(new Error(`${type} flow timed out`)); + } + this.logger.debug( + `[${flowKey}] Flow state elapsed time: ${elapsedTime}, checking again...`, + ); + } catch (error) { + this.logger.error(`[${flowKey}] Error checking flow state:`, error); + clearInterval(intervalId); + this.intervals.delete(intervalId); + reject(error); + } + }, checkInterval); + + this.intervals.add(intervalId); + }); + } + + /** + * Completes a flow successfully + */ + async completeFlow(flowId: string, type: string, result: T): Promise { + const flowKey = this.getFlowKey(flowId, type); + const flowState = (await this.keyv.get(flowKey)) as FlowState | undefined; + + if (!flowState) { + return false; + } + + const updatedState: FlowState = { + ...flowState, + status: 'COMPLETED', + result, + completedAt: Date.now(), + }; + + await this.keyv.set(flowKey, updatedState, this.ttl); + return true; + } + + /** + * Marks a flow as failed + */ + async failFlow(flowId: string, type: string, error: Error | string): Promise { + const flowKey = this.getFlowKey(flowId, type); + const flowState = (await this.keyv.get(flowKey)) as FlowState | undefined; + + if (!flowState) { + return false; + } + + const updatedState: FlowState = { + ...flowState, + status: 'FAILED', + error: error instanceof Error ? error.message : error, + failedAt: Date.now(), + }; + + await this.keyv.set(flowKey, updatedState, this.ttl); + return true; + } + + /** + * Gets current flow state + */ + async getFlowState(flowId: string, type: string): Promise | null> { + const flowKey = this.getFlowKey(flowId, type); + return this.keyv.get(flowKey); + } + + /** + * Creates a new flow and waits for its completion, only executing the handler if no existing flow is found + * @param flowId - The ID of the flow + * @param type - The type of flow + * @param handler - Async function to execute if no existing flow is found + * @param metadata - Optional metadata for the flow + */ + async createFlowWithHandler( + flowId: string, + type: string, + handler: () => Promise, + metadata: FlowMetadata = {}, + ): Promise { + const flowKey = this.getFlowKey(flowId, type); + let existingState = (await this.keyv.get(flowKey)) as FlowState | undefined; + if (existingState) { + this.logger.debug(`[${flowKey}] Flow already exists`); + return this.monitorFlow(flowKey, type); + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + + existingState = (await this.keyv.get(flowKey)) as FlowState | undefined; + if (existingState) { + this.logger.debug(`[${flowKey}] Flow exists on 2nd check`); + return this.monitorFlow(flowKey, type); + } + + const initialState: FlowState = { + type, + status: 'PENDING', + metadata, + createdAt: Date.now(), + }; + this.logger.debug(`[${flowKey}] Creating initial flow state`); + await this.keyv.set(flowKey, initialState, this.ttl); + + try { + const result = await handler(); + await this.completeFlow(flowId, type, result); + return result; + } catch (error) { + await this.failFlow(flowId, type, error instanceof Error ? error : new Error(String(error))); + throw error; + } + } +} diff --git a/packages/mcp/src/flow/types.ts b/packages/mcp/src/flow/types.ts new file mode 100644 index 000000000..491dcb789 --- /dev/null +++ b/packages/mcp/src/flow/types.ts @@ -0,0 +1,23 @@ +import type { Logger } from 'winston'; +export type FlowStatus = 'PENDING' | 'COMPLETED' | 'FAILED'; + +export interface FlowMetadata { + [key: string]: unknown; +} + +export interface FlowState { + type: string; + status: FlowStatus; + metadata: FlowMetadata; + createdAt: number; + result?: T; + error?: string; + completedAt?: number; + failedAt?: number; +} + +export interface FlowManagerOptions { + ttl: number; + ci?: boolean; + logger?: Logger; +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index ecf601787..ba1dcdf82 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,4 +1,7 @@ /* MCP */ export * from './manager'; +/* Flow */ +export * from './flow/manager'; /* types */ export type * from './types/mcp'; +export type * from './flow/types'; diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json index 94667987f..7c03dc1fb 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/mcp/tsconfig.json @@ -18,7 +18,7 @@ "isolatedModules": true, "noEmit": true, "sourceMap": true, - "baseUrl": "." // This should be the root of your package + "baseUrl": "." }, "ts-node": { "experimentalSpecifierResolution": "node",