🚧 WIP: Merge Dev Build (#4611)

* refactor: Agent CodeFiles, abortUpload WIP

* feat: code environment file upload

* refactor: useLazyEffect

* refactor:
- Add `watch` from `useFormContext` to check if code execution is enabled
- Disable file upload button if `agent_id` is not selected or code execution is disabled

* WIP: primeCodeFiles; refactor: rename sessionId to session_id for uniformity

* Refactor: Rename session_id to sessionId for uniformity in AuthService.js

* chore: bump @librechat/agents to version 1.7.1

* WIP: prime code files

* refactor: Update code env file upload method to use read stream

* feat: reupload code env file if no longer active

* refactor: isAssistantTool -> isEntityTool + address type issues

* feat: execute code tool hook

* refactor: Rename isPluginAuthenticated to checkPluginAuth in PluginController.js

* refactor: Update PluginController.js to use AuthType constant for comparison

* feat: verify tool authentication (execute_code)

* feat: enter librechat_code_api_key

* refactor: Remove unused imports in BookmarkForm.tsx

* feat: authenticate code tool

* refactor: Update Action.tsx to conditionally render the key and revoke key buttons

* refactor(Code/Action): prevent uncheck-able 'Run Code' capability when key is revoked

* refactor(Code/Action): Update Action.tsx to conditionally render the key and revoke key buttons

* fix: agent file upload edge cases

* chore: bump @librechat/agents

* fix: custom endpoint providerValue icon

* feat: ollama meta modal token values + context

* feat: ollama agents

* refactor: Update token models for Ollama models

* chore: Comment out CodeForm

* refactor: Update token models for Ollama and Meta models
This commit is contained in:
Danny Avila 2024-11-01 18:36:39 -04:00 committed by GitHub
parent 1909efd6ba
commit 95011ce349
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1418 additions and 1002 deletions

View file

@ -24,6 +24,7 @@ const {
StructuredWolfram, StructuredWolfram,
TavilySearchResults, TavilySearchResults,
} = require('../'); } = require('../');
const { primeFiles } = require('~/server/services/Files/Code/process');
const createFileSearchTool = require('./createFileSearchTool'); const createFileSearchTool = require('./createFileSearchTool');
const { loadToolSuite } = require('./loadToolSuite'); const { loadToolSuite } = require('./loadToolSuite');
const { loadSpecs } = require('./loadSpecs'); const { loadSpecs } = require('./loadSpecs');
@ -255,12 +256,14 @@ const loadTools = async ({
for (const tool of tools) { for (const tool of tools) {
if (tool === Tools.execute_code) { if (tool === Tools.execute_code) {
const authValues = await loadAuthValues({ const authValues = await loadAuthValues({
userId: user.id, userId: user,
authFields: [EnvVar.CODE_API_KEY], authFields: [EnvVar.CODE_API_KEY],
}); });
const files = await primeFiles(options, authValues[EnvVar.CODE_API_KEY]);
requestedTools[tool] = () => requestedTools[tool] = () =>
createCodeExecutionTool({ createCodeExecutionTool({
user_id: user.id, user_id: user,
files,
...authValues, ...authValues,
}); });
continue; continue;

View file

@ -21,6 +21,8 @@ const mongoose = require('mongoose');
* @property {string} [source] - The source of the file (e.g., from FileSources) * @property {string} [source] - The source of the file (e.g., from FileSources)
* @property {number} [width] - Optional width of the file * @property {number} [width] - Optional width of the file
* @property {number} [height] - Optional height of the file * @property {number} [height] - Optional height of the file
* @property {Object} [metadata] - Metadata related to the file
* @property {string} [metadata.fileIdentifier] - Unique identifier for the file in metadata
* @property {Date} [expiresAt] - Optional expiration date of the file * @property {Date} [expiresAt] - Optional expiration date of the file
* @property {Date} [createdAt] - Date when the file was created * @property {Date} [createdAt] - Date when the file was created
* @property {Date} [updatedAt] - Date when the file was updated * @property {Date} [updatedAt] - Date when the file was updated
@ -91,6 +93,9 @@ const fileSchema = mongoose.Schema(
}, },
width: Number, width: Number,
height: Number, height: Number,
metadata: {
fileIdentifier: String,
},
expiresAt: { expiresAt: {
type: Date, type: Date,
expires: 3600, // 1 hour in seconds expires: 3600, // 1 hour in seconds

View file

@ -10,6 +10,13 @@ const bedrockValues = {
'llama3-1-8b': { prompt: 0.3, completion: 0.6 }, 'llama3-1-8b': { prompt: 0.3, completion: 0.6 },
'llama3-1-70b': { prompt: 2.65, completion: 3.5 }, 'llama3-1-70b': { prompt: 2.65, completion: 3.5 },
'llama3-1-405b': { prompt: 5.32, completion: 16.0 }, 'llama3-1-405b': { prompt: 5.32, completion: 16.0 },
'llama2:13b': { prompt: 0.75, completion: 1.0 },
'llama2:70b': { prompt: 1.95, completion: 2.56 },
'llama3:8b': { prompt: 0.3, completion: 0.6 },
'llama3:70b': { prompt: 2.65, completion: 3.5 },
'llama3.1:8b': { prompt: 0.3, completion: 0.6 },
'llama3.1:70b': { prompt: 2.65, completion: 3.5 },
'llama3.1:405b': { prompt: 5.32, completion: 16.0 },
'mistral-7b': { prompt: 0.15, completion: 0.2 }, 'mistral-7b': { prompt: 0.15, completion: 0.2 },
'mistral-small': { prompt: 0.15, completion: 0.2 }, 'mistral-small': { prompt: 0.15, completion: 0.2 },
'mixtral-8x7b': { prompt: 0.45, completion: 0.7 }, 'mixtral-8x7b': { prompt: 0.45, completion: 0.7 },

View file

@ -43,7 +43,7 @@
"@langchain/core": "^0.2.18", "@langchain/core": "^0.2.18",
"@langchain/google-genai": "^0.0.11", "@langchain/google-genai": "^0.0.11",
"@langchain/google-vertexai": "^0.0.17", "@langchain/google-vertexai": "^0.0.17",
"@librechat/agents": "^1.6.9", "@librechat/agents": "^1.7.7",
"axios": "^1.7.7", "axios": "^1.7.7",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",

View file

@ -1,5 +1,5 @@
const { promises: fs } = require('fs'); const { promises: fs } = require('fs');
const { CacheKeys } = require('librechat-data-provider'); const { CacheKeys, AuthType } = require('librechat-data-provider');
const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs'); const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs');
const { getLogStores } = require('~/cache'); const { getLogStores } = require('~/cache');
@ -25,7 +25,7 @@ const filterUniquePlugins = (plugins) => {
* @param {TPlugin} plugin The plugin object containing the authentication configuration. * @param {TPlugin} plugin The plugin object containing the authentication configuration.
* @returns {boolean} True if the plugin is authenticated for all required fields, false otherwise. * @returns {boolean} True if the plugin is authenticated for all required fields, false otherwise.
*/ */
const isPluginAuthenticated = (plugin) => { const checkPluginAuth = (plugin) => {
if (!plugin.authConfig || plugin.authConfig.length === 0) { if (!plugin.authConfig || plugin.authConfig.length === 0) {
return false; return false;
} }
@ -36,7 +36,7 @@ const isPluginAuthenticated = (plugin) => {
for (const fieldOption of authFieldOptions) { for (const fieldOption of authFieldOptions) {
const envValue = process.env[fieldOption]; const envValue = process.env[fieldOption];
if (envValue && envValue.trim() !== '' && envValue !== 'user_provided') { if (envValue && envValue.trim() !== '' && envValue !== AuthType.USER_PROVIDED) {
isFieldAuthenticated = true; isFieldAuthenticated = true;
break; break;
} }
@ -64,7 +64,7 @@ const getAvailablePluginsController = async (req, res) => {
let authenticatedPlugins = []; let authenticatedPlugins = [];
for (const plugin of uniquePlugins) { for (const plugin of uniquePlugins) {
authenticatedPlugins.push( authenticatedPlugins.push(
isPluginAuthenticated(plugin) ? { ...plugin, authenticated: true } : plugin, checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin,
); );
} }
@ -111,7 +111,7 @@ const getAvailableTools = async (req, res) => {
const uniquePlugins = filterUniquePlugins(jsonData); const uniquePlugins = filterUniquePlugins(jsonData);
const authenticatedPlugins = uniquePlugins.map((plugin) => { const authenticatedPlugins = uniquePlugins.map((plugin) => {
if (isPluginAuthenticated(plugin)) { if (checkPluginAuth(plugin)) {
return { ...plugin, authenticated: true }; return { ...plugin, authenticated: true };
} else { } else {
return plugin; return plugin;

View file

@ -61,10 +61,10 @@ const deleteUserFiles = async (req) => {
const updateUserPluginsController = async (req, res) => { const updateUserPluginsController = async (req, res) => {
const { user } = req; const { user } = req;
const { pluginKey, action, auth, isAssistantTool } = req.body; const { pluginKey, action, auth, isEntityTool } = req.body;
let authService; let authService;
try { try {
if (!isAssistantTool) { if (!isEntityTool) {
const userPluginsService = await updateUserPluginsService(user, pluginKey, action); const userPluginsService = await updateUserPluginsService(user, pluginKey, action);
if (userPluginsService instanceof Error) { if (userPluginsService instanceof Error) {

View file

@ -1,6 +1,12 @@
const { Tools } = require('librechat-data-provider'); const { Tools } = require('librechat-data-provider');
const { GraphEvents, ToolEndHandler, ChatModelStreamHandler } = require('@librechat/agents'); const {
EnvVar,
GraphEvents,
ToolEndHandler,
ChatModelStreamHandler,
} = require('@librechat/agents');
const { processCodeOutput } = require('~/server/services/Files/Code/process'); const { processCodeOutput } = require('~/server/services/Files/Code/process');
const { loadAuthValues } = require('~/app/clients/tools/util');
const { logger } = require('~/config'); const { logger } = require('~/config');
/** @typedef {import('@librechat/agents').Graph} Graph */ /** @typedef {import('@librechat/agents').Graph} Graph */
@ -158,13 +164,18 @@ function createToolEndCallback({ req, res, artifactPromises }) {
const { id, name } = file; const { id, name } = file;
artifactPromises.push( artifactPromises.push(
(async () => { (async () => {
const result = await loadAuthValues({
userId: req.user.id,
authFields: [EnvVar.CODE_API_KEY],
});
const fileMetadata = await processCodeOutput({ const fileMetadata = await processCodeOutput({
req, req,
id, id,
name, name,
apiKey: result[EnvVar.CODE_API_KEY],
toolCallId: tool_call_id, toolCallId: tool_call_id,
messageId: metadata.run_id, messageId: metadata.run_id,
sessionId: artifact.session_id, session_id: artifact.session_id,
conversationId: metadata.thread_id, conversationId: metadata.thread_id,
}); });
if (!res.headersSent) { if (!res.headersSent) {

View file

@ -15,7 +15,6 @@ const {
EModelEndpoint, EModelEndpoint,
anthropicSchema, anthropicSchema,
bedrockOutputParser, bedrockOutputParser,
providerEndpointMap,
removeNullishValues, removeNullishValues,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { const {
@ -465,7 +464,6 @@ class AgentClient extends BaseClient {
const config = { const config = {
configurable: { configurable: {
provider: providerEndpointMap[this.options.agent.provider],
thread_id: this.conversationId, thread_id: this.conversationId,
}, },
signal: abortController.signal, signal: abortController.signal,

View file

@ -35,9 +35,10 @@ async function createRun({
streaming = true, streaming = true,
streamUsage = true, streamUsage = true,
}) { }) {
const provider = providerEndpointMap[agent.provider] ?? agent.provider;
const llmConfig = Object.assign( const llmConfig = Object.assign(
{ {
provider: providerEndpointMap[agent.provider], provider,
streaming, streaming,
streamUsage, streamUsage,
}, },

View file

@ -0,0 +1,53 @@
const { EnvVar } = require('@librechat/agents');
const { Tools, AuthType } = require('librechat-data-provider');
const { loadAuthValues } = require('~/app/clients/tools/util');
const fieldsMap = {
[Tools.execute_code]: [EnvVar.CODE_API_KEY],
};
/**
* @param {ServerRequest} req - The request object, containing information about the HTTP request.
* @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
* @returns {Promise<void>} A promise that resolves when the function has completed.
*/
const verifyToolAuth = async (req, res) => {
try {
const { toolId } = req.params;
const authFields = fieldsMap[toolId];
if (!authFields) {
res.status(404).json({ message: 'Tool not found' });
return;
}
let result;
try {
result = await loadAuthValues({
userId: req.user.id,
authFields,
});
} catch (error) {
res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED });
return;
}
let isUserProvided = false;
for (const field of authFields) {
if (!result[field]) {
res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED });
return;
}
if (!isUserProvided && process.env[field] !== result[field]) {
isUserProvided = true;
}
}
res.status(200).json({
authenticated: true,
message: isUserProvided ? AuthType.USER_PROVIDED : AuthType.SYSTEM_DEFINED,
});
} catch (error) {
res.status(500).json({ message: error.message });
}
};
module.exports = {
verifyToolAuth,
};

View file

@ -0,0 +1,22 @@
const express = require('express');
const { getAvailableTools } = require('~/server/controllers/PluginController');
const { verifyToolAuth } = require('~/server/controllers/tools');
const router = express.Router();
/**
* Get a list of available tools for agents.
* @route GET /agents/tools
* @returns {TPlugin[]} 200 - application/json
*/
router.get('/', getAvailableTools);
/**
* Verify authentication for a specific tool
* @route GET /agents/tools/:toolId/auth
* @param {string} toolId - The ID of the tool to verify
* @returns {{ authenticated?: boolean; message?: string }}
*/
router.get('/:toolId/auth', verifyToolAuth);
module.exports = router;

View file

@ -2,9 +2,9 @@ const multer = require('multer');
const express = require('express'); const express = require('express');
const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { getAvailableTools } = require('~/server/controllers/PluginController');
const v1 = require('~/server/controllers/agents/v1'); const v1 = require('~/server/controllers/agents/v1');
const actions = require('./actions'); const actions = require('./actions');
const tools = require('./tools');
const upload = multer(); const upload = multer();
const router = express.Router(); const router = express.Router();
@ -35,9 +35,8 @@ router.use('/actions', actions);
/** /**
* Get a list of available tools for agents. * Get a list of available tools for agents.
* @route GET /agents/tools * @route GET /agents/tools
* @returns {TPlugin[]} 200 - application/json
*/ */
router.use('/tools', getAvailableTools); router.use('/tools', tools);
/** /**
* Creates an agent. * Creates an agent.

View file

@ -17,6 +17,7 @@ const {
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { loadAuthValues } = require('~/app/clients/tools/util'); const { loadAuthValues } = require('~/app/clients/tools/util');
const { getAgent } = require('~/models/Agent');
const { getFiles } = require('~/models/File'); const { getFiles } = require('~/models/File');
const { logger } = require('~/config'); const { logger } = require('~/config');
@ -67,12 +68,31 @@ router.delete('/', async (req, res) => {
} }
const fileIds = files.map((file) => file.file_id); const fileIds = files.map((file) => file.file_id);
const userFiles = await getFiles({ file_id: { $in: fileIds }, user: req.user.id }); const dbFiles = await getFiles({ file_id: { $in: fileIds } });
if (userFiles.length !== files.length) { const unauthorizedFiles = dbFiles.filter((file) => file.user.toString() !== req.user.id);
return res.status(403).json({ message: 'You can only delete your own files' });
if (unauthorizedFiles.length > 0) {
return res.status(403).json({
message: 'You can only delete your own files',
unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id),
});
} }
await processDeleteRequest({ req, files: userFiles }); /* Handle entity unlinking even if no valid files to delete */
if (req.body.agent_id && req.body.tool_resource && dbFiles.length === 0) {
const agent = await getAgent({
id: req.body.agent_id,
});
const toolResourceFiles = agent.tool_resources?.[req.body.tool_resource]?.file_ids ?? [];
const agentFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
await processDeleteRequest({ req, files: agentFiles });
res.status(200).json({ message: 'File associations removed successfully' });
return;
}
await processDeleteRequest({ req, files: dbFiles });
logger.debug( logger.debug(
`[/files] Files deleted successfully: ${files `[/files] Files deleted successfully: ${files
@ -87,13 +107,13 @@ router.delete('/', async (req, res) => {
} }
}); });
router.get('/code/download/:sessionId/:fileId', async (req, res) => { router.get('/code/download/:session_id/:fileId', async (req, res) => {
try { try {
const { sessionId, fileId } = req.params; const { session_id, fileId } = req.params;
const logPrefix = `Session ID: ${sessionId} | File ID: ${fileId} | Code output download requested by user `; const logPrefix = `Session ID: ${session_id} | File ID: ${fileId} | Code output download requested by user `;
logger.debug(logPrefix); logger.debug(logPrefix);
if (!sessionId || !fileId) { if (!session_id || !fileId) {
return res.status(400).send('Bad request'); return res.status(400).send('Bad request');
} }
@ -108,7 +128,10 @@ router.get('/code/download/:sessionId/:fileId', async (req, res) => {
const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] }); const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] });
/** @type {AxiosResponse<ReadableStream> | undefined} */ /** @type {AxiosResponse<ReadableStream> | undefined} */
const response = await getDownloadStream(`${sessionId}/${fileId}`, result[EnvVar.CODE_API_KEY]); const response = await getDownloadStream(
`${session_id}/${fileId}`,
result[EnvVar.CODE_API_KEY],
);
res.set(response.headers); res.set(response.headers);
response.data.pipe(res); response.data.pipe(res);
} catch (error) { } catch (error) {

View file

@ -11,7 +11,7 @@
const { z } = require('zod'); const { z } = require('zod');
const { tool } = require('@langchain/core/tools'); const { tool } = require('@langchain/core/tools');
const { createContentAggregator } = require('@librechat/agents'); const { createContentAggregator, Providers } = require('@librechat/agents');
const { const {
EModelEndpoint, EModelEndpoint,
getResponseSender, getResponseSender,
@ -22,8 +22,9 @@ const {
createToolEndCallback, createToolEndCallback,
} = require('~/server/controllers/agents/callbacks'); } = require('~/server/controllers/agents/callbacks');
const initAnthropic = require('~/server/services/Endpoints/anthropic/initialize'); const initAnthropic = require('~/server/services/Endpoints/anthropic/initialize');
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
const getBedrockOptions = require('~/server/services/Endpoints/bedrock/options'); const getBedrockOptions = require('~/server/services/Endpoints/bedrock/options');
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
const initCustom = require('~/server/services/Endpoints/custom/initialize');
const { loadAgentTools } = require('~/server/services/ToolService'); const { loadAgentTools } = require('~/server/services/ToolService');
const AgentClient = require('~/server/controllers/agents/client'); const AgentClient = require('~/server/controllers/agents/client');
const { getModelMaxTokens } = require('~/utils'); const { getModelMaxTokens } = require('~/utils');
@ -53,6 +54,7 @@ const providerConfigMap = {
[EModelEndpoint.azureOpenAI]: initOpenAI, [EModelEndpoint.azureOpenAI]: initOpenAI,
[EModelEndpoint.anthropic]: initAnthropic, [EModelEndpoint.anthropic]: initAnthropic,
[EModelEndpoint.bedrock]: getBedrockOptions, [EModelEndpoint.bedrock]: getBedrockOptions,
[Providers.OLLAMA]: initCustom,
}; };
const initializeClient = async ({ req, res, endpointOption }) => { const initializeClient = async ({ req, res, endpointOption }) => {
@ -92,7 +94,7 @@ const initializeClient = async ({ req, res, endpointOption }) => {
}); });
let modelOptions = { model: agent.model }; let modelOptions = { model: agent.model };
const getOptions = providerConfigMap[agent.provider]; let getOptions = providerConfigMap[agent.provider];
if (!getOptions) { if (!getOptions) {
throw new Error(`Provider ${agent.provider} not supported`); throw new Error(`Provider ${agent.provider} not supported`);
} }

View file

@ -12,11 +12,14 @@ const { fetchModels } = require('~/server/services/ModelService');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const { isUserProvided } = require('~/server/utils'); const { isUserProvided } = require('~/server/utils');
const { OpenAIClient } = require('~/app'); const { OpenAIClient } = require('~/app');
const { Providers } = require('@librechat/agents');
const { PROXY } = process.env; const { PROXY } = process.env;
const initializeClient = async ({ req, res, endpointOption }) => { const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrideEndpoint }) => {
const { key: expiresAt, endpoint } = req.body; const { key: expiresAt } = req.body;
const endpoint = overrideEndpoint ?? req.body.endpoint;
const customConfig = await getCustomConfig(); const customConfig = await getCustomConfig();
if (!customConfig) { if (!customConfig) {
throw new Error(`Config not found for the ${endpoint} custom endpoint.`); throw new Error(`Config not found for the ${endpoint} custom endpoint.`);
@ -133,6 +136,17 @@ const initializeClient = async ({ req, res, endpointOption }) => {
...endpointOption, ...endpointOption,
}; };
if (optionsOnly) {
const modelOptions = endpointOption.model_parameters;
if (endpoint === Providers.OLLAMA && clientOptions.reverseProxyUrl) {
modelOptions.baseUrl = clientOptions.reverseProxyUrl.split('/v1')[0];
delete clientOptions.reverseProxyUrl;
}
return {
llmConfig: modelOptions,
};
}
const client = new OpenAIClient(apiKey, clientOptions); const client = new OpenAIClient(apiKey, clientOptions);
return { return {
client, client,

View file

@ -1,19 +1,20 @@
// downloadStream.js // Code Files
const axios = require('axios'); const axios = require('axios');
const FormData = require('form-data');
const { getCodeBaseURL } = require('@librechat/agents'); const { getCodeBaseURL } = require('@librechat/agents');
const baseURL = getCodeBaseURL(); const MAX_FILE_SIZE = 25 * 1024 * 1024;
/** /**
* Retrieves a download stream for a specified file. * Retrieves a download stream for a specified file.
* @param {string} fileIdentifier - The identifier for the file (e.g., "sessionId/fileId"). * @param {string} fileIdentifier - The identifier for the file (e.g., "session_id/fileId").
* @param {string} apiKey - The API key for authentication. * @param {string} apiKey - The API key for authentication.
* @returns {Promise<AxiosResponse>} A promise that resolves to a readable stream of the file content. * @returns {Promise<AxiosResponse>} A promise that resolves to a readable stream of the file content.
* @throws {Error} If there's an error during the download process. * @throws {Error} If there's an error during the download process.
*/ */
async function getCodeOutputDownloadStream(fileIdentifier, apiKey) { async function getCodeOutputDownloadStream(fileIdentifier, apiKey) {
try { try {
const baseURL = getCodeBaseURL();
const response = await axios({ const response = await axios({
method: 'get', method: 'get',
url: `${baseURL}/download/${fileIdentifier}`, url: `${baseURL}/download/${fileIdentifier}`,
@ -31,4 +32,45 @@ async function getCodeOutputDownloadStream(fileIdentifier, apiKey) {
} }
} }
module.exports = { getCodeOutputDownloadStream }; /**
* Uploads a file to the Code Environment server.
* @param {Object} params - The params object.
* @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id`
* representing the user, and an `app.locals.paths` object with an `uploads` path.
* @param {import('fs').ReadStream | import('stream').Readable} params.stream - The read stream for the file.
* @param {string} params.filename - The name of the file.
* @param {string} params.apiKey - The API key for authentication.
* @returns {Promise<string>}
* @throws {Error} If there's an error during the upload process.
*/
async function uploadCodeEnvFile({ req, stream, filename, apiKey }) {
try {
const form = new FormData();
form.append('file', stream, filename);
const baseURL = getCodeBaseURL();
const response = await axios.post(`${baseURL}/upload`, form, {
headers: {
...form.getHeaders(),
'Content-Type': 'multipart/form-data',
'User-Agent': 'LibreChat/1.0',
'User-Id': req.user.id,
'X-API-Key': apiKey,
},
maxContentLength: MAX_FILE_SIZE,
maxBodyLength: MAX_FILE_SIZE,
});
/** @type {{ message: string; session_id: string; files: Array<{ fileId: string; filename: string }> }} */
const result = response.data;
if (result.message !== 'success') {
throw new Error(`Error uploading file: ${result.message}`);
}
return `${result.session_id}/${result.files[0].fileId}`;
} catch (error) {
throw new Error(`Error uploading file: ${error.message}`);
}
}
module.exports = { getCodeOutputDownloadStream, uploadCodeEnvFile };

View file

@ -1,11 +1,16 @@
const path = require('path'); const path = require('path');
const { v4 } = require('uuid'); const { v4 } = require('uuid');
const axios = require('axios'); const axios = require('axios');
const { getCodeBaseURL, EnvVar } = require('@librechat/agents'); const { getCodeBaseURL } = require('@librechat/agents');
const { FileContext, imageExtRegex } = require('librechat-data-provider'); const {
EToolResources,
FileContext,
imageExtRegex,
FileSources,
} = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { convertImage } = require('~/server/services/Files/images/convert'); const { convertImage } = require('~/server/services/Files/images/convert');
const { loadAuthValues } = require('~/app/clients/tools/util'); const { createFile, getFiles, updateFile } = require('~/models/File');
const { createFile } = require('~/models/File');
const { logger } = require('~/config'); const { logger } = require('~/config');
/** /**
@ -13,8 +18,9 @@ const { logger } = require('~/config');
* @param {ServerRequest} params.req - The Express request object. * @param {ServerRequest} params.req - The Express request object.
* @param {string} params.id - The file ID. * @param {string} params.id - The file ID.
* @param {string} params.name - The filename. * @param {string} params.name - The filename.
* @param {string} params.apiKey - The code execution API key.
* @param {string} params.toolCallId - The tool call ID that generated the file. * @param {string} params.toolCallId - The tool call ID that generated the file.
* @param {string} params.sessionId - The code execution session ID. * @param {string} params.session_id - The code execution session ID.
* @param {string} params.conversationId - The current conversation ID. * @param {string} params.conversationId - The current conversation ID.
* @param {string} params.messageId - The current message ID. * @param {string} params.messageId - The current message ID.
* @returns {Promise<MongoFile & { messageId: string, toolCallId: string } | { filename: string; filepath: string; expiresAt: number; conversationId: string; toolCallId: string; messageId: string } | undefined>} The file metadata or undefined if an error occurs. * @returns {Promise<MongoFile & { messageId: string, toolCallId: string } | { filename: string; filepath: string; expiresAt: number; conversationId: string; toolCallId: string; messageId: string } | undefined>} The file metadata or undefined if an error occurs.
@ -23,10 +29,11 @@ const processCodeOutput = async ({
req, req,
id, id,
name, name,
apiKey,
toolCallId, toolCallId,
conversationId, conversationId,
messageId, messageId,
sessionId, session_id,
}) => { }) => {
const currentDate = new Date(); const currentDate = new Date();
const baseURL = getCodeBaseURL(); const baseURL = getCodeBaseURL();
@ -34,7 +41,7 @@ const processCodeOutput = async ({
if (!fileExt || !imageExtRegex.test(name)) { if (!fileExt || !imageExtRegex.test(name)) {
return { return {
filename: name, filename: name,
filepath: `/api/files/code/download/${sessionId}/${id}`, filepath: `/api/files/code/download/${session_id}/${id}`,
/** Note: expires 24 hours after creation */ /** Note: expires 24 hours after creation */
expiresAt: currentDate.getTime() + 86400000, expiresAt: currentDate.getTime() + 86400000,
conversationId, conversationId,
@ -45,14 +52,13 @@ const processCodeOutput = async ({
try { try {
const formattedDate = currentDate.toISOString(); const formattedDate = currentDate.toISOString();
const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] });
const response = await axios({ const response = await axios({
method: 'get', method: 'get',
url: `${baseURL}/download/${sessionId}/${id}`, url: `${baseURL}/download/${session_id}/${id}`,
responseType: 'arraybuffer', responseType: 'arraybuffer',
headers: { headers: {
'User-Agent': 'LibreChat/1.0', 'User-Agent': 'LibreChat/1.0',
'X-API-Key': result[EnvVar.CODE_API_KEY], 'X-API-Key': apiKey,
}, },
timeout: 15000, timeout: 15000,
}); });
@ -82,6 +88,120 @@ const processCodeOutput = async ({
} }
}; };
function checkIfActive(dateString) {
const givenDate = new Date(dateString);
const currentDate = new Date();
const timeDifference = currentDate - givenDate;
const hoursPassed = timeDifference / (1000 * 60 * 60);
return hoursPassed < 23;
}
/**
* Retrieves the `lastModified` time string for a specified file from Code Execution Server.
*
* @param {Object} params - The parameters object.
* @param {string} params.fileIdentifier - The identifier for the file (e.g., "session_id/fileId").
* @param {string} params.apiKey - The API key for authentication.
*
* @returns {Promise<string|null>}
* A promise that resolves to the `lastModified` time string of the file if successful, or null if there is an
* error in initialization or fetching the info.
*/
async function getSessionInfo(fileIdentifier, apiKey) {
try {
const baseURL = getCodeBaseURL();
const session_id = fileIdentifier.split('/')[0];
const response = await axios({
method: 'get',
url: `${baseURL}/files/${session_id}`,
params: {
detail: 'summary',
},
headers: {
'User-Agent': 'LibreChat/1.0',
'X-API-Key': apiKey,
},
timeout: 5000,
});
return response.data.find((file) => file.name.startsWith(fileIdentifier))?.lastModified;
} catch (error) {
logger.error(`Error fetching session info: ${error.message}`, error);
return null;
}
}
/**
*
* @param {Object} options
* @param {ServerRequest} options.req
* @param {Agent['tool_resources']} options.tool_resources
* @param {string} apiKey
* @returns {Promise<Array<{ id: string; session_id: string; name: string }>>}
*/
const primeFiles = async (options, apiKey) => {
const { tool_resources } = options;
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
const dbFiles = await getFiles({ file_id: { $in: file_ids } });
const files = [];
const sessions = new Map();
for (const file of dbFiles) {
if (file.metadata.fileIdentifier) {
const [session_id, id] = file.metadata.fileIdentifier.split('/');
const pushFile = () => {
files.push({
id,
session_id,
name: file.filename,
});
};
if (sessions.has(session_id)) {
pushFile();
continue;
}
const reuploadFile = async () => {
try {
const { getDownloadStream } = getStrategyFunctions(file.source);
const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(
FileSources.execute_code,
);
const stream = await getDownloadStream(file.filepath);
const fileIdentifier = await uploadCodeEnvFile({
req: options.req,
stream,
filename: file.filename,
apiKey,
});
await updateFile({ file_id: file.file_id, metadata: { fileIdentifier } });
sessions.set(session_id, true);
pushFile();
} catch (error) {
logger.error(
`Error re-uploading file ${id} in session ${session_id}: ${error.message}`,
error,
);
}
};
const uploadTime = await getSessionInfo(file.metadata.fileIdentifier, apiKey);
if (!uploadTime) {
logger.warn(`Failed to get upload time for file ${id} in session ${session_id}`);
await reuploadFile();
continue;
}
if (!checkIfActive(uploadTime)) {
await reuploadFile();
continue;
}
sessions.set(session_id, true);
pushFile();
}
}
return files;
};
module.exports = { module.exports = {
primeFiles,
processCodeOutput, processCodeOutput,
}; };

View file

@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const axios = require('axios'); const axios = require('axios');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const { ref, uploadBytes, getDownloadURL, getStream, deleteObject } = require('firebase/storage'); const { ref, uploadBytes, getDownloadURL, deleteObject } = require('firebase/storage');
const { getBufferMetadata } = require('~/server/utils'); const { getBufferMetadata } = require('~/server/utils');
const { getFirebaseStorage } = require('./initialize'); const { getFirebaseStorage } = require('./initialize');
const { logger } = require('~/config'); const { logger } = require('~/config');
@ -155,7 +155,7 @@ function extractFirebaseFilePath(urlString) {
* Deletes a file from Firebase storage. This function determines the filepath from the * Deletes a file from Firebase storage. This function determines the filepath from the
* Firebase storage URL via regex for deletion. Validated by the user's ID. * Firebase storage URL via regex for deletion. Validated by the user's ID.
* *
* @param {Express.Request} req - The request object from Express. * @param {ServerRequest} req - The request object from Express.
* It should contain a `user` object with an `id` property. * It should contain a `user` object with an `id` property.
* @param {MongoFile} file - The file object to be deleted. * @param {MongoFile} file - The file object to be deleted.
* *
@ -195,7 +195,7 @@ const deleteFirebaseFile = async (req, file) => {
* Uploads a file to Firebase Storage. * Uploads a file to Firebase Storage.
* *
* @param {Object} params - The params object. * @param {Object} params - The params object.
* @param {Express.Request} params.req - The request object from Express. It should have a `user` property with an `id` * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id`
* representing the user. * representing the user.
* @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should * @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should
* have a `path` property that points to the location of the uploaded file. * have a `path` property that points to the location of the uploaded file.
@ -225,16 +225,22 @@ async function uploadFileToFirebase({ req, file, file_id }) {
* Retrieves a readable stream for a file from Firebase storage. * Retrieves a readable stream for a file from Firebase storage.
* *
* @param {string} filepath - The filepath. * @param {string} filepath - The filepath.
* @returns {ReadableStream} A readable stream of the file. * @returns {Promise<ReadableStream>} A readable stream of the file.
*/ */
function getFirebaseFileStream(filepath) { async function getFirebaseFileStream(filepath) {
try { try {
const storage = getFirebaseStorage(); const storage = getFirebaseStorage();
if (!storage) { if (!storage) {
throw new Error('Firebase is not initialized'); throw new Error('Firebase is not initialized');
} }
const fileRef = ref(storage, filepath);
return getStream(fileRef); const response = await axios({
method: 'get',
url: filepath,
responseType: 'stream',
});
return response.data;
} catch (error) { } catch (error) {
logger.error('Error getting Firebase file stream:', error); logger.error('Error getting Firebase file stream:', error);
throw error; throw error;

View file

@ -1,6 +1,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const axios = require('axios'); const axios = require('axios');
const { EModelEndpoint } = require('librechat-data-provider');
const { getBufferMetadata } = require('~/server/utils'); const { getBufferMetadata } = require('~/server/utils');
const paths = require('~/config/paths'); const paths = require('~/config/paths');
const { logger } = require('~/config'); const { logger } = require('~/config');
@ -222,6 +223,10 @@ const deleteLocalFile = async (req, file) => {
const parts = file.filepath.split(path.sep); const parts = file.filepath.split(path.sep);
const subfolder = parts[1]; const subfolder = parts[1];
if (!subfolder && parts[0] === EModelEndpoint.agents) {
logger.warn(`Agent File ${file.file_id} is missing filepath, may have been deleted already`);
return;
}
const filepath = path.join(publicPath, file.filepath); const filepath = path.join(publicPath, file.filepath);
if (!isValidPath(req, publicPath, subfolder, filepath)) { if (!isValidPath(req, publicPath, subfolder, filepath)) {
@ -235,7 +240,7 @@ const deleteLocalFile = async (req, file) => {
* Uploads a file to the specified upload directory. * Uploads a file to the specified upload directory.
* *
* @param {Object} params - The params object. * @param {Object} params - The params object.
* @param {Object} params.req - The request object from Express. It should have a `user` property with an `id` * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id`
* representing the user, and an `app.locals.paths` object with an `uploads` path. * representing the user, and an `app.locals.paths` object with an `uploads` path.
* @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should * @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should
* have a `path` property that points to the location of the uploaded file. * have a `path` property that points to the location of the uploaded file.

View file

@ -7,7 +7,7 @@ const { logger } = require('~/config');
* Uploads a file that can be used across various OpenAI services. * Uploads a file that can be used across various OpenAI services.
* *
* @param {Object} params - The params object. * @param {Object} params - The params object.
* @param {Express.Request} params.req - The request object from Express. It should have a `user` property with an `id` * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id`
* representing the user, and an `app.locals.paths` object with an `imageOutput` path. * representing the user, and an `app.locals.paths` object with an `imageOutput` path.
* @param {Express.Multer.File} params.file - The file uploaded to the server via multer. * @param {Express.Multer.File} params.file - The file uploaded to the server via multer.
* @param {OpenAIClient} params.openai - The initialized OpenAI client. * @param {OpenAIClient} params.openai - The initialized OpenAI client.
@ -42,7 +42,7 @@ async function uploadOpenAIFile({ req, file, openai }) {
/** /**
* Deletes a file previously uploaded to OpenAI. * Deletes a file previously uploaded to OpenAI.
* *
* @param {Express.Request} req - The request object from Express. * @param {ServerRequest} req - The request object from Express.
* @param {MongoFile} file - The database representation of the uploaded file. * @param {MongoFile} file - The database representation of the uploaded file.
* @param {OpenAI} openai - The initialized OpenAI client. * @param {OpenAI} openai - The initialized OpenAI client.
* @returns {Promise<void>} * @returns {Promise<void>}

View file

@ -8,7 +8,7 @@ const { logger } = require('~/config');
* Deletes a file from the vector database. This function takes a file object, constructs the full path, and * Deletes a file from the vector database. This function takes a file object, constructs the full path, and
* verifies the path's validity before deleting the file. If the path is invalid, an error is thrown. * verifies the path's validity before deleting the file. If the path is invalid, an error is thrown.
* *
* @param {Express.Request} req - The request object from Express. It should have an `app.locals.paths` object with * @param {ServerRequest} req - The request object from Express. It should have an `app.locals.paths` object with
* a `publicPath` property. * a `publicPath` property.
* @param {MongoFile} file - The file object to be deleted. It should have a `filepath` property that is * @param {MongoFile} file - The file object to be deleted. It should have a `filepath` property that is
* a string representing the path of the file relative to the publicPath. * a string representing the path of the file relative to the publicPath.

View file

@ -1,3 +1,4 @@
const fs = require('fs');
const path = require('path'); const path = require('path');
const mime = require('mime'); const mime = require('mime');
const { v4 } = require('uuid'); const { v4 } = require('uuid');
@ -12,14 +13,17 @@ const {
mergeFileConfig, mergeFileConfig,
hostImageIdSuffix, hostImageIdSuffix,
checkOpenAIStorage, checkOpenAIStorage,
removeNullishValues,
hostImageNamePrefix, hostImageNamePrefix,
isAssistantsEndpoint, isAssistantsEndpoint,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { EnvVar } = require('@librechat/agents');
const { addResourceFileId, deleteResourceFileId } = require('~/server/controllers/assistants/v2'); const { addResourceFileId, deleteResourceFileId } = require('~/server/controllers/assistants/v2');
const { convertImage, resizeAndConvert } = require('~/server/services/Files/images'); const { convertImage, resizeAndConvert } = require('~/server/services/Files/images');
const { addAgentResourceFile, removeAgentResourceFile } = require('~/models/Agent'); const { addAgentResourceFile, removeAgentResourceFile } = require('~/models/Agent');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { createFile, updateFileUsage, deleteFiles } = require('~/models/File'); const { createFile, updateFileUsage, deleteFiles } = require('~/models/File');
const { loadAuthValues } = require('~/app/clients/tools/util');
const { LB_QueueAsyncCall } = require('~/server/utils/queue'); const { LB_QueueAsyncCall } = require('~/server/utils/queue');
const { getStrategyFunctions } = require('./strategies'); const { getStrategyFunctions } = require('./strategies');
const { determineFileType } = require('~/server/utils'); const { determineFileType } = require('~/server/utils');
@ -89,6 +93,7 @@ function enqueueDeleteOperation({ req, file, deleteFile, promises, resolvedFileI
* @param {MongoFile[]} params.files - The file objects to delete. * @param {MongoFile[]} params.files - The file objects to delete.
* @param {Express.Request} params.req - The express request object. * @param {Express.Request} params.req - The express request object.
* @param {DeleteFilesBody} params.req.body - The request body. * @param {DeleteFilesBody} params.req.body - The request body.
* @param {string} [params.req.body.agent_id] - The agent ID if file uploaded is associated to an agent.
* @param {string} [params.req.body.assistant_id] - The assistant ID if file uploaded is associated to an assistant. * @param {string} [params.req.body.assistant_id] - The assistant ID if file uploaded is associated to an assistant.
* @param {string} [params.req.body.tool_resource] - The tool resource if assistant file uploaded is associated to a tool resource. * @param {string} [params.req.body.tool_resource] - The tool resource if assistant file uploaded is associated to a tool resource.
* *
@ -438,10 +443,25 @@ const processAgentFileUpload = async ({ req, res, file, metadata }) => {
throw new Error('No agent ID provided for agent file upload'); throw new Error('No agent ID provided for agent file upload');
} }
let fileInfoMetadata;
if (tool_resource === EToolResources.execute_code) {
const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(FileSources.execute_code);
const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] });
const stream = fs.createReadStream(file.path);
const fileIdentifier = await uploadCodeEnvFile({
req,
stream,
filename: file.originalname,
apiKey: result[EnvVar.CODE_API_KEY],
});
fileInfoMetadata = { fileIdentifier };
}
const source = const source =
tool_resource === EToolResources.file_search tool_resource === EToolResources.file_search
? FileSources.vectordb ? FileSources.vectordb
: req.app.locals.fileStrategy; : req.app.locals.fileStrategy;
const { handleFileUpload } = getStrategyFunctions(source); const { handleFileUpload } = getStrategyFunctions(source);
const { file_id, temp_file_id } = metadata; const { file_id, temp_file_id } = metadata;
@ -463,9 +483,9 @@ const processAgentFileUpload = async ({ req, res, file, metadata }) => {
if (!messageAttachment && tool_resource) { if (!messageAttachment && tool_resource) {
await addAgentResourceFile({ await addAgentResourceFile({
req, req,
agent_id,
file_id, file_id,
tool_resource: tool_resource, agent_id,
tool_resource,
}); });
} }
@ -479,24 +499,24 @@ const processAgentFileUpload = async ({ req, res, file, metadata }) => {
filepath = result.filepath; filepath = result.filepath;
} }
const result = await createFile( const fileInfo = removeNullishValues({
{ user: req.user.id,
user: req.user.id, file_id,
file_id, temp_file_id,
temp_file_id, bytes,
bytes, filepath,
filepath, filename: filename ?? file.originalname,
filename: filename ?? file.originalname, context: messageAttachment ? FileContext.message_attachment : FileContext.agents,
context: messageAttachment ? FileContext.message_attachment : FileContext.agents, model: messageAttachment ? undefined : req.body.model,
model: messageAttachment ? undefined : req.body.model, metadata: fileInfoMetadata,
type: file.mimetype, type: file.mimetype,
embedded, embedded,
source, source,
height, height,
width, width,
}, });
true,
); const result = await createFile(fileInfo, true);
res.status(200).json({ message: 'Agent file uploaded and processed successfully', ...result }); res.status(200).json({ message: 'Agent file uploaded and processed successfully', ...result });
}; };

View file

@ -5,11 +5,13 @@ const {
saveURLToFirebase, saveURLToFirebase,
deleteFirebaseFile, deleteFirebaseFile,
saveBufferToFirebase, saveBufferToFirebase,
uploadFileToFirebase,
uploadImageToFirebase, uploadImageToFirebase,
processFirebaseAvatar, processFirebaseAvatar,
getFirebaseFileStream, getFirebaseFileStream,
} = require('./Firebase'); } = require('./Firebase');
const { const {
uploadLocalFile,
getLocalFileURL, getLocalFileURL,
saveFileFromURL, saveFileFromURL,
saveLocalBuffer, saveLocalBuffer,
@ -20,17 +22,15 @@ const {
getLocalFileStream, getLocalFileStream,
} = require('./Local'); } = require('./Local');
const { uploadOpenAIFile, deleteOpenAIFile, getOpenAIFileStream } = require('./OpenAI'); const { uploadOpenAIFile, deleteOpenAIFile, getOpenAIFileStream } = require('./OpenAI');
const { getCodeOutputDownloadStream, uploadCodeEnvFile } = require('./Code');
const { uploadVectors, deleteVectors } = require('./VectorDB'); const { uploadVectors, deleteVectors } = require('./VectorDB');
const { getCodeOutputDownloadStream } = require('./Code');
/** /**
* Firebase Storage Strategy Functions * Firebase Storage Strategy Functions
* *
* */ * */
const firebaseStrategy = () => ({ const firebaseStrategy = () => ({
// saveFile: handleFileUpload: uploadFileToFirebase,
/** @type {typeof uploadVectors | null} */
handleFileUpload: null,
saveURL: saveURLToFirebase, saveURL: saveURLToFirebase,
getFileURL: getFirebaseURL, getFileURL: getFirebaseURL,
deleteFile: deleteFirebaseFile, deleteFile: deleteFirebaseFile,
@ -46,8 +46,7 @@ const firebaseStrategy = () => ({
* *
* */ * */
const localStrategy = () => ({ const localStrategy = () => ({
/** @type {typeof uploadVectors | null} */ handleFileUpload: uploadLocalFile,
handleFileUpload: null,
saveURL: saveFileFromURL, saveURL: saveFileFromURL,
getFileURL: getLocalFileURL, getFileURL: getLocalFileURL,
saveBuffer: saveLocalBuffer, saveBuffer: saveLocalBuffer,
@ -124,8 +123,7 @@ const codeOutputStrategy = () => ({
prepareImagePayload: null, prepareImagePayload: null,
/** @type {typeof deleteLocalFile | null} */ /** @type {typeof deleteLocalFile | null} */
deleteFile: null, deleteFile: null,
/** @type {typeof uploadVectors | null} */ handleFileUpload: uploadCodeEnvFile,
handleFileUpload: null,
getDownloadStream: getCodeOutputDownloadStream, getDownloadStream: getCodeOutputDownloadStream,
}); });

View file

@ -77,13 +77,27 @@ const anthropicModels = {
}; };
const metaModels = { const metaModels = {
'llama2-13b': 4000, llama3: 8000,
'llama2-70b': 4000, llama2: 4000,
'llama3-8b': 8000, 'llama3.1': 127500,
'llama3-70b': 8000, 'llama3-1': 127500,
'llama3-1-8b': 127500, 'llama3.1:405b': 127500,
'llama3-1-70b': 127500, 'llama3.1:70b': 127500,
'llama3.1:8b': 127500,
'llama3-1-405b': 127500, 'llama3-1-405b': 127500,
'llama3-1-70b': 127500,
'llama3-1-8b': 127500,
'llama3-70b': 8000,
'llama3-8b': 8000,
'llama2-70b': 4000,
'llama2-13b': 4000,
'llama3:70b': 8000,
'llama3:8b': 8000,
'llama2:70b': 4000,
};
const ollamaModels = {
'qwen2.5': 32000,
}; };
const ai21Models = { const ai21Models = {
@ -102,6 +116,7 @@ const bedrockModels = {
...anthropicModels, ...anthropicModels,
...mistralModels, ...mistralModels,
...cohereModels, ...cohereModels,
...ollamaModels,
...metaModels, ...metaModels,
...ai21Models, ...ai21Models,
...amazonModels, ...amazonModels,

View file

@ -1,5 +1,5 @@
const { EModelEndpoint } = require('librechat-data-provider'); const { EModelEndpoint } = require('librechat-data-provider');
const { getModelMaxTokens, matchModelName, maxTokensMap } = require('./tokens'); const { getModelMaxTokens, processModelData, matchModelName, maxTokensMap } = require('./tokens');
describe('getModelMaxTokens', () => { describe('getModelMaxTokens', () => {
test('should return correct tokens for exact match', () => { test('should return correct tokens for exact match', () => {
@ -317,3 +317,108 @@ describe('matchModelName', () => {
expect(matchModelName('chat-', EModelEndpoint.google)).toBe('chat-'); expect(matchModelName('chat-', EModelEndpoint.google)).toBe('chat-');
}); });
}); });
describe('Meta Models Tests', () => {
describe('getModelMaxTokens', () => {
test('should return correct tokens for LLaMa 2 models', () => {
expect(getModelMaxTokens('llama2')).toBe(4000);
expect(getModelMaxTokens('llama2.70b')).toBe(4000);
expect(getModelMaxTokens('llama2-13b')).toBe(4000);
expect(getModelMaxTokens('llama2-70b')).toBe(4000);
});
test('should return correct tokens for LLaMa 3 models', () => {
expect(getModelMaxTokens('llama3')).toBe(8000);
expect(getModelMaxTokens('llama3.8b')).toBe(8000);
expect(getModelMaxTokens('llama3.70b')).toBe(8000);
expect(getModelMaxTokens('llama3-8b')).toBe(8000);
expect(getModelMaxTokens('llama3-70b')).toBe(8000);
});
test('should return correct tokens for LLaMa 3.1 models', () => {
expect(getModelMaxTokens('llama3.1:8b')).toBe(127500);
expect(getModelMaxTokens('llama3.1:70b')).toBe(127500);
expect(getModelMaxTokens('llama3.1:405b')).toBe(127500);
expect(getModelMaxTokens('llama3-1-8b')).toBe(127500);
expect(getModelMaxTokens('llama3-1-70b')).toBe(127500);
expect(getModelMaxTokens('llama3-1-405b')).toBe(127500);
});
test('should handle partial matches for Meta models', () => {
// Test with full model names
expect(getModelMaxTokens('meta/llama3.1:405b')).toBe(127500);
expect(getModelMaxTokens('meta/llama3.1:70b')).toBe(127500);
expect(getModelMaxTokens('meta/llama3.1:8b')).toBe(127500);
expect(getModelMaxTokens('meta/llama3-1-8b')).toBe(127500);
// Test base versions
expect(getModelMaxTokens('meta/llama3.1')).toBe(127500);
expect(getModelMaxTokens('meta/llama3-1')).toBe(127500);
expect(getModelMaxTokens('meta/llama3')).toBe(8000);
expect(getModelMaxTokens('meta/llama2')).toBe(4000);
});
});
describe('matchModelName', () => {
test('should match exact LLaMa model names', () => {
expect(matchModelName('llama2')).toBe('llama2');
expect(matchModelName('llama3')).toBe('llama3');
expect(matchModelName('llama3.1:8b')).toBe('llama3.1:8b');
});
test('should match LLaMa model variations', () => {
// Test full model names
expect(matchModelName('meta/llama3.1:405b')).toBe('llama3.1:405b');
expect(matchModelName('meta/llama3.1:70b')).toBe('llama3.1:70b');
expect(matchModelName('meta/llama3.1:8b')).toBe('llama3.1:8b');
expect(matchModelName('meta/llama3-1-8b')).toBe('llama3-1-8b');
// Test base versions
expect(matchModelName('meta/llama3.1')).toBe('llama3.1');
expect(matchModelName('meta/llama3-1')).toBe('llama3-1');
});
test('should handle custom endpoint for Meta models', () => {
expect(matchModelName('llama2', EModelEndpoint.bedrock)).toBe('llama2');
expect(matchModelName('llama3', EModelEndpoint.bedrock)).toBe('llama3');
expect(matchModelName('llama3.1:8b', EModelEndpoint.bedrock)).toBe('llama3.1:8b');
});
});
describe('processModelData with Meta models', () => {
test('should process Meta model data correctly', () => {
const input = {
data: [
{
id: 'llama2',
pricing: {
prompt: '0.00001',
completion: '0.00003',
},
context_length: 4000,
},
{
id: 'llama3',
pricing: {
prompt: '0.00002',
completion: '0.00004',
},
context_length: 8000,
},
],
};
const result = processModelData(input);
expect(result.llama2).toEqual({
prompt: 10,
completion: 30,
context: 4000,
});
expect(result.llama3).toEqual({
prompt: 20,
completion: 40,
context: 8000,
});
});
});
});

View file

@ -7,12 +7,12 @@ import type {
TConversationTag, TConversationTag,
TConversationTagRequest, TConversationTagRequest,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { cn, removeFocusOutlines, defaultTextProps, logger } from '~/utils';
import { Checkbox, Label, TextareaAutosize, Input } from '~/components'; import { Checkbox, Label, TextareaAutosize, Input } from '~/components';
import { useBookmarkContext } from '~/Providers/BookmarkContext'; import { useBookmarkContext } from '~/Providers/BookmarkContext';
import { useConversationTagMutation } from '~/data-provider'; import { useConversationTagMutation } from '~/data-provider';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn, logger } from '~/utils';
type TBookmarkFormProps = { type TBookmarkFormProps = {
tags?: string[]; tags?: string[];

View file

@ -47,26 +47,28 @@ const getKnownClass = ({
export default function UnknownIcon({ export default function UnknownIcon({
className = '', className = '',
endpoint, endpoint: _endpoint,
iconURL, iconURL = '',
context, context,
}: { }: {
iconURL?: string; iconURL?: string;
className?: string; className?: string;
endpoint: EModelEndpoint | string | null; endpoint?: EModelEndpoint | string | null;
context?: 'landing' | 'menu-item' | 'nav' | 'message'; context?: 'landing' | 'menu-item' | 'nav' | 'message';
}) { }) {
const endpoint = _endpoint ?? '';
if (!endpoint) { if (!endpoint) {
return <CustomMinimalIcon className={className} />; return <CustomMinimalIcon className={className} />;
} }
console.log('UnknownIcon', endpoint);
const currentEndpoint = endpoint.toLowerCase(); const currentEndpoint = endpoint.toLowerCase();
if (iconURL) { if (iconURL) {
return <img className={className} src={iconURL} alt={`${endpoint} Icon`} />; return <img className={className} src={iconURL} alt={`${endpoint} Icon`} />;
} }
const assetPath = knownEndpointAssets[currentEndpoint]; const assetPath: string = knownEndpointAssets[currentEndpoint] ?? '';
if (!assetPath) { if (!assetPath) {
return <CustomMinimalIcon className={className} />; return <CustomMinimalIcon className={className} />;

View file

@ -1,22 +1,26 @@
import { TPlugin, TPluginAuthConfig, TPluginAction } from 'librechat-data-provider';
import { Save } from 'lucide-react'; import { Save } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { TPlugin, TPluginAuthConfig, TPluginAction } from 'librechat-data-provider';
import { HoverCard, HoverCardTrigger } from '~/components/ui'; import { HoverCard, HoverCardTrigger } from '~/components/ui';
import PluginTooltip from './PluginTooltip'; import PluginTooltip from './PluginTooltip';
import { useLocalize } from '~/hooks';
type TPluginAuthFormProps = { type TPluginAuthFormProps = {
plugin: TPlugin | undefined; plugin: TPlugin | undefined;
onSubmit: (installActionData: TPluginAction) => void; onSubmit: (installActionData: TPluginAction) => void;
isAssistantTool?: boolean; isEntityTool?: boolean;
}; };
function PluginAuthForm({ plugin, onSubmit, isAssistantTool }: TPluginAuthFormProps) { function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps) {
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors, isDirty, isValid, isSubmitting }, formState: { errors, isDirty, isValid, isSubmitting },
} = useForm(); } = useForm();
const localize = useLocalize();
const authConfig = plugin?.authConfig ?? [];
return ( return (
<div className="flex w-full flex-col items-center gap-2"> <div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full gap-6 sm:grid-cols-2"> <div className="grid w-full gap-6 sm:grid-cols-2">
@ -28,11 +32,11 @@ function PluginAuthForm({ plugin, onSubmit, isAssistantTool }: TPluginAuthFormPr
pluginKey: plugin?.pluginKey ?? '', pluginKey: plugin?.pluginKey ?? '',
action: 'install', action: 'install',
auth, auth,
isAssistantTool, isEntityTool,
}), }),
)} )}
> >
{plugin?.authConfig?.map((config: TPluginAuthConfig, i: number) => { {authConfig.map((config: TPluginAuthConfig, i: number) => {
const authField = config.authField.split('||')[0]; const authField = config.authField.split('||')[0];
return ( return (
<div key={`${authField}-${i}`} className="flex w-full flex-col gap-1"> <div key={`${authField}-${i}`} className="flex w-full flex-col gap-1">
@ -66,8 +70,7 @@ function PluginAuthForm({ plugin, onSubmit, isAssistantTool }: TPluginAuthFormPr
</HoverCard> </HoverCard>
{errors[authField] && ( {errors[authField] && (
<span role="alert" className="mt-1 text-sm text-red-400"> <span role="alert" className="mt-1 text-sm text-red-400">
{/* @ts-ignore - Type 'string | FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined' is not assignable to type 'ReactNode' */} {errors[authField].message as string}
{errors[authField].message}
</span> </span>
)} )}
</div> </div>
@ -79,7 +82,7 @@ function PluginAuthForm({ plugin, onSubmit, isAssistantTool }: TPluginAuthFormPr
className="btn btn-primary relative" className="btn btn-primary relative"
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
Save {localize('com_ui_save')}
<Save className="flex h-4 w-4 items-center stroke-2" /> <Save className="flex h-4 w-4 items-center stroke-2" />
</div> </div>
</button> </button>

View file

@ -11,7 +11,6 @@ import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import Action from '~/components/SidePanel/Builder/Action'; import Action from '~/components/SidePanel/Builder/Action';
import { ToolSelectDialog } from '~/components/Tools'; import { ToolSelectDialog } from '~/components/Tools';
import { useLocalize, useAuthContext } from '~/hooks'; import { useLocalize, useAuthContext } from '~/hooks';
import CapabilitiesForm from './CapabilitiesForm';
import { processAgentOption } from '~/utils'; import { processAgentOption } from '~/utils';
import { Spinner } from '~/components/svg'; import { Spinner } from '~/components/svg';
import DeleteButton from './DeleteButton'; import DeleteButton from './DeleteButton';
@ -19,6 +18,7 @@ import AgentAvatar from './AgentAvatar';
import FileSearch from './FileSearch'; import FileSearch from './FileSearch';
import ShareAgent from './ShareAgent'; import ShareAgent from './ShareAgent';
import AgentTool from './AgentTool'; import AgentTool from './AgentTool';
// import CodeForm from './Code/Form';
import { Panel } from '~/common'; import { Panel } from '~/common';
const labelClass = 'mb-2 text-token-text-primary block font-medium'; const labelClass = 'mb-2 text-token-text-primary block font-medium';
@ -92,6 +92,26 @@ export default function AgentConfig({
return _agent.knowledge_files ?? []; return _agent.knowledge_files ?? [];
}, [agent, agent_id, fileMap]); }, [agent, agent_id, fileMap]);
const code_files = useMemo(() => {
if (typeof agent === 'string') {
return [];
}
if (agent?.id !== agent_id) {
return [];
}
if (agent.code_files) {
return agent.code_files;
}
const _agent = processAgentOption({
agent,
fileMap,
});
return _agent.code_files ?? [];
}, [agent, agent_id, fileMap]);
/* Mutations */ /* Mutations */
const update = useUpdateAgentMutation({ const update = useUpdateAgentMutation({
onSuccess: (data) => { onSuccess: (data) => {
@ -293,7 +313,7 @@ export default function AgentConfig({
<div className="shadow-stroke relative flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-white text-black dark:bg-white"> <div className="shadow-stroke relative flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-white text-black dark:bg-white">
<Icon <Icon
className="h-2/3 w-2/3" className="h-2/3 w-2/3"
endpoint={provider as string} endpoint={providerValue as string}
endpointType={endpointType} endpointType={endpointType}
iconURL={endpointIconURL} iconURL={endpointIconURL}
/> />
@ -303,11 +323,8 @@ export default function AgentConfig({
</div> </div>
</button> </button>
</div> </div>
<CapabilitiesForm {/* Code Execution */}
codeEnabled={codeEnabled} {/* {codeEnabled && <CodeForm agent_id={agent_id} files={code_files} />} */}
agentsConfig={agentsConfig}
retrievalEnabled={false}
/>
{/* File Search */} {/* File Search */}
{fileSearchEnabled && <FileSearch agent_id={agent_id} files={knowledge_files} />} {fileSearchEnabled && <FileSearch agent_id={agent_id} files={knowledge_files} />}
{/* Agent Tools & Actions */} {/* Agent Tools & Actions */}

View file

@ -12,7 +12,7 @@ import { cn } from '~/utils';
export default function AgentTool({ export default function AgentTool({
tool, tool,
allTools, allTools,
agent_id, agent_id = '',
}: { }: {
tool: string; tool: string;
allTools: TPlugin[]; allTools: TPlugin[];
@ -28,7 +28,7 @@ export default function AgentTool({
const removeTool = (tool: string) => { const removeTool = (tool: string) => {
if (tool) { if (tool) {
updateUserPlugins.mutate( updateUserPlugins.mutate(
{ pluginKey: tool, action: 'uninstall', auth: null, isAgentTool: true }, { pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
{ {
onError: (error: unknown) => { onError: (error: unknown) => {
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' }); showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });

View file

@ -1,56 +0,0 @@
import { useMemo } from 'react';
// import { Capabilities } from 'librechat-data-provider';
// import { useFormContext, useWatch } from 'react-hook-form';
import type { TConfig } from 'librechat-data-provider';
// import type { AgentForm } from '~/common';
// import ImageVision from './ImageVision';
import { useLocalize } from '~/hooks';
import Retrieval from './Retrieval';
// import CodeFiles from './CodeFiles';
import Code from './Code';
export default function CapabilitiesForm({
codeEnabled,
retrievalEnabled,
agentsConfig,
}: {
codeEnabled?: boolean;
retrievalEnabled?: boolean;
agentsConfig?: TConfig | null;
}) {
const localize = useLocalize();
// const methods = useFormContext<AgentForm>();
// const { control } = methods;
// const agent = useWatch({ control, name: 'agent' });
// const agent_id = useWatch({ control, name: 'id' });
// const files = useMemo(() => {
// if (typeof agent === 'string') {
// return [];
// }
// return agent?.code_files;
// }, [agent]);
const retrievalModels = useMemo(
() => new Set(agentsConfig?.retrievalModels ?? []),
[agentsConfig],
);
return (
<div className="mb-4">
<div className="mb-1.5 flex items-center">
<span>
<label className="text-token-text-primary block font-medium">
{localize('com_assistants_capabilities')}
</label>
</span>
</div>
<div className="flex flex-col items-start gap-2">
{codeEnabled === true && <Code />}
{retrievalEnabled === true && <Retrieval retrievalModels={retrievalModels} />}
{/* {imageVisionEnabled && version == 1 && <ImageVision />} */}
{/* {codeEnabled && <CodeFiles agent_id={agent_id} files={files} />} */}
</div>
</div>
);
}

View file

@ -1,70 +0,0 @@
import { AgentCapabilities } from 'librechat-data-provider';
import { useFormContext, Controller } from 'react-hook-form';
import type { AgentForm } from '~/common';
import {
Checkbox,
HoverCard,
HoverCardContent,
HoverCardPortal,
HoverCardTrigger,
} from '~/components/ui';
import { CircleHelpIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';
export default function Code() {
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { control, setValue, getValues } = methods;
return (
<>
<HoverCard openDelay={50}>
<div className="flex items-center">
<Controller
name={AgentCapabilities.execute_code}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()}
/>
)}
/>
<button
type="button"
className="flex items-center space-x-2"
onClick={() =>
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
setValue(AgentCapabilities.execute_code, !getValues(AgentCapabilities.execute_code), {
shouldDirty: true,
})
}
>
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={AgentCapabilities.execute_code}
>
{localize('com_agents_execute_code')}
</label>
<HoverCardTrigger>
<CircleHelpIcon className="h-5 w-5 text-gray-500" />
</HoverCardTrigger>
</button>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{/* // TODO: add a Code Interpreter description */}
</p>
</div>
</HoverCardContent>
</HoverCardPortal>
</div>
</HoverCard>
</>
);
}

View file

@ -0,0 +1,151 @@
import { useState } from 'react';
import { KeyRoundIcon } from 'lucide-react';
import { AuthType, AgentCapabilities } from 'librechat-data-provider';
import { useFormContext, Controller, useForm, useWatch } from 'react-hook-form';
import type { AgentForm } from '~/common';
import {
Input,
OGDialog,
Checkbox,
HoverCard,
HoverCardContent,
HoverCardPortal,
HoverCardTrigger,
Button,
} from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useAuthCodeTool } from '~/hooks';
import { CircleHelpIcon } from '~/components/svg';
import { ESide } from '~/common';
type ApiKeyFormData = {
apiKey: string;
authType?: string | AuthType;
};
export default function Action({ authType = '', isToolAuthenticated = false }) {
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { control, setValue, getValues } = methods;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const runCodeIsEnabled = useWatch({ control, name: AgentCapabilities.execute_code });
const { installTool, removeTool } = useAuthCodeTool({ isEntityTool: true });
const { reset, register, handleSubmit } = useForm<ApiKeyFormData>();
const isUserProvided = authType === AuthType.USER_PROVIDED;
const handleCheckboxChange = (checked: boolean) => {
if (isToolAuthenticated) {
setValue(AgentCapabilities.execute_code, checked, { shouldDirty: true });
} else if (runCodeIsEnabled) {
setValue(AgentCapabilities.execute_code, false, { shouldDirty: true });
} else {
setIsDialogOpen(true);
}
};
const onSubmit = (data: { apiKey: string }) => {
reset();
installTool(data.apiKey);
setIsDialogOpen(false);
};
const handleRevokeApiKey = () => {
reset();
removeTool();
setIsDialogOpen(false);
};
return (
<>
<HoverCard openDelay={50}>
<div className="flex items-center">
<Controller
name={AgentCapabilities.execute_code}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={runCodeIsEnabled ? runCodeIsEnabled : isToolAuthenticated && field.value}
onCheckedChange={handleCheckboxChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()}
disabled={runCodeIsEnabled ? false : !isToolAuthenticated}
/>
)}
/>
<button
type="button"
className="flex items-center space-x-2"
onClick={() => {
const value = !getValues(AgentCapabilities.execute_code);
handleCheckboxChange(value);
}}
>
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={AgentCapabilities.execute_code}
>
{localize('com_agents_execute_code')}
</label>
</button>
<div className="ml-2 flex gap-2">
{isUserProvided && (isToolAuthenticated || runCodeIsEnabled) && (
<button type="button" onClick={() => setIsDialogOpen(true)}>
<KeyRoundIcon className="h-5 w-5 text-text-primary" />
</button>
)}
<HoverCardTrigger>
<CircleHelpIcon className="h-5 w-5 text-gray-500" />
</HoverCardTrigger>
</div>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{/* // TODO: add a Code Interpreter description */}
</p>
</div>
</HoverCardContent>
</HoverCardPortal>
</div>
</HoverCard>
<OGDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<OGDialogTemplate
className="w-11/12 sm:w-1/4"
title={localize('com_agents_tool_not_authenticated')}
main={
<form onSubmit={handleSubmit(onSubmit)}>
<Input
type="password"
placeholder="Enter API Key"
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('apiKey', { required: true })}
/>
</form>
}
selection={{
selectHandler: handleSubmit(onSubmit),
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
selectText: localize('com_ui_save'),
}}
buttons={
isUserProvided &&
isToolAuthenticated && (
<Button
onClick={handleRevokeApiKey}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
>
{localize('com_ui_revoke')}
</Button>
)
}
showCancelButton={true}
/>
</OGDialog>
</>
);
}

View file

@ -1,21 +1,22 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef } from 'react';
import { useFormContext } from 'react-hook-form';
import { import {
EToolResources, EToolResources,
EModelEndpoint, EModelEndpoint,
mergeFileConfig, mergeFileConfig,
AgentCapabilities,
fileConfig as defaultFileConfig, fileConfig as defaultFileConfig,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider'; import type { EndpointFileConfig } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common'; import type { ExtendedFile, AgentForm } from '~/common';
import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks';
import FileRow from '~/components/Chat/Input/Files/FileRow'; import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetFileConfig } from '~/data-provider'; import { useGetFileConfig } from '~/data-provider';
import { useFileHandling } from '~/hooks/Files';
import useLocalize from '~/hooks/useLocalize';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
const tool_resource = EToolResources.code_interpreter; const tool_resource = EToolResources.execute_code;
export default function CodeFiles({ export default function Files({
agent_id, agent_id,
files: _files, files: _files,
}: { }: {
@ -24,22 +25,29 @@ export default function CodeFiles({
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const { setFilesLoading } = useChatContext(); const { setFilesLoading } = useChatContext();
const { watch } = useFormContext<AgentForm>();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map()); const [files, setFiles] = useState<Map<string, ExtendedFile>>(new Map());
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data), select: (data) => mergeFileConfig(data),
}); });
const { handleFileChange } = useFileHandling({ const { abortUpload, handleFileChange } = useFileHandling({
fileSetter: setFiles,
overrideEndpoint: EModelEndpoint.agents, overrideEndpoint: EModelEndpoint.agents,
additionalMetadata: { agent_id, tool_resource }, additionalMetadata: { agent_id, tool_resource },
fileSetter: setFiles,
}); });
useEffect(() => { useLazyEffect(
if (_files) { () => {
setFiles(new Map(_files)); if (_files) {
} setFiles(new Map(_files));
}, [_files]); }
},
[_files],
750,
);
const codeChecked = watch(AgentCapabilities.execute_code);
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents] as const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents] as
| EndpointFileConfig | EndpointFileConfig
@ -68,6 +76,7 @@ export default function CodeFiles({
files={files} files={files}
setFiles={setFiles} setFiles={setFiles}
agent_id={agent_id} agent_id={agent_id}
abortUpload={abortUpload}
tool_resource={tool_resource} tool_resource={tool_resource}
setFilesLoading={setFilesLoading} setFilesLoading={setFilesLoading}
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>} Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
@ -75,7 +84,7 @@ export default function CodeFiles({
<div> <div>
<button <button
type="button" type="button"
disabled={!agent_id} disabled={!agent_id || codeChecked === false}
className="btn btn-neutral border-token-border-light relative h-8 w-full rounded-lg font-medium" className="btn btn-neutral border-token-border-light relative h-8 w-full rounded-lg font-medium"
onClick={handleButtonClick} onClick={handleButtonClick}
> >
@ -86,7 +95,7 @@ export default function CodeFiles({
style={{ display: 'none' }} style={{ display: 'none' }}
tabIndex={-1} tabIndex={-1}
ref={fileInputRef} ref={fileInputRef}
disabled={!agent_id} disabled={!agent_id || codeChecked === false}
onChange={handleFileChange} onChange={handleFileChange}
/> />
{localize('com_ui_upload_files')} {localize('com_ui_upload_files')}

View file

@ -0,0 +1,33 @@
import { Tools } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { useVerifyAgentToolAuth } from '~/data-provider';
import { useLocalize } from '~/hooks';
import Action from './Action';
import Files from './Files';
export default function CodeForm({
agent_id,
files,
}: {
agent_id: string;
files?: [string, ExtendedFile][];
}) {
const localize = useLocalize();
const { data } = useVerifyAgentToolAuth({ toolId: Tools.execute_code });
return (
<div className="mb-4">
<div className="mb-1.5 flex items-center">
<span>
<label className="text-token-text-primary block font-medium">
{localize('com_assistants_capabilities')}
</label>
</span>
</div>
<div className="flex flex-col items-start gap-2">
<Action authType={data?.message} isToolAuthenticated={data?.authenticated} />
<Files agent_id={agent_id} files={files} />
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef } from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { import {
EModelEndpoint, EModelEndpoint,
@ -9,12 +9,11 @@ import {
fileConfig as defaultFileConfig, fileConfig as defaultFileConfig,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { ExtendedFile, AgentForm } from '~/common'; import type { ExtendedFile, AgentForm } from '~/common';
import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks';
import FileRow from '~/components/Chat/Input/Files/FileRow'; import FileRow from '~/components/Chat/Input/Files/FileRow';
import FileSearchCheckbox from './FileSearchCheckbox'; import FileSearchCheckbox from './FileSearchCheckbox';
import { useGetFileConfig } from '~/data-provider'; import { useGetFileConfig } from '~/data-provider';
import { AttachmentIcon } from '~/components/svg'; import { AttachmentIcon } from '~/components/svg';
import { useFileHandling } from '~/hooks/Files';
import useLocalize from '~/hooks/useLocalize';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
export default function FileSearch({ export default function FileSearch({
@ -40,18 +39,22 @@ export default function FileSearch({
fileSetter: setFiles, fileSetter: setFiles,
}); });
useEffect(() => { useLazyEffect(
if (_files) { () => {
setFiles(new Map(_files)); if (_files) {
} setFiles(new Map(_files));
}, [_files]); }
},
[_files],
750,
);
const fileSearchChecked = watch(AgentCapabilities.file_search); const fileSearchChecked = watch(AgentCapabilities.file_search);
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents]; const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents];
const disabled = endpointFileConfig.disabled ?? false; const isUploadDisabled = endpointFileConfig.disabled ?? false;
if (disabled === true) { if (isUploadDisabled) {
return null; return null;
} }

View file

@ -12,7 +12,7 @@ import { cn } from '~/utils';
export default function AssistantTool({ export default function AssistantTool({
tool, tool,
allTools, allTools,
assistant_id, assistant_id = '',
}: { }: {
tool: string; tool: string;
allTools: TPlugin[]; allTools: TPlugin[];
@ -28,7 +28,7 @@ export default function AssistantTool({
const removeTool = (tool: string) => { const removeTool = (tool: string) => {
if (tool) { if (tool) {
updateUserPlugins.mutate( updateUserPlugins.mutate(
{ pluginKey: tool, action: 'uninstall', auth: null, isAssistantTool: true }, { pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
{ {
onError: (error: unknown) => { onError: (error: unknown) => {
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' }); showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
@ -78,7 +78,7 @@ export default function AssistantTool({
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<button <button
type="button" type="button"
className="transition-colors flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700" className="flex h-9 w-9 min-w-9 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
> >
<TrashIcon /> <TrashIcon />
</button> </button>

View file

@ -90,7 +90,7 @@ function ToolSelectDialog({
const onRemoveTool = (tool: string) => { const onRemoveTool = (tool: string) => {
setShowPluginAuthForm(false); setShowPluginAuthForm(false);
updateUserPlugins.mutate( updateUserPlugins.mutate(
{ pluginKey: tool, action: 'uninstall', auth: null, isAssistantTool: true }, { pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
{ {
onError: (error: unknown) => { onError: (error: unknown) => {
handleInstallError(error as TError); handleInstallError(error as TError);
@ -199,7 +199,7 @@ function ToolSelectDialog({
<PluginAuthForm <PluginAuthForm
plugin={selectedPlugin} plugin={selectedPlugin}
onSubmit={(installActionData: TPluginAction) => handleInstall(installActionData)} onSubmit={(installActionData: TPluginAction) => handleInstall(installActionData)}
isAssistantTool={true} isEntityTool={true}
/> />
</div> </div>
)} )}

View file

@ -10,13 +10,6 @@ import {
import type * as t from 'librechat-data-provider'; import type * as t from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query'; import type { UseMutationResult } from '@tanstack/react-query';
export type TGenTitleMutation = UseMutationResult<
t.TGenTitleResponse,
unknown,
t.TGenTitleRequest,
unknown
>;
export const useUploadFileMutation = ( export const useUploadFileMutation = (
_options?: t.UploadMutationOptions, _options?: t.UploadMutationOptions,
signal?: AbortSignal | null, signal?: AbortSignal | null,
@ -152,9 +145,9 @@ export const useDeleteFilesMutation = (
return useMutation([MutationKeys.fileDelete], { return useMutation([MutationKeys.fileDelete], {
mutationFn: (body: t.DeleteFilesBody) => dataService.deleteFiles(body), mutationFn: (body: t.DeleteFilesBody) => dataService.deleteFiles(body),
...options, ...options,
onSuccess: (data, ...args) => { onSuccess: (data, vars, context) => {
queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (cachefiles) => { queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (cachefiles) => {
const { files: filesDeleted } = args[0]; const { files: filesDeleted } = vars;
const fileMap = filesDeleted.reduce((acc, file) => { const fileMap = filesDeleted.reduce((acc, file) => {
acc.set(file.file_id, file); acc.set(file.file_id, file);
@ -163,7 +156,10 @@ export const useDeleteFilesMutation = (
return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id)); return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id));
}); });
onSuccess?.(data, ...args); onSuccess?.(data, vars, context);
if (vars.agent_id != null && vars.agent_id) {
queryClient.refetchQueries([QueryKeys.agent, vars.agent_id]);
}
}, },
}); });
}; };

View file

@ -0,0 +1,2 @@
export * from './queries';
// export * from './mutations';

View file

@ -0,0 +1,20 @@
import { QueryKeys, dataService } from 'librechat-data-provider';
import { useQuery } from '@tanstack/react-query';
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
export const useVerifyAgentToolAuth = (
params: t.VerifyToolAuthParams,
config?: UseQueryOptions<t.VerifyToolAuthResponse>,
): QueryObserverResult<t.VerifyToolAuthResponse> => {
return useQuery<t.VerifyToolAuthResponse>(
[QueryKeys.toolAuth, params.toolId],
() => dataService.getVerifyAgentToolAuth(params),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
},
);
};

View file

@ -1,4 +1,5 @@
export * from './Files'; export * from './Files';
export * from './Tools';
export * from './connection'; export * from './connection';
export * from './mutations'; export * from './mutations';
export * from './prompts'; export * from './prompts';

View file

@ -0,0 +1 @@
export * from './useLazyEffect';

View file

@ -0,0 +1,18 @@
/* eslint-disable react-hooks/exhaustive-deps */
// https://stackoverflow.com/a/67504622/51500
import { DependencyList, EffectCallback, useCallback, useEffect, useRef } from 'react';
import debounce from 'lodash/debounce';
export function useLazyEffect(effect: EffectCallback, deps: DependencyList = [], wait = 300) {
const cleanUp = useRef<void | (() => void)>();
const effectRef = useRef<EffectCallback>();
effectRef.current = useCallback(effect, deps);
const lazyEffect = useCallback(
debounce(() => (cleanUp.current = effectRef.current?.()), wait),
[],
);
useEffect(lazyEffect, deps);
useEffect(() => {
return () => (cleanUp.current instanceof Function ? cleanUp.current() : undefined);
}, []);
}

View file

@ -1,2 +1,3 @@
export { default as useAuthCodeTool } from './useAuthCodeTool';
export { default as usePluginInstall } from './usePluginInstall'; export { default as usePluginInstall } from './usePluginInstall';
export { default as usePluginDialogHelpers } from './usePluginDialogHelpers'; export { default as usePluginDialogHelpers } from './usePluginDialogHelpers';

View file

@ -0,0 +1,53 @@
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { AuthType, Tools, QueryKeys } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
// import { useToastContext } from '~/Providers';
const useAuthCodeTool = (options?: { isEntityTool: boolean }) => {
// const { showToast } = useToastContext();
const queryClient = useQueryClient();
const isEntityTool = options?.isEntityTool ?? true;
const updateUserPlugins = useUpdateUserPluginsMutation({
onMutate: (vars) => {
queryClient.setQueryData([QueryKeys.toolAuth, Tools.execute_code], () => ({
authenticated: vars.action === 'install',
message: AuthType.USER_PROVIDED,
}));
},
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.toolAuth, Tools.execute_code]);
},
onError: () => {
queryClient.invalidateQueries([QueryKeys.toolAuth, Tools.execute_code]);
},
});
const installTool = useCallback(
(apiKey: string) => {
updateUserPlugins.mutate({
pluginKey: Tools.execute_code,
action: 'install',
auth: { LIBRECHAT_CODE_API_KEY: apiKey },
isEntityTool,
});
},
[updateUserPlugins, isEntityTool],
);
const removeTool = useCallback(() => {
updateUserPlugins.mutate({
pluginKey: Tools.execute_code,
action: 'uninstall',
auth: { LIBRECHAT_CODE_API_KEY: null },
isEntityTool,
});
}, [updateUserPlugins, isEntityTool]);
return {
removeTool,
installTool,
};
};
export default useAuthCodeTool;

View file

@ -6,6 +6,7 @@ export * from './Config';
export * from './Conversations'; export * from './Conversations';
export * from './Nav'; export * from './Nav';
export * from './Files'; export * from './Files';
export * from './Generic';
export * from './Input'; export * from './Input';
export * from './Messages'; export * from './Messages';
export * from './Plugins'; export * from './Plugins';

1158
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -125,10 +125,10 @@ export const assistants = ({
return url; return url;
}; };
export const agents = ({ path, options }: { path?: string; options?: object }) => { export const agents = ({ path = '', options }: { path?: string; options?: object }) => {
let url = '/api/agents'; let url = '/api/agents';
if (path) { if (path && path !== '') {
url += `/${path}`; url += `/${path}`;
} }

View file

@ -584,6 +584,7 @@ export const alternateName = {
[EModelEndpoint.anthropic]: 'Anthropic', [EModelEndpoint.anthropic]: 'Anthropic',
[EModelEndpoint.custom]: 'Custom', [EModelEndpoint.custom]: 'Custom',
[EModelEndpoint.bedrock]: 'AWS Bedrock', [EModelEndpoint.bedrock]: 'AWS Bedrock',
ollama: 'Ollama',
}; };
const sharedOpenAIModels = [ const sharedOpenAIModels = [

View file

@ -304,6 +304,16 @@ export const getAvailableTools = (
return request.get(path); return request.get(path);
}; };
export const getVerifyAgentToolAuth = (
params: q.VerifyToolAuthParams,
): Promise<q.VerifyToolAuthResponse> => {
return request.get(
endpoints.agents({
path: `tools/${params.toolId}/auth`,
}),
);
};
/* Files */ /* Files */
export const getFiles = (): Promise<f.TFile[]> => { export const getFiles = (): Promise<f.TFile[]> => {

View file

@ -25,6 +25,7 @@ export enum QueryKeys {
files = 'files', files = 'files',
fileConfig = 'fileConfig', fileConfig = 'fileConfig',
tools = 'tools', tools = 'tools',
toolAuth = 'toolAuth',
agentTools = 'agentTools', agentTools = 'agentTools',
actions = 'actions', actions = 'actions',
assistantDocs = 'assistantDocs', assistantDocs = 'assistantDocs',

View file

@ -408,16 +408,16 @@ export const useAvailablePluginsQuery = <TData = s.TPlugin[]>(
); );
}; };
export const useUpdateUserPluginsMutation = (): UseMutationResult< export const useUpdateUserPluginsMutation = (
t.TUser, _options?: m.UpdatePluginAuthOptions,
unknown, ): UseMutationResult<t.TUser, unknown, t.TUpdateUserPlugins, unknown> => {
t.TUpdateUserPlugins,
unknown
> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { onSuccess, ...options } = _options ?? {};
return useMutation((payload: t.TUpdateUserPlugins) => dataService.updateUserPlugins(payload), { return useMutation((payload: t.TUpdateUserPlugins) => dataService.updateUserPlugins(payload), {
onSuccess: () => { ...options,
onSuccess: (...args) => {
queryClient.invalidateQueries([QueryKeys.user]); queryClient.invalidateQueries([QueryKeys.user]);
onSuccess?.(...args);
}, },
}); });
}; };

View file

@ -8,7 +8,7 @@ export const isUUID = z.string().uuid();
export enum AuthType { export enum AuthType {
OVERRIDE_AUTH = 'override_auth', OVERRIDE_AUTH = 'override_auth',
USER_PROVIDED = 'user_provided', USER_PROVIDED = 'user_provided',
SYSTEM_DEFINED = 'SYSTEM_DEFINED', SYSTEM_DEFINED = 'system_defined',
} }
export const authTypeSchema = z.nativeEnum(AuthType); export const authTypeSchema = z.nativeEnum(AuthType);
@ -369,7 +369,7 @@ export const tPluginSchema = z.object({
pluginKey: z.string(), pluginKey: z.string(),
description: z.string(), description: z.string(),
icon: z.string(), icon: z.string(),
authConfig: z.array(tPluginAuthConfigSchema), authConfig: z.array(tPluginAuthConfigSchema).optional(),
authenticated: z.boolean().optional(), authenticated: z.boolean().optional(),
isButton: z.boolean().optional(), isButton: z.boolean().optional(),
}); });

View file

@ -72,14 +72,13 @@ export type TPluginAction = {
pluginKey: string; pluginKey: string;
action: 'install' | 'uninstall'; action: 'install' | 'uninstall';
auth?: unknown; auth?: unknown;
isAssistantTool?: boolean; isEntityTool?: boolean;
}; };
export type GroupedConversations = [key: string, TConversation[]][]; export type GroupedConversations = [key: string, TConversation[]][];
export type TUpdateUserPlugins = { export type TUpdateUserPlugins = {
isAssistantTool?: boolean; isEntityTool?: boolean;
isAgentTool?: boolean;
pluginKey: string; pluginKey: string;
action: string; action: string;
auth?: unknown; auth?: unknown;

View file

@ -69,6 +69,7 @@ export type TFile = {
height?: number; height?: number;
expiresAt?: string | Date; expiresAt?: string | Date;
preview?: string; preview?: string;
metadata?: { fileIdentifier?: string };
createdAt?: string | Date; createdAt?: string | Date;
updatedAt?: string | Date; updatedAt?: string | Date;
}; };

View file

@ -248,3 +248,6 @@ export type AcceptTermsMutationOptions = MutationOptions<
unknown, unknown,
void void
>; >;
/* Tools */
export type UpdatePluginAuthOptions = MutationOptions<types.TUser, types.TUpdateUserPlugins>;

View file

@ -1,13 +1,13 @@
import type { InfiniteData } from '@tanstack/react-query'; import type { InfiniteData } from '@tanstack/react-query';
import type * as s from '../schemas';
import type * as t from '../types'; import type * as t from '../types';
import type { TMessage, TConversation, TSharedLink, TConversationTag } from '../schemas';
export type Conversation = { export type Conversation = {
id: string; id: string;
createdAt: number; createdAt: number;
participants: string[]; participants: string[];
lastMessage: string; lastMessage: string;
conversations: TConversation[]; conversations: s.TConversation[];
}; };
// Parameters for listing conversations (e.g., for pagination) // Parameters for listing conversations (e.g., for pagination)
@ -24,33 +24,33 @@ export type ConversationListParams = {
// Type for the response from the conversation list API // Type for the response from the conversation list API
export type ConversationListResponse = { export type ConversationListResponse = {
conversations: TConversation[]; conversations: s.TConversation[];
pageNumber: string; pageNumber: string;
pageSize: string | number; pageSize: string | number;
pages: string | number; pages: string | number;
messages: TMessage[]; messages: s.TMessage[];
}; };
export type ConversationData = InfiniteData<ConversationListResponse>; export type ConversationData = InfiniteData<ConversationListResponse>;
export type ConversationUpdater = ( export type ConversationUpdater = (
data: ConversationData, data: ConversationData,
conversation: TConversation, conversation: s.TConversation,
) => ConversationData; ) => ConversationData;
export type SharedMessagesResponse = Omit<TSharedLink, 'messages'> & { export type SharedMessagesResponse = Omit<s.TSharedLink, 'messages'> & {
messages: TMessage[]; messages: s.TMessage[];
}; };
export type SharedLinkListParams = Omit<ConversationListParams, 'isArchived' | 'conversationId'> & { export type SharedLinkListParams = Omit<ConversationListParams, 'isArchived' | 'conversationId'> & {
isPublic?: boolean; isPublic?: boolean;
}; };
export type SharedLinksResponse = Omit<ConversationListResponse, 'conversations' | 'messages'> & { export type SharedLinksResponse = Omit<ConversationListResponse, 'conversations' | 'messages'> & {
sharedLinks: TSharedLink[]; sharedLinks: s.TSharedLink[];
}; };
// Type for the response from the conversation list API // Type for the response from the conversation list API
export type SharedLinkListResponse = { export type SharedLinkListResponse = {
sharedLinks: TSharedLink[]; sharedLinks: s.TSharedLink[];
pageNumber: string; pageNumber: string;
pageSize: string | number; pageSize: string | number;
pages: string | number; pages: string | number;
@ -71,4 +71,7 @@ export type AllPromptGroupsFilterRequest = {
export type AllPromptGroupsResponse = t.TPromptGroup[]; export type AllPromptGroupsResponse = t.TPromptGroup[];
export type ConversationTagsResponse = TConversationTag[]; export type ConversationTagsResponse = s.TConversationTag[];
export type VerifyToolAuthParams = { toolId: string };
export type VerifyToolAuthResponse = { authenticated: boolean; message?: string | s.AuthType };