LibreChat/api/server/controllers/tools.js
2026-05-10 14:39:57 -04:00

263 lines
8.9 KiB
JavaScript

const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { checkAccess, loadWebSearchAuth } = require('@librechat/api');
const {
Tools,
AuthType,
Permissions,
ToolCallTypes,
PermissionTypes,
} = require('librechat-data-provider');
const { getRoleByName, createToolCall, getToolCallsByConvo, getMessage } = require('~/models');
const {
getRetentionExpiry,
processFileURL,
uploadImageBuffer,
} = require('~/server/services/Files/process');
const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { loadTools } = require('~/app/clients/tools/util');
/**
* Tools that are callable directly via `POST /tools/:toolId/call`.
* `execute_code` is the only entry today; the tool runs server-side via
* the agents library / sandbox service without any per-user credential.
*/
const directCallableTools = new Set([Tools.execute_code]);
const toolAccessPermType = {
[Tools.execute_code]: PermissionTypes.RUN_CODE,
};
/**
* Verifies web search authentication, ensuring each category has at least
* one fully authenticated service.
*
* @param {ServerRequest} req - The request object
* @param {ServerResponse} res - The response object
* @returns {Promise<void>} A promise that resolves when the function has completed
*/
const verifyWebSearchAuth = async (req, res) => {
try {
const appConfig = req.config;
const userId = req.user.id;
/** @type {TCustomConfig['webSearch']} */
const webSearchConfig = appConfig?.webSearch || {};
const result = await loadWebSearchAuth({
userId,
loadAuthValues,
webSearchConfig,
throwError: false,
});
return res.status(200).json({
authenticated: result.authenticated,
authTypes: result.authTypes,
});
} catch (error) {
console.error('Error in verifyWebSearchAuth:', error);
return res.status(500).json({ message: error.message });
}
};
/**
* @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;
if (toolId === Tools.web_search) {
return await verifyWebSearchAuth(req, res);
}
if (!directCallableTools.has(toolId)) {
res.status(404).json({ message: 'Tool not found' });
return;
}
/**
* `execute_code` no longer requires a per-user credential — sandbox
* auth is handled server-side by the agents library. Always report
* system-authenticated so the client proceeds straight to the call
* without a key-entry dialog.
*
* Deployment contract: reachability of the sandbox service is the
* admin's responsibility. This endpoint does not probe the service
* (a per-auth-check network hop would be too expensive for what is
* a UI-gate query). If the sandbox is unreachable, the call path
* surfaces the error at execution time instead of here.
*/
res.status(200).json({ authenticated: true, message: AuthType.SYSTEM_DEFINED });
} catch (error) {
res.status(500).json({ message: error.message });
}
};
/**
* @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.
* @param {NextFunction} next - The next middleware function to call.
* @returns {Promise<void>} A promise that resolves when the function has completed.
*/
const callTool = async (req, res) => {
try {
const appConfig = req.config;
const { toolId = '' } = req.params;
if (!directCallableTools.has(toolId)) {
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`);
res.status(404).json({ message: 'Tool not found' });
return;
}
const { partIndex, blockIndex, messageId, conversationId, ...args } = req.body;
if (!messageId) {
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call without message ID`);
res.status(400).json({ message: 'Message ID required' });
return;
}
const message = await getMessage({ user: req.user.id, messageId });
if (!message) {
logger.debug(`[${toolId}/call] User ${req.user.id} attempted call with invalid message ID`);
res.status(404).json({ message: 'Message not found' });
return;
}
logger.debug(`[${toolId}/call] User: ${req.user.id}`);
let hasAccess = true;
if (toolAccessPermType[toolId]) {
hasAccess = await checkAccess({
user: req.user,
permissionType: toolAccessPermType[toolId],
permissions: [Permissions.USE],
getRoleByName,
});
}
if (!hasAccess) {
logger.warn(
`[${toolAccessPermType[toolId]}] Forbidden: Insufficient permissions for User ${req.user.id}: ${Permissions.USE}`,
);
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
}
const { loadedTools } = await loadTools({
user: req.user.id,
tools: [toolId],
functions: true,
options: {
req,
returnMetadata: true,
processFileURL,
uploadImageBuffer,
},
webSearch: appConfig.webSearch,
fileStrategy: appConfig.fileStrategy,
imageOutputType: appConfig.imageOutputType,
});
const tool = loadedTools[0];
const toolCallId = `${req.user.id}_${nanoid()}`;
const result = await tool.invoke({
args,
name: toolId,
id: toolCallId,
type: ToolCallTypes.TOOL_CALL,
});
const { content, artifact } = result;
const toolCallData = {
toolId,
messageId,
partIndex,
blockIndex,
conversationId,
result: content,
user: req.user.id,
};
Object.assign(toolCallData, await getRetentionExpiry(req));
if (!artifact || !artifact.files || toolId !== Tools.execute_code) {
createToolCall(toolCallData).catch((error) => {
logger.error(`Error creating tool call: ${error.message}`);
});
return res.status(200).json({
result: content,
});
}
const artifactPromises = [];
for (const file of artifact.files) {
/* Files flagged `inherited` by codeapi are unchanged passthroughs of
* inputs the caller already owns (skill files, prior downloaded inputs,
* inherited .dirkeep markers). Re-downloading them is wasted work and
* 403s when the file is scoped to a different entity (e.g. skill
* entity_id) than the user's session key. They remain available for
* subsequent tool calls via primeInvokedSkills / session inheritance. */
if (file.inherited) {
continue;
}
const { id, name } = file;
artifactPromises.push(
(async () => {
const result = await processCodeOutput({
req,
id,
name,
messageId,
toolCallId,
conversationId,
session_id: artifact.session_id,
});
const fileMetadata = result?.file ?? null;
const finalize = result?.finalize;
if (!fileMetadata) {
return null;
}
/* This endpoint is non-streaming and its contract is "give
* me the artifacts" — return the persisted record immediately
* (with `status: 'pending'` for office buckets) and run the
* preview render in the background. The client polls
* `/api/files/:file_id/preview` for the resolved record.
* No `onResolved` — there's no live stream to write to here. */
runPreviewFinalize({
finalize,
fileId: fileMetadata.file_id,
previewRevision: result?.previewRevision,
});
return fileMetadata;
})().catch((error) => {
logger.error('Error processing code output:', error);
return null;
}),
);
}
const attachments = await Promise.all(artifactPromises);
toolCallData.attachments = attachments;
createToolCall(toolCallData).catch((error) => {
logger.error(`Error creating tool call: ${error.message}`);
});
res.status(200).json({
result: content,
attachments,
});
} catch (error) {
logger.error('Error calling tool', error);
res.status(500).json({ message: 'Error calling tool' });
}
};
const getToolCalls = async (req, res) => {
try {
const { conversationId } = req.query;
const toolCalls = await getToolCallsByConvo(conversationId, req.user.id);
res.status(200).json(toolCalls);
} catch (error) {
logger.error('Error getting tool calls', error);
res.status(500).json({ message: 'Error getting tool calls' });
}
};
module.exports = {
callTool,
getToolCalls,
verifyToolAuth,
};