Compare commits
21 Commits
refactor/m
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f87bc231c5 | ||
|
|
611411e712 | ||
|
|
6235ad21fe | ||
|
|
25afe0c4c9 | ||
|
|
dd72c32a84 | ||
|
|
613be5103b | ||
|
|
a92843a54d | ||
|
|
451dcbff83 | ||
|
|
e6baecb985 | ||
|
|
6e0e47d5dd | ||
|
|
6d91fa1fe5 | ||
|
|
88717fcb81 | ||
|
|
8cb7f7dea5 | ||
|
|
3f1224e23e | ||
|
|
4464b333e9 | ||
|
|
3784c702aa | ||
|
|
6d0ce0ac2a | ||
|
|
3b15944448 | ||
|
|
02454bf502 | ||
|
|
cfffd43184 | ||
|
|
317a5b5310 |
@@ -10,17 +10,7 @@ const {
|
||||
validateVisionModel,
|
||||
} = require('librechat-data-provider');
|
||||
const { SplitStreamHandler: _Handler } = require('@librechat/agents');
|
||||
const {
|
||||
Tokenizer,
|
||||
createFetch,
|
||||
matchModelName,
|
||||
getClaudeHeaders,
|
||||
getModelMaxTokens,
|
||||
configureReasoning,
|
||||
checkPromptCacheSupport,
|
||||
getModelMaxOutputTokens,
|
||||
createStreamEventHandlers,
|
||||
} = require('@librechat/api');
|
||||
const { Tokenizer, createFetch, createStreamEventHandlers } = require('@librechat/api');
|
||||
const {
|
||||
truncateText,
|
||||
formatMessage,
|
||||
@@ -29,6 +19,12 @@ const {
|
||||
parseParamFromPrompt,
|
||||
createContextHandlers,
|
||||
} = require('./prompts');
|
||||
const {
|
||||
getClaudeHeaders,
|
||||
configureReasoning,
|
||||
checkPromptCacheSupport,
|
||||
} = require('~/server/services/Endpoints/anthropic/helpers');
|
||||
const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils');
|
||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { sleep } = require('~/server/utils');
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const { google } = require('googleapis');
|
||||
const { getModelMaxTokens } = require('@librechat/api');
|
||||
const { concat } = require('@langchain/core/utils/stream');
|
||||
const { ChatVertexAI } = require('@langchain/google-vertexai');
|
||||
const { Tokenizer, getSafetySettings } = require('@librechat/api');
|
||||
@@ -22,6 +21,7 @@ const {
|
||||
} = require('librechat-data-provider');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images');
|
||||
const { spendTokens } = require('~/models/spendTokens');
|
||||
const { getModelMaxTokens } = require('~/utils');
|
||||
const { sleep } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
const {
|
||||
|
||||
@@ -7,9 +7,7 @@ const {
|
||||
createFetch,
|
||||
resolveHeaders,
|
||||
constructAzureURL,
|
||||
getModelMaxTokens,
|
||||
genAzureChatCompletion,
|
||||
getModelMaxOutputTokens,
|
||||
createStreamEventHandlers,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
@@ -33,13 +31,13 @@ const {
|
||||
titleInstruction,
|
||||
createContextHandlers,
|
||||
} = require('./prompts');
|
||||
const { extractBaseURL, getModelMaxTokens, getModelMaxOutputTokens } = require('~/utils');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { addSpaceIfNeeded, sleep } = require('~/server/utils');
|
||||
const { spendTokens } = require('~/models/spendTokens');
|
||||
const { handleOpenAIErrors } = require('./tools/util');
|
||||
const { summaryBuffer } = require('./memory');
|
||||
const { runTitleChain } = require('./chains');
|
||||
const { extractBaseURL } = require('~/utils');
|
||||
const { tokenSplit } = require('./document');
|
||||
const BaseClient = require('./BaseClient');
|
||||
const { createLLM } = require('./llm');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { getModelMaxTokens } = require('@librechat/api');
|
||||
const BaseClient = require('../BaseClient');
|
||||
const { getModelMaxTokens } = require('../../../utils');
|
||||
|
||||
class FakeClient extends BaseClient {
|
||||
constructor(apiKey, options = {}) {
|
||||
|
||||
@@ -71,10 +71,9 @@ const primeFiles = async (options) => {
|
||||
* @param {ServerRequest} options.req
|
||||
* @param {Array<{ file_id: string; filename: string }>} options.files
|
||||
* @param {string} [options.entity_id]
|
||||
* @param {boolean} [options.fileCitations=false] - Whether to include citation instructions
|
||||
* @returns
|
||||
*/
|
||||
const createFileSearchTool = async ({ req, files, entity_id, fileCitations = false }) => {
|
||||
const createFileSearchTool = async ({ req, files, entity_id }) => {
|
||||
return tool(
|
||||
async ({ query }) => {
|
||||
if (files.length === 0) {
|
||||
@@ -143,9 +142,9 @@ const createFileSearchTool = async ({ req, files, entity_id, fileCitations = fal
|
||||
const formattedString = formattedResults
|
||||
.map(
|
||||
(result, index) =>
|
||||
`File: ${result.filename}${
|
||||
fileCitations ? `\nAnchor: \\ue202turn0file${index} (${result.filename})` : ''
|
||||
}\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nContent: ${result.content}\n`,
|
||||
`File: ${result.filename}\nAnchor: \\ue202turn0file${index} (${result.filename})\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nContent: ${
|
||||
result.content
|
||||
}\n`,
|
||||
)
|
||||
.join('\n---\n');
|
||||
|
||||
@@ -159,14 +158,12 @@ const createFileSearchTool = async ({ req, files, entity_id, fileCitations = fal
|
||||
pageRelevance: result.page ? { [result.page]: 1.0 - result.distance } : {},
|
||||
}));
|
||||
|
||||
return [formattedString, { [Tools.file_search]: { sources, fileCitations } }];
|
||||
return [formattedString, { [Tools.file_search]: { sources } }];
|
||||
},
|
||||
{
|
||||
name: Tools.file_search,
|
||||
responseFormat: 'content_and_artifact',
|
||||
description: `Performs semantic search across attached "${Tools.file_search}" documents using natural language queries. This tool analyzes the content of uploaded files to find relevant information, quotes, and passages that best match your query. Use this to extract specific information or find relevant sections within the available documents.${
|
||||
fileCitations
|
||||
? `
|
||||
description: `Performs semantic search across attached "${Tools.file_search}" documents using natural language queries. This tool analyzes the content of uploaded files to find relevant information, quotes, and passages that best match your query. Use this to extract specific information or find relevant sections within the available documents.
|
||||
|
||||
**CITE FILE SEARCH RESULTS:**
|
||||
Use anchor markers immediately after statements derived from file content. Reference the filename in your text:
|
||||
@@ -174,9 +171,7 @@ Use anchor markers immediately after statements derived from file content. Refer
|
||||
- Page reference: "According to report.docx... \\ue202turn0file1"
|
||||
- Multi-file: "Multiple sources confirm... \\ue200\\ue202turn0file0\\ue202turn0file1\\ue201"
|
||||
|
||||
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`
|
||||
: ''
|
||||
}`,
|
||||
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`,
|
||||
schema: z.object({
|
||||
query: z
|
||||
.string()
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { SerpAPI } = require('@langchain/community/tools/serpapi');
|
||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||
const { mcpToolPattern, loadWebSearchAuth, checkAccess } = require('@librechat/api');
|
||||
const { mcpToolPattern, loadWebSearchAuth } = require('@librechat/api');
|
||||
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
|
||||
const {
|
||||
Tools,
|
||||
Constants,
|
||||
Permissions,
|
||||
EToolResources,
|
||||
PermissionTypes,
|
||||
replaceSpecialVars,
|
||||
} = require('librechat-data-provider');
|
||||
const { Tools, Constants, EToolResources, replaceSpecialVars } = require('librechat-data-provider');
|
||||
const {
|
||||
availableTools,
|
||||
manifestToolMap,
|
||||
@@ -34,7 +27,6 @@ const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||
const { createMCPTool, createMCPTools } = require('~/server/services/MCP');
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
/**
|
||||
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
|
||||
@@ -289,29 +281,7 @@ const loadTools = async ({
|
||||
if (toolContext) {
|
||||
toolContextMap[tool] = toolContext;
|
||||
}
|
||||
|
||||
/** @type {boolean | undefined} Check if user has FILE_CITATIONS permission */
|
||||
let fileCitations;
|
||||
if (fileCitations == null && options.req?.user != null) {
|
||||
try {
|
||||
fileCitations = await checkAccess({
|
||||
user: options.req.user,
|
||||
permissionType: PermissionTypes.FILE_CITATIONS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[handleTools] FILE_CITATIONS permission check failed:', error);
|
||||
fileCitations = false;
|
||||
}
|
||||
}
|
||||
|
||||
return createFileSearchTool({
|
||||
req: options.req,
|
||||
files,
|
||||
entity_id: agent?.id,
|
||||
fileCitations,
|
||||
});
|
||||
return createFileSearchTool({ req: options.req, files, entity_id: agent?.id });
|
||||
};
|
||||
continue;
|
||||
} else if (tool === Tools.web_search) {
|
||||
|
||||
@@ -189,15 +189,11 @@ async function createAutoRefillTransaction(txData) {
|
||||
* @param {txData} _txData - Transaction data.
|
||||
*/
|
||||
async function createTransaction(_txData) {
|
||||
const { balance, transactions, ...txData } = _txData;
|
||||
const { balance, ...txData } = _txData;
|
||||
if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (transactions?.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = new Transaction(txData);
|
||||
transaction.endpointTokenConfig = txData.endpointTokenConfig;
|
||||
calculateTokenValue(transaction);
|
||||
@@ -226,11 +222,7 @@ async function createTransaction(_txData) {
|
||||
* @param {txData} _txData - Transaction data.
|
||||
*/
|
||||
async function createStructuredTransaction(_txData) {
|
||||
const { balance, transactions, ...txData } = _txData;
|
||||
if (transactions?.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { balance, ...txData } = _txData;
|
||||
const transaction = new Transaction({
|
||||
...txData,
|
||||
endpointTokenConfig: txData.endpointTokenConfig,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
|
||||
|
||||
const { getMultiplier, getCacheMultiplier } = require('./tx');
|
||||
const { createTransaction, createStructuredTransaction } = require('./Transaction');
|
||||
const { Balance, Transaction } = require('~/db/models');
|
||||
const { createTransaction } = require('./Transaction');
|
||||
const { Balance } = require('~/db/models');
|
||||
|
||||
let mongoServer;
|
||||
beforeAll(async () => {
|
||||
@@ -379,188 +380,3 @@ describe('NaN Handling Tests', () => {
|
||||
expect(balance.tokenCredits).toBe(initialBalance);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transactions Config Tests', () => {
|
||||
test('createTransaction should not save when transactions.enabled is false', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
rawAmount: -100,
|
||||
tokenType: 'prompt',
|
||||
transactions: { enabled: false },
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createTransaction(txData);
|
||||
|
||||
// Assert: No transaction should be created
|
||||
expect(result).toBeUndefined();
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(0);
|
||||
const balance = await Balance.findOne({ user: userId });
|
||||
expect(balance.tokenCredits).toBe(initialBalance);
|
||||
});
|
||||
|
||||
test('createTransaction should save when transactions.enabled is true', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
rawAmount: -100,
|
||||
tokenType: 'prompt',
|
||||
transactions: { enabled: true },
|
||||
balance: { enabled: true },
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createTransaction(txData);
|
||||
|
||||
// Assert: Transaction should be created
|
||||
expect(result).toBeDefined();
|
||||
expect(result.balance).toBeLessThan(initialBalance);
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(1);
|
||||
expect(transactions[0].rawAmount).toBe(-100);
|
||||
});
|
||||
|
||||
test('createTransaction should save when balance.enabled is true even if transactions config is missing', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
rawAmount: -100,
|
||||
tokenType: 'prompt',
|
||||
balance: { enabled: true },
|
||||
// No transactions config provided
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createTransaction(txData);
|
||||
|
||||
// Assert: Transaction should be created (backward compatibility)
|
||||
expect(result).toBeDefined();
|
||||
expect(result.balance).toBeLessThan(initialBalance);
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('createTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
rawAmount: -100,
|
||||
tokenType: 'prompt',
|
||||
transactions: { enabled: true },
|
||||
balance: { enabled: false },
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createTransaction(txData);
|
||||
|
||||
// Assert: Transaction should be created but balance unchanged
|
||||
expect(result).toBeUndefined();
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(1);
|
||||
expect(transactions[0].rawAmount).toBe(-100);
|
||||
const balance = await Balance.findOne({ user: userId });
|
||||
expect(balance.tokenCredits).toBe(initialBalance);
|
||||
});
|
||||
|
||||
test('createStructuredTransaction should not save when transactions.enabled is false', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'claude-3-5-sonnet';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'message',
|
||||
tokenType: 'prompt',
|
||||
inputTokens: -10,
|
||||
writeTokens: -100,
|
||||
readTokens: -5,
|
||||
transactions: { enabled: false },
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createStructuredTransaction(txData);
|
||||
|
||||
// Assert: No transaction should be created
|
||||
expect(result).toBeUndefined();
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(0);
|
||||
const balance = await Balance.findOne({ user: userId });
|
||||
expect(balance.tokenCredits).toBe(initialBalance);
|
||||
});
|
||||
|
||||
test('createStructuredTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'claude-3-5-sonnet';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'message',
|
||||
tokenType: 'prompt',
|
||||
inputTokens: -10,
|
||||
writeTokens: -100,
|
||||
readTokens: -5,
|
||||
transactions: { enabled: true },
|
||||
balance: { enabled: false },
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createStructuredTransaction(txData);
|
||||
|
||||
// Assert: Transaction should be created but balance unchanged
|
||||
expect(result).toBeUndefined();
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(1);
|
||||
expect(transactions[0].inputTokens).toBe(-10);
|
||||
expect(transactions[0].writeTokens).toBe(-100);
|
||||
expect(transactions[0].readTokens).toBe(-5);
|
||||
const balance = await Balance.findOne({ user: userId });
|
||||
expect(balance.tokenCredits).toBe(initialBalance);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { matchModelName } = require('@librechat/api');
|
||||
const { matchModelName } = require('../utils/tokens');
|
||||
const defaultRate = 6;
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,12 +44,12 @@
|
||||
"@googleapis/youtube": "^20.0.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/community": "^0.3.47",
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@langchain/core": "^0.3.72",
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/openai": "^0.5.18",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.76",
|
||||
"@librechat/agents": "^3.0.0-rc10",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
|
||||
@@ -75,7 +75,7 @@ const refreshController = async (req, res) => {
|
||||
if (!user) {
|
||||
return res.status(401).redirect('/login');
|
||||
}
|
||||
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString());
|
||||
const token = setOpenIDAuthTokens(tokenset, res);
|
||||
return res.status(200).send({ token, user });
|
||||
} catch (error) {
|
||||
logger.error('[refreshController] OpenID token refresh error', error);
|
||||
|
||||
@@ -95,6 +95,19 @@ class ModelEndHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Agent Chain helper
|
||||
* @param {string | undefined} [last_agent_id]
|
||||
* @param {string | undefined} [langgraph_node]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function checkIfLastAgent(last_agent_id, langgraph_node) {
|
||||
if (!last_agent_id || !langgraph_node) {
|
||||
return false;
|
||||
}
|
||||
return langgraph_node?.endsWith(last_agent_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default handlers for stream events.
|
||||
* @param {Object} options - The options object.
|
||||
@@ -125,7 +138,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||
handle: (event, data, metadata) => {
|
||||
if (data?.stepDetails.type === StepTypes.TOOL_CALLS) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
@@ -154,7 +167,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||
handle: (event, data, metadata) => {
|
||||
if (data?.delta.type === StepTypes.TOOL_CALLS) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
@@ -172,7 +185,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||
handle: (event, data, metadata) => {
|
||||
if (data?.result != null) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
@@ -188,7 +201,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
|
||||
*/
|
||||
handle: (event, data, metadata) => {
|
||||
if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
@@ -204,7 +217,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
|
||||
*/
|
||||
handle: (event, data, metadata) => {
|
||||
if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
|
||||
@@ -3,22 +3,17 @@ const { logger } = require('@librechat/data-schemas');
|
||||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
||||
const {
|
||||
sendEvent,
|
||||
createRun,
|
||||
Tokenizer,
|
||||
checkAccess,
|
||||
logAxiosError,
|
||||
resolveHeaders,
|
||||
getBalanceConfig,
|
||||
memoryInstructions,
|
||||
formatContentStrings,
|
||||
getTransactionsConfig,
|
||||
createMemoryProcessor,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Callback,
|
||||
Providers,
|
||||
GraphEvents,
|
||||
TitleMethod,
|
||||
formatMessage,
|
||||
formatAgentMessages,
|
||||
@@ -37,12 +32,12 @@ const {
|
||||
bedrockInputSchema,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
|
||||
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||
const { createContextHandlers } = require('~/app/clients/prompts');
|
||||
const { checkCapability } = require('~/server/services/Config');
|
||||
const BaseClient = require('~/app/clients/BaseClient');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
@@ -79,8 +74,6 @@ const payloadParser = ({ req, agent, endpoint }) => {
|
||||
return req.body.endpointOption.model_parameters;
|
||||
};
|
||||
|
||||
const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
||||
|
||||
function createTokenCounter(encoding) {
|
||||
return function (message) {
|
||||
const countTokens = (text) => Tokenizer.getTokenCount(text, encoding);
|
||||
@@ -89,10 +82,11 @@ function createTokenCounter(encoding) {
|
||||
}
|
||||
|
||||
function logToolError(graph, error, toolId) {
|
||||
logAxiosError({
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #chatCompletion] Tool Error',
|
||||
error,
|
||||
message: `[api/server/controllers/agents/client.js #chatCompletion] Tool Error "${toolId}"`,
|
||||
});
|
||||
toolId,
|
||||
);
|
||||
}
|
||||
|
||||
class AgentClient extends BaseClient {
|
||||
@@ -624,13 +618,11 @@ class AgentClient extends BaseClient {
|
||||
* @param {string} [params.model]
|
||||
* @param {string} [params.context='message']
|
||||
* @param {AppConfig['balance']} [params.balance]
|
||||
* @param {AppConfig['transactions']} [params.transactions]
|
||||
* @param {UsageMetadata[]} [params.collectedUsage=this.collectedUsage]
|
||||
*/
|
||||
async recordCollectedUsage({
|
||||
model,
|
||||
balance,
|
||||
transactions,
|
||||
context = 'message',
|
||||
collectedUsage = this.collectedUsage,
|
||||
}) {
|
||||
@@ -656,7 +648,6 @@ class AgentClient extends BaseClient {
|
||||
const txMetadata = {
|
||||
context,
|
||||
balance,
|
||||
transactions,
|
||||
conversationId: this.conversationId,
|
||||
user: this.user ?? this.options.req.user?.id,
|
||||
endpointTokenConfig: this.options.endpointTokenConfig,
|
||||
@@ -805,138 +796,81 @@ class AgentClient extends BaseClient {
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Agent} agent
|
||||
* @param {BaseMessage[]} messages
|
||||
* @param {number} [i]
|
||||
* @param {TMessageContentParts[]} [contentData]
|
||||
* @param {Record<string, number>} [currentIndexCountMap]
|
||||
*/
|
||||
const runAgent = async (agent, _messages, i = 0, contentData = [], _currentIndexCountMap) => {
|
||||
config.configurable.model = agent.model_parameters.model;
|
||||
const currentIndexCountMap = _currentIndexCountMap ?? indexTokenCountMap;
|
||||
if (i > 0) {
|
||||
this.model = agent.model_parameters.model;
|
||||
const runAgents = async (messages) => {
|
||||
const agents = [this.options.agent];
|
||||
if (
|
||||
this.agentConfigs &&
|
||||
this.agentConfigs.size > 0 &&
|
||||
((this.options.agent.edges?.length ?? 0) > 0 ||
|
||||
(await checkCapability(this.options.req, AgentCapabilities.chain)))
|
||||
) {
|
||||
agents.push(...this.agentConfigs.values());
|
||||
}
|
||||
if (i > 0 && config.signal == null) {
|
||||
config.signal = abortController.signal;
|
||||
}
|
||||
if (agent.recursion_limit && typeof agent.recursion_limit === 'number') {
|
||||
config.recursionLimit = agent.recursion_limit;
|
||||
|
||||
if (agents[0].recursion_limit && typeof agents[0].recursion_limit === 'number') {
|
||||
config.recursionLimit = agents[0].recursion_limit;
|
||||
}
|
||||
|
||||
if (
|
||||
agentsEConfig?.maxRecursionLimit &&
|
||||
config.recursionLimit > agentsEConfig?.maxRecursionLimit
|
||||
) {
|
||||
config.recursionLimit = agentsEConfig?.maxRecursionLimit;
|
||||
}
|
||||
config.configurable.agent_id = agent.id;
|
||||
config.configurable.name = agent.name;
|
||||
config.configurable.agent_index = i;
|
||||
const noSystemMessages = noSystemModelRegex.some((regex) =>
|
||||
agent.model_parameters.model.match(regex),
|
||||
);
|
||||
|
||||
const systemMessage = Object.values(agent.toolContextMap ?? {})
|
||||
.join('\n')
|
||||
.trim();
|
||||
// TODO: needs to be added as part of AgentContext initialization
|
||||
// const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
||||
// const noSystemMessages = noSystemModelRegex.some((regex) =>
|
||||
// agent.model_parameters.model.match(regex),
|
||||
// );
|
||||
// if (noSystemMessages === true && systemContent?.length) {
|
||||
// const latestMessageContent = _messages.pop().content;
|
||||
// if (typeof latestMessageContent !== 'string') {
|
||||
// latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
|
||||
// _messages.push(new HumanMessage({ content: latestMessageContent }));
|
||||
// } else {
|
||||
// const text = [systemContent, latestMessageContent].join('\n');
|
||||
// _messages.push(new HumanMessage(text));
|
||||
// }
|
||||
// }
|
||||
// let messages = _messages;
|
||||
// if (agent.useLegacyContent === true) {
|
||||
// messages = formatContentStrings(messages);
|
||||
// }
|
||||
// if (
|
||||
// agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes(
|
||||
// 'prompt-caching',
|
||||
// )
|
||||
// ) {
|
||||
// messages = addCacheControl(messages);
|
||||
// }
|
||||
|
||||
let systemContent = [
|
||||
systemMessage,
|
||||
agent.instructions ?? '',
|
||||
i !== 0 ? (agent.additional_instructions ?? '') : '',
|
||||
]
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
if (noSystemMessages === true) {
|
||||
agent.instructions = undefined;
|
||||
agent.additional_instructions = undefined;
|
||||
} else {
|
||||
agent.instructions = systemContent;
|
||||
agent.additional_instructions = undefined;
|
||||
}
|
||||
|
||||
if (noSystemMessages === true && systemContent?.length) {
|
||||
const latestMessageContent = _messages.pop().content;
|
||||
if (typeof latestMessageContent !== 'string') {
|
||||
latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
|
||||
_messages.push(new HumanMessage({ content: latestMessageContent }));
|
||||
} else {
|
||||
const text = [systemContent, latestMessageContent].join('\n');
|
||||
_messages.push(new HumanMessage(text));
|
||||
}
|
||||
}
|
||||
|
||||
let messages = _messages;
|
||||
if (agent.useLegacyContent === true) {
|
||||
messages = formatContentStrings(messages);
|
||||
}
|
||||
if (
|
||||
agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes(
|
||||
'prompt-caching',
|
||||
)
|
||||
) {
|
||||
messages = addCacheControl(messages);
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
memoryPromise = this.runMemory(messages);
|
||||
}
|
||||
|
||||
/** Resolve request-based headers for Custom Endpoints. Note: if this is added to
|
||||
* non-custom endpoints, needs consideration of varying provider header configs.
|
||||
*/
|
||||
if (agent.model_parameters?.configuration?.defaultHeaders != null) {
|
||||
agent.model_parameters.configuration.defaultHeaders = resolveHeaders({
|
||||
headers: agent.model_parameters.configuration.defaultHeaders,
|
||||
body: config.configurable.requestBody,
|
||||
});
|
||||
}
|
||||
memoryPromise = this.runMemory(messages);
|
||||
|
||||
run = await createRun({
|
||||
agent,
|
||||
req: this.options.req,
|
||||
agents,
|
||||
indexTokenCountMap,
|
||||
runId: this.responseMessageId,
|
||||
signal: abortController.signal,
|
||||
customHandlers: this.options.eventHandlers,
|
||||
requestBody: config.configurable.requestBody,
|
||||
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
throw new Error('Failed to create run');
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
this.run = run;
|
||||
}
|
||||
|
||||
if (contentData.length) {
|
||||
const agentUpdate = {
|
||||
type: ContentTypes.AGENT_UPDATE,
|
||||
[ContentTypes.AGENT_UPDATE]: {
|
||||
index: contentData.length,
|
||||
runId: this.responseMessageId,
|
||||
agentId: agent.id,
|
||||
},
|
||||
};
|
||||
const streamData = {
|
||||
event: GraphEvents.ON_AGENT_UPDATE,
|
||||
data: agentUpdate,
|
||||
};
|
||||
this.options.aggregateContent(streamData);
|
||||
sendEvent(this.options.res, streamData);
|
||||
contentData.push(agentUpdate);
|
||||
run.Graph.contentData = contentData;
|
||||
}
|
||||
|
||||
this.run = run;
|
||||
if (userMCPAuthMap != null) {
|
||||
config.configurable.userMCPAuthMap = userMCPAuthMap;
|
||||
}
|
||||
|
||||
/** @deprecated Agent Chain */
|
||||
config.configurable.last_agent_id = agents[agents.length - 1].id;
|
||||
await run.processStream({ messages }, config, {
|
||||
keepContent: i !== 0,
|
||||
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||
indexTokenCountMap: currentIndexCountMap,
|
||||
maxContextTokens: agent.maxContextTokens,
|
||||
callbacks: {
|
||||
[Callback.TOOL_ERROR]: logToolError,
|
||||
},
|
||||
@@ -945,109 +879,22 @@ class AgentClient extends BaseClient {
|
||||
config.signal = null;
|
||||
};
|
||||
|
||||
await runAgent(this.options.agent, initialMessages);
|
||||
let finalContentStart = 0;
|
||||
if (
|
||||
this.agentConfigs &&
|
||||
this.agentConfigs.size > 0 &&
|
||||
(await checkCapability(this.options.req, AgentCapabilities.chain))
|
||||
) {
|
||||
const windowSize = 5;
|
||||
let latestMessage = initialMessages.pop().content;
|
||||
if (typeof latestMessage !== 'string') {
|
||||
latestMessage = latestMessage[0].text;
|
||||
}
|
||||
let i = 1;
|
||||
let runMessages = [];
|
||||
|
||||
const windowIndexCountMap = {};
|
||||
const windowMessages = initialMessages.slice(-windowSize);
|
||||
let currentIndex = 4;
|
||||
for (let i = initialMessages.length - 1; i >= 0; i--) {
|
||||
windowIndexCountMap[currentIndex] = indexTokenCountMap[i];
|
||||
currentIndex--;
|
||||
if (currentIndex < 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const encoding = this.getEncoding();
|
||||
const tokenCounter = createTokenCounter(encoding);
|
||||
for (const [agentId, agent] of this.agentConfigs) {
|
||||
if (abortController.signal.aborted === true) {
|
||||
break;
|
||||
}
|
||||
const currentRun = await run;
|
||||
|
||||
if (
|
||||
i === this.agentConfigs.size &&
|
||||
config.configurable.hide_sequential_outputs === true
|
||||
) {
|
||||
const content = this.contentParts.filter(
|
||||
(part) => part.type === ContentTypes.TOOL_CALL,
|
||||
);
|
||||
|
||||
this.options.res.write(
|
||||
`event: message\ndata: ${JSON.stringify({
|
||||
event: 'on_content_update',
|
||||
data: {
|
||||
runId: this.responseMessageId,
|
||||
content,
|
||||
},
|
||||
})}\n\n`,
|
||||
);
|
||||
}
|
||||
const _runMessages = currentRun.Graph.getRunMessages();
|
||||
finalContentStart = this.contentParts.length;
|
||||
runMessages = runMessages.concat(_runMessages);
|
||||
const contentData = currentRun.Graph.contentData.slice();
|
||||
const bufferString = getBufferString([new HumanMessage(latestMessage), ...runMessages]);
|
||||
if (i === this.agentConfigs.size) {
|
||||
logger.debug(`SEQUENTIAL AGENTS: Last buffer string:\n${bufferString}`);
|
||||
}
|
||||
try {
|
||||
const contextMessages = [];
|
||||
const runIndexCountMap = {};
|
||||
for (let i = 0; i < windowMessages.length; i++) {
|
||||
const message = windowMessages[i];
|
||||
const messageType = message._getType();
|
||||
if (
|
||||
(!agent.tools || agent.tools.length === 0) &&
|
||||
(messageType === 'tool' || (message.tool_calls?.length ?? 0) > 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
runIndexCountMap[contextMessages.length] = windowIndexCountMap[i];
|
||||
contextMessages.push(message);
|
||||
}
|
||||
const bufferMessage = new HumanMessage(bufferString);
|
||||
runIndexCountMap[contextMessages.length] = tokenCounter(bufferMessage);
|
||||
const currentMessages = [...contextMessages, bufferMessage];
|
||||
await runAgent(agent, currentMessages, i, contentData, runIndexCountMap);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[api/server/controllers/agents/client.js #chatCompletion] Error running agent ${agentId} (${i})`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
await runAgents(initialMessages);
|
||||
/** @deprecated Agent Chain */
|
||||
if (config.configurable.hide_sequential_outputs) {
|
||||
this.contentParts = this.contentParts.filter((part, index) => {
|
||||
// Include parts that are either:
|
||||
// 1. At or after the finalContentStart index
|
||||
// 2. Of type tool_call
|
||||
// 3. Have tool_call_ids property
|
||||
return (
|
||||
index >= this.contentParts.length - 1 ||
|
||||
part.type === ContentTypes.TOOL_CALL ||
|
||||
part.tool_call_ids
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Note: not implemented */
|
||||
if (config.configurable.hide_sequential_outputs !== true) {
|
||||
finalContentStart = 0;
|
||||
}
|
||||
|
||||
this.contentParts = this.contentParts.filter((part, index) => {
|
||||
// Include parts that are either:
|
||||
// 1. At or after the finalContentStart index
|
||||
// 2. Of type tool_call
|
||||
// 3. Have tool_call_ids property
|
||||
return (
|
||||
index >= finalContentStart || part.type === ContentTypes.TOOL_CALL || part.tool_call_ids
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
@@ -1055,12 +902,7 @@ class AgentClient extends BaseClient {
|
||||
}
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const transactionsConfig = getTransactionsConfig(appConfig);
|
||||
await this.recordCollectedUsage({
|
||||
context: 'message',
|
||||
balance: balanceConfig,
|
||||
transactions: transactionsConfig,
|
||||
});
|
||||
await this.recordCollectedUsage({ context: 'message', balance: balanceConfig });
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage',
|
||||
@@ -1254,13 +1096,11 @@ class AgentClient extends BaseClient {
|
||||
});
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const transactionsConfig = getTransactionsConfig(appConfig);
|
||||
await this.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
context: 'title',
|
||||
model: clientOptions.model,
|
||||
balance: balanceConfig,
|
||||
transactions: transactionsConfig,
|
||||
}).catch((err) => {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #titleConvo] Error recording collected usage',
|
||||
|
||||
@@ -237,9 +237,6 @@ describe('AgentClient - titleConvo', () => {
|
||||
balance: {
|
||||
enabled: false,
|
||||
},
|
||||
transactions: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { v4 } = require('uuid');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
|
||||
const { sendEvent, getBalanceConfig } = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
Constants,
|
||||
@@ -34,6 +34,7 @@ const { checkBalance } = require('~/models/balanceMethods');
|
||||
const { getConvo } = require('~/models/Conversation');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { countTokens } = require('~/server/utils');
|
||||
const { getModelMaxTokens } = require('~/utils');
|
||||
const { getOpenAIClient } = require('./helpers');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { v4 } = require('uuid');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
|
||||
const { sendEvent, getBalanceConfig } = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
Constants,
|
||||
@@ -31,6 +31,7 @@ const { checkBalance } = require('~/models/balanceMethods');
|
||||
const { getConvo } = require('~/models/Conversation');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { countTokens } = require('~/server/utils');
|
||||
const { getModelMaxTokens } = require('~/utils');
|
||||
const { getOpenAIClient } = require('./helpers');
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ const { logger } = require('@librechat/data-schemas');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const { isEnabled, ErrorController } = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
const createValidateImageRequest = require('./middleware/validateImageRequest');
|
||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
||||
const { updateInterfacePermissions } = require('~/models/interface');
|
||||
const { checkMigrations } = require('./services/start/migration');
|
||||
@@ -126,7 +126,7 @@ const startServer = async () => {
|
||||
app.use('/api/config', routes.config);
|
||||
app.use('/api/assistants', routes.assistants);
|
||||
app.use('/api/files', await routes.files.initialize());
|
||||
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
|
||||
app.use('/images/', validateImageRequest, routes.staticRoute);
|
||||
app.use('/api/share', routes.share);
|
||||
app.use('/api/roles', routes.roles);
|
||||
app.use('/api/agents', routes.agents);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const validatePasswordReset = require('./validatePasswordReset');
|
||||
const validateRegistration = require('./validateRegistration');
|
||||
const validateImageRequest = require('./validateImageRequest');
|
||||
const buildEndpointOption = require('./buildEndpointOption');
|
||||
const validateMessageReq = require('./validateMessageReq');
|
||||
const checkDomainAllowed = require('./checkDomainAllowed');
|
||||
@@ -49,5 +50,6 @@ module.exports = {
|
||||
validateMessageReq,
|
||||
buildEndpointOption,
|
||||
validateRegistration,
|
||||
validateImageRequest,
|
||||
validatePasswordReset,
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
|
||||
const validateImageRequest = require('~/server/middleware/validateImageRequest');
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
isEnabled: jest.fn(),
|
||||
jest.mock('~/server/services/Config/app', () => ({
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('validateImageRequest middleware', () => {
|
||||
let req, res, next, validateImageRequest;
|
||||
let req, res, next;
|
||||
const validObjectId = '65cfb246f7ecadb8b1e8036b';
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -22,278 +22,116 @@ describe('validateImageRequest middleware', () => {
|
||||
};
|
||||
next = jest.fn();
|
||||
process.env.JWT_REFRESH_SECRET = 'test-secret';
|
||||
process.env.OPENID_REUSE_TOKENS = 'false';
|
||||
|
||||
// Default: OpenID token reuse disabled
|
||||
isEnabled.mockReturnValue(false);
|
||||
// Mock getAppConfig to return secureImageLinks: true by default
|
||||
getAppConfig.mockResolvedValue({
|
||||
secureImageLinks: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Factory function', () => {
|
||||
test('should return a pass-through middleware if secureImageLinks is false', async () => {
|
||||
const middleware = createValidateImageRequest(false);
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return validation middleware if secureImageLinks is true', async () => {
|
||||
validateImageRequest = createValidateImageRequest(true);
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
||||
test('should call next() if secureImageLinks is false', async () => {
|
||||
getAppConfig.mockResolvedValue({
|
||||
secureImageLinks: false,
|
||||
});
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Standard LibreChat token flow', () => {
|
||||
beforeEach(() => {
|
||||
validateImageRequest = createValidateImageRequest(true);
|
||||
});
|
||||
|
||||
test('should return 401 if refresh token is not provided', async () => {
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
||||
});
|
||||
|
||||
test('should return 403 if refresh token is invalid', async () => {
|
||||
req.headers.cookie = 'refreshToken=invalid-token';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should return 403 if refresh token is expired', async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${expiredToken}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should call next() for valid image path', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/example.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 403 for invalid image path', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should allow agent avatar pattern for any valid ObjectId', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should prevent file traversal attempts', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
|
||||
const traversalAttempts = [
|
||||
`/images/${validObjectId}/../../../etc/passwd`,
|
||||
`/images/${validObjectId}/..%2F..%2F..%2Fetc%2Fpasswd`,
|
||||
`/images/${validObjectId}/image.jpg/../../../etc/passwd`,
|
||||
`/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`,
|
||||
];
|
||||
|
||||
for (const attempt of traversalAttempts) {
|
||||
req.originalUrl = attempt;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
jest.clearAllMocks();
|
||||
// Reset mocks for next iteration
|
||||
res.status = jest.fn().mockReturnThis();
|
||||
res.send = jest.fn();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle URL encoded characters in valid paths', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
test('should return 401 if refresh token is not provided', async () => {
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
||||
});
|
||||
|
||||
describe('OpenID token flow', () => {
|
||||
beforeEach(() => {
|
||||
validateImageRequest = createValidateImageRequest(true);
|
||||
// Enable OpenID token reuse
|
||||
isEnabled.mockReturnValue(true);
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
});
|
||||
|
||||
test('should return 403 if no OpenID user ID cookie when token_provider is openid', async () => {
|
||||
req.headers.cookie = 'refreshToken=dummy-token; token_provider=openid';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should validate JWT-signed user ID for OpenID flow', async () => {
|
||||
const signedUserId = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
|
||||
req.originalUrl = `/images/${validObjectId}/example.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 403 for invalid JWT-signed user ID', async () => {
|
||||
req.headers.cookie =
|
||||
'refreshToken=dummy-token; token_provider=openid; openid_user_id=invalid-jwt';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should return 403 for expired JWT-signed user ID', async () => {
|
||||
const expiredSignedUserId = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${expiredSignedUserId}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should validate image path against JWT-signed user ID', async () => {
|
||||
const signedUserId = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
const differentObjectId = '65cfb246f7ecadb8b1e8036c';
|
||||
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
|
||||
req.originalUrl = `/images/${differentObjectId}/example.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should allow agent avatars in OpenID flow', async () => {
|
||||
const signedUserId = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
test('should return 403 if refresh token is invalid', async () => {
|
||||
req.headers.cookie = 'refreshToken=invalid-token';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
describe('Security edge cases', () => {
|
||||
let validToken;
|
||||
test('should return 403 if refresh token is expired', async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${expiredToken}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
validateImageRequest = createValidateImageRequest(true);
|
||||
validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
});
|
||||
test('should call next() for valid image path', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/example.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle very long image filenames', async () => {
|
||||
const longFilename = 'a'.repeat(1000) + '.jpg';
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/${longFilename}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
test('should return 403 for invalid image path', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should handle URLs with maximum practical length', async () => {
|
||||
// Most browsers support URLs up to ~2000 characters
|
||||
const longFilename = 'x'.repeat(1900) + '.jpg';
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/${longFilename}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
test('should return 403 for invalid ObjectId format', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/123/example.jpg'; // Invalid ObjectId
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should accept URLs just under the 2048 limit', async () => {
|
||||
// Create a URL exactly 2047 characters long
|
||||
const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
|
||||
const filenameLength = 2047 - baseLength;
|
||||
const filename = 'a'.repeat(filenameLength) + '.jpg';
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/${filename}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
// File traversal tests
|
||||
test('should prevent file traversal attempts', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
|
||||
test('should handle malformed URL encoding gracefully', async () => {
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/test%ZZinvalid.jpg`;
|
||||
const traversalAttempts = [
|
||||
`/images/${validObjectId}/../../../etc/passwd`,
|
||||
`/images/${validObjectId}/..%2F..%2F..%2Fetc%2Fpasswd`,
|
||||
`/images/${validObjectId}/image.jpg/../../../etc/passwd`,
|
||||
`/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`,
|
||||
];
|
||||
|
||||
for (const attempt of traversalAttempts) {
|
||||
req.originalUrl = attempt;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test('should reject URLs with null bytes', async () => {
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/test\x00.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should handle URLs with repeated slashes', async () => {
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}//test.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should reject extremely long URLs as potential DoS', async () => {
|
||||
// Create a URL longer than 2048 characters
|
||||
const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
|
||||
const filenameLength = 2049 - baseLength; // Ensure total length exceeds 2048
|
||||
const extremelyLongFilename = 'x'.repeat(filenameLength) + '.jpg';
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/${extremelyLongFilename}`;
|
||||
// Verify our test URL is actually too long
|
||||
expect(req.originalUrl.length).toBeGreaterThan(2048);
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
test('should handle URL encoded characters in valid paths', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const cookies = require('cookie');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
|
||||
const OBJECT_ID_LENGTH = 24;
|
||||
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
|
||||
@@ -22,129 +22,50 @@ function isValidObjectId(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a LibreChat refresh token
|
||||
* @param {string} refreshToken - The refresh token to validate
|
||||
* @returns {{valid: boolean, userId?: string, error?: string}} - Validation result
|
||||
* Middleware to validate image request.
|
||||
* Must be set by `secureImageLinks` via custom config file.
|
||||
*/
|
||||
function validateToken(refreshToken) {
|
||||
async function validateImageRequest(req, res, next) {
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
if (!appConfig.secureImageLinks) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
|
||||
if (!refreshToken) {
|
||||
logger.warn('[validateImageRequest] Refresh token not provided');
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
|
||||
if (!isValidObjectId(payload.id)) {
|
||||
return { valid: false, error: 'Invalid User ID' };
|
||||
}
|
||||
|
||||
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < currentTimeInSeconds) {
|
||||
return { valid: false, error: 'Refresh token expired' };
|
||||
}
|
||||
|
||||
return { valid: true, userId: payload.id };
|
||||
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
} catch (err) {
|
||||
logger.warn('[validateToken]', err);
|
||||
return { valid: false, error: 'Invalid token' };
|
||||
logger.warn('[validateImageRequest]', err);
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
if (!isValidObjectId(payload.id)) {
|
||||
logger.warn('[validateImageRequest] Invalid User ID');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < currentTimeInSeconds) {
|
||||
logger.warn('[validateImageRequest] Refresh token expired');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const fullPath = decodeURIComponent(req.originalUrl);
|
||||
const pathPattern = new RegExp(`^/images/${payload.id}/[^/]+$`);
|
||||
|
||||
if (pathPattern.test(fullPath)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
next();
|
||||
} else {
|
||||
logger.warn('[validateImageRequest] Invalid image path');
|
||||
res.status(403).send('Access Denied');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create the `validateImageRequest` middleware with configured secureImageLinks
|
||||
* @param {boolean} [secureImageLinks] - Whether secure image links are enabled
|
||||
*/
|
||||
function createValidateImageRequest(secureImageLinks) {
|
||||
if (!secureImageLinks) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
/**
|
||||
* Middleware to validate image request.
|
||||
* Supports both LibreChat refresh tokens and OpenID JWT tokens.
|
||||
* Must be set by `secureImageLinks` via custom config file.
|
||||
*/
|
||||
return async function validateImageRequest(req, res, next) {
|
||||
try {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (!cookieHeader) {
|
||||
logger.warn('[validateImageRequest] No cookies provided');
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
const parsedCookies = cookies.parse(cookieHeader);
|
||||
const refreshToken = parsedCookies.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
logger.warn('[validateImageRequest] Token not provided');
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
const tokenProvider = parsedCookies.token_provider;
|
||||
let userIdForPath;
|
||||
|
||||
if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
const openidUserId = parsedCookies.openid_user_id;
|
||||
if (!openidUserId) {
|
||||
logger.warn('[validateImageRequest] No OpenID user ID cookie found');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const validationResult = validateToken(openidUserId);
|
||||
if (!validationResult.valid) {
|
||||
logger.warn(`[validateImageRequest] ${validationResult.error}`);
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
userIdForPath = validationResult.userId;
|
||||
} else {
|
||||
const validationResult = validateToken(refreshToken);
|
||||
if (!validationResult.valid) {
|
||||
logger.warn(`[validateImageRequest] ${validationResult.error}`);
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
userIdForPath = validationResult.userId;
|
||||
}
|
||||
|
||||
if (!userIdForPath) {
|
||||
logger.warn('[validateImageRequest] No user ID available for path validation');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
if (req.originalUrl.length > MAX_URL_LENGTH) {
|
||||
logger.warn('[validateImageRequest] URL too long');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
if (req.originalUrl.includes('\x00')) {
|
||||
logger.warn('[validateImageRequest] URL contains null byte');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
let fullPath;
|
||||
try {
|
||||
fullPath = decodeURIComponent(req.originalUrl);
|
||||
} catch {
|
||||
logger.warn('[validateImageRequest] Invalid URL encoding');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const agentAvatarPattern = /^\/images\/[a-f0-9]{24}\/agent-[^/]*$/;
|
||||
if (agentAvatarPattern.test(fullPath)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
return next();
|
||||
}
|
||||
|
||||
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`);
|
||||
|
||||
if (pathPattern.test(fullPath)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
next();
|
||||
} else {
|
||||
logger.warn('[validateImageRequest] Invalid image path');
|
||||
res.status(403).send('Access Denied');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[validateImageRequest] Error:', error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = createValidateImageRequest;
|
||||
module.exports = validateImageRequest;
|
||||
|
||||
@@ -122,6 +122,7 @@ router.get('/', async function (req, res) {
|
||||
payload.minPasswordLength = minPasswordLength;
|
||||
}
|
||||
|
||||
payload.mcpServers = {};
|
||||
const getMCPServers = () => {
|
||||
try {
|
||||
if (appConfig?.mcpConfig == null) {
|
||||
@@ -135,9 +136,6 @@ router.get('/', async function (req, res) {
|
||||
if (!mcpServers) return;
|
||||
const oauthServers = mcpManager.getOAuthServers();
|
||||
for (const serverName in mcpServers) {
|
||||
if (!payload.mcpServers) {
|
||||
payload.mcpServers = {};
|
||||
}
|
||||
const serverConfig = mcpServers[serverName];
|
||||
payload.mcpServers[serverName] = removeNullishValues({
|
||||
startup: serverConfig?.startup,
|
||||
|
||||
@@ -39,7 +39,7 @@ const oauthHandler = async (req, res) => {
|
||||
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
|
||||
) {
|
||||
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
|
||||
setOpenIDAuthTokens(req.user.tokenset, res, req.user._id.toString());
|
||||
setOpenIDAuthTokens(req.user.tokenset, res);
|
||||
} else {
|
||||
await setAuthTokens(req.user._id, res);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ const AppService = async () => {
|
||||
enabled: isEnabled(process.env.CHECK_BALANCE),
|
||||
startBalance: startBalance ? parseInt(startBalance, 10) : undefined,
|
||||
};
|
||||
const transactions = config.transactions ?? configDefaults.transactions;
|
||||
const imageOutputType = config?.imageOutputType ?? configDefaults.imageOutputType;
|
||||
|
||||
process.env.CDN_PROVIDER = fileStrategy;
|
||||
@@ -85,7 +84,6 @@ const AppService = async () => {
|
||||
memory,
|
||||
speech,
|
||||
balance,
|
||||
transactions,
|
||||
mcpConfig,
|
||||
webSearch,
|
||||
fileStrategy,
|
||||
|
||||
@@ -402,10 +402,9 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
|
||||
* @param {import('openid-client').TokenEndpointResponse & import('openid-client').TokenEndpointResponseHelpers} tokenset
|
||||
* - The tokenset object containing access and refresh tokens
|
||||
* @param {Object} res - response object
|
||||
* @param {string} [userId] - Optional MongoDB user ID for image path validation
|
||||
* @returns {String} - access token
|
||||
*/
|
||||
const setOpenIDAuthTokens = (tokenset, res, userId) => {
|
||||
const setOpenIDAuthTokens = (tokenset, res) => {
|
||||
try {
|
||||
if (!tokenset) {
|
||||
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
||||
@@ -436,18 +435,6 @@ const setOpenIDAuthTokens = (tokenset, res, userId) => {
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
if (userId && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
/** JWT-signed user ID cookie for image path validation when OPENID_REUSE_TOKENS is enabled */
|
||||
const signedUserId = jwt.sign({ id: userId }, process.env.JWT_REFRESH_SECRET, {
|
||||
expiresIn: expiryInMilliseconds / 1000,
|
||||
});
|
||||
res.cookie('openid_user_id', signedUserId, {
|
||||
expires: expirationDate,
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
}
|
||||
return tokenset.access_token;
|
||||
} catch (error) {
|
||||
logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error);
|
||||
@@ -465,7 +452,7 @@ const setOpenIDAuthTokens = (tokenset, res, userId) => {
|
||||
const resendVerificationEmail = async (req) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
await deleteTokens({ email });
|
||||
await deleteTokens(email);
|
||||
const user = await findUser({ email }, 'email _id name');
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const { Providers } = require('@librechat/agents');
|
||||
const {
|
||||
primeResources,
|
||||
getModelMaxTokens,
|
||||
extractLibreChatParams,
|
||||
optionalChainWithEmptyCheck,
|
||||
} = require('@librechat/api');
|
||||
@@ -18,6 +17,7 @@ const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||
const { processFiles } = require('~/server/services/Files/process');
|
||||
const { getFiles, getToolFilesByIds } = require('~/models/File');
|
||||
const { getConvoFiles } = require('~/models/Conversation');
|
||||
const { getModelMaxTokens } = require('~/utils');
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { createContentAggregator } = require('@librechat/agents');
|
||||
const { validateAgentModel, getCustomEndpointConfig } = require('@librechat/api');
|
||||
const {
|
||||
validateAgentModel,
|
||||
getCustomEndpointConfig,
|
||||
createSequentialChainEdges,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
@@ -119,44 +123,90 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
||||
|
||||
const agent_ids = primaryConfig.agent_ids;
|
||||
let userMCPAuthMap = primaryConfig.userMCPAuthMap;
|
||||
if (agent_ids?.length) {
|
||||
for (const agentId of agent_ids) {
|
||||
const agent = await getAgent({ id: agentId });
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
|
||||
async function processAgent(agentId) {
|
||||
const agent = await getAgent({ id: agentId });
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
}
|
||||
|
||||
const config = await initializeAgent({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
requestFiles,
|
||||
conversationId,
|
||||
endpointOption,
|
||||
allowedProviders,
|
||||
});
|
||||
if (userMCPAuthMap != null) {
|
||||
Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {});
|
||||
} else {
|
||||
userMCPAuthMap = config.userMCPAuthMap;
|
||||
}
|
||||
agentConfigs.set(agentId, config);
|
||||
}
|
||||
|
||||
let edges = primaryConfig.edges;
|
||||
const checkAgentInit = (agentId) => agentId === primaryConfig.id || agentConfigs.has(agentId);
|
||||
if ((edges?.length ?? 0) > 0) {
|
||||
for (const edge of edges) {
|
||||
if (Array.isArray(edge.to)) {
|
||||
for (const to of edge.to) {
|
||||
if (checkAgentInit(to)) {
|
||||
continue;
|
||||
}
|
||||
await processAgent(to);
|
||||
}
|
||||
} else if (typeof edge.to === 'string' && checkAgentInit(edge.to)) {
|
||||
continue;
|
||||
} else if (typeof edge.to === 'string') {
|
||||
await processAgent(edge.to);
|
||||
}
|
||||
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
if (Array.isArray(edge.from)) {
|
||||
for (const from of edge.from) {
|
||||
if (checkAgentInit(from)) {
|
||||
continue;
|
||||
}
|
||||
await processAgent(from);
|
||||
}
|
||||
} else if (typeof edge.from === 'string' && checkAgentInit(edge.from)) {
|
||||
continue;
|
||||
} else if (typeof edge.from === 'string') {
|
||||
await processAgent(edge.from);
|
||||
}
|
||||
|
||||
const config = await initializeAgent({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
requestFiles,
|
||||
conversationId,
|
||||
endpointOption,
|
||||
allowedProviders,
|
||||
});
|
||||
if (userMCPAuthMap != null) {
|
||||
Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {});
|
||||
} else {
|
||||
userMCPAuthMap = config.userMCPAuthMap;
|
||||
}
|
||||
agentConfigs.set(agentId, config);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Agent Chain */
|
||||
if (agent_ids?.length) {
|
||||
for (const agentId of agent_ids) {
|
||||
if (checkAgentInit(agentId)) {
|
||||
continue;
|
||||
}
|
||||
await processAgent(agentId);
|
||||
}
|
||||
|
||||
const chain = await createSequentialChainEdges([primaryConfig.id].concat(agent_ids), '{convo}');
|
||||
edges = edges ? edges.concat(chain) : chain;
|
||||
}
|
||||
|
||||
primaryConfig.edges = edges;
|
||||
|
||||
let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint];
|
||||
if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) {
|
||||
try {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { AnthropicClientOptions } from '@librechat/agents';
|
||||
import { EModelEndpoint, anthropicSettings } from 'librechat-data-provider';
|
||||
import { matchModelName } from '~/utils/tokens';
|
||||
const { EModelEndpoint, anthropicSettings } = require('librechat-data-provider');
|
||||
const { matchModelName } = require('~/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* @param {string} modelName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function checkPromptCacheSupport(modelName: string): boolean {
|
||||
const modelMatch = matchModelName(modelName, EModelEndpoint.anthropic) ?? '';
|
||||
function checkPromptCacheSupport(modelName) {
|
||||
const modelMatch = matchModelName(modelName, EModelEndpoint.anthropic);
|
||||
if (
|
||||
modelMatch.includes('claude-3-5-sonnet-latest') ||
|
||||
modelMatch.includes('claude-3.5-sonnet-latest')
|
||||
@@ -32,10 +31,7 @@ function checkPromptCacheSupport(modelName: string): boolean {
|
||||
* @param {boolean} supportsCacheControl Whether the model supports cache control
|
||||
* @returns {AnthropicClientOptions['extendedOptions']['defaultHeaders']|undefined} The headers object or undefined if not applicable
|
||||
*/
|
||||
function getClaudeHeaders(
|
||||
model: string,
|
||||
supportsCacheControl: boolean,
|
||||
): Record<string, string> | undefined {
|
||||
function getClaudeHeaders(model, supportsCacheControl) {
|
||||
if (!supportsCacheControl) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -76,13 +72,9 @@ function getClaudeHeaders(
|
||||
* @param {number|null} extendedOptions.thinkingBudget The token budget for thinking
|
||||
* @returns {Object} Updated request options
|
||||
*/
|
||||
function configureReasoning(
|
||||
anthropicInput: AnthropicClientOptions & { max_tokens?: number },
|
||||
extendedOptions: { thinking?: boolean; thinkingBudget?: number | null } = {},
|
||||
): AnthropicClientOptions & { max_tokens?: number } {
|
||||
function configureReasoning(anthropicInput, extendedOptions = {}) {
|
||||
const updatedOptions = { ...anthropicInput };
|
||||
const currentMaxTokens = updatedOptions.max_tokens ?? updatedOptions.maxTokens;
|
||||
|
||||
if (
|
||||
extendedOptions.thinking &&
|
||||
updatedOptions?.model &&
|
||||
@@ -90,16 +82,11 @@ function configureReasoning(
|
||||
/claude-(?:sonnet|opus|haiku)-[4-9]/.test(updatedOptions.model))
|
||||
) {
|
||||
updatedOptions.thinking = {
|
||||
...updatedOptions.thinking,
|
||||
type: 'enabled',
|
||||
} as { type: 'enabled'; budget_tokens: number };
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
updatedOptions.thinking != null &&
|
||||
extendedOptions.thinkingBudget != null &&
|
||||
updatedOptions.thinking.type === 'enabled'
|
||||
) {
|
||||
if (updatedOptions.thinking != null && extendedOptions.thinkingBudget != null) {
|
||||
updatedOptions.thinking = {
|
||||
...updatedOptions.thinking,
|
||||
budget_tokens: extendedOptions.thinkingBudget,
|
||||
@@ -108,10 +95,9 @@ function configureReasoning(
|
||||
|
||||
if (
|
||||
updatedOptions.thinking != null &&
|
||||
updatedOptions.thinking.type === 'enabled' &&
|
||||
(currentMaxTokens == null || updatedOptions.thinking.budget_tokens > currentMaxTokens)
|
||||
) {
|
||||
const maxTokens = anthropicSettings.maxOutputTokens.reset(updatedOptions.model ?? '');
|
||||
const maxTokens = anthropicSettings.maxOutputTokens.reset(updatedOptions.model);
|
||||
updatedOptions.max_tokens = currentMaxTokens ?? maxTokens;
|
||||
|
||||
logger.warn(
|
||||
@@ -129,4 +115,4 @@ function configureReasoning(
|
||||
return updatedOptions;
|
||||
}
|
||||
|
||||
export { checkPromptCacheSupport, getClaudeHeaders, configureReasoning };
|
||||
module.exports = { checkPromptCacheSupport, getClaudeHeaders, configureReasoning };
|
||||
@@ -1,6 +1,6 @@
|
||||
const { getLLMConfig } = require('@librechat/api');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
|
||||
const { getLLMConfig } = require('~/server/services/Endpoints/anthropic/llm');
|
||||
const AnthropicClient = require('~/app/clients/AnthropicClient');
|
||||
|
||||
const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => {
|
||||
@@ -27,13 +27,13 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
|
||||
const anthropicConfig = appConfig.endpoints?.[EModelEndpoint.anthropic];
|
||||
|
||||
if (anthropicConfig) {
|
||||
clientOptions.streamRate = anthropicConfig.streamRate;
|
||||
clientOptions._lc_stream_delay = anthropicConfig.streamRate;
|
||||
clientOptions.titleModel = anthropicConfig.titleModel;
|
||||
}
|
||||
|
||||
const allConfig = appConfig.endpoints?.all;
|
||||
if (allConfig) {
|
||||
clientOptions.streamRate = allConfig.streamRate;
|
||||
clientOptions._lc_stream_delay = allConfig.streamRate;
|
||||
}
|
||||
|
||||
if (optionsOnly) {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { ProxyAgent } from 'undici';
|
||||
import { AnthropicClientOptions } from '@librechat/agents';
|
||||
import { anthropicSettings, removeNullishValues } from 'librechat-data-provider';
|
||||
import type {
|
||||
AnthropicConfigOptions,
|
||||
AnthropicLLMConfigResult,
|
||||
AnthropicParameters,
|
||||
} from '~/types/anthropic';
|
||||
import { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } from './helpers';
|
||||
const { ProxyAgent } = require('undici');
|
||||
const { anthropicSettings, removeNullishValues } = require('librechat-data-provider');
|
||||
const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = require('./helpers');
|
||||
|
||||
/**
|
||||
* Generates configuration options for creating an Anthropic language model (LLM) instance.
|
||||
@@ -27,42 +21,25 @@ import { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } from '.
|
||||
*
|
||||
* @returns {Object} Configuration options for creating an Anthropic LLM instance, with null and undefined values removed.
|
||||
*/
|
||||
function getLLMConfig(
|
||||
apiKey?: string,
|
||||
options: AnthropicConfigOptions = {} as AnthropicConfigOptions,
|
||||
): AnthropicLLMConfigResult {
|
||||
function getLLMConfig(apiKey, options = {}) {
|
||||
const systemOptions = {
|
||||
thinking: options.modelOptions?.thinking ?? anthropicSettings.thinking.default,
|
||||
promptCache: options.modelOptions?.promptCache ?? anthropicSettings.promptCache.default,
|
||||
thinkingBudget:
|
||||
options.modelOptions?.thinkingBudget ?? anthropicSettings.thinkingBudget.default,
|
||||
thinking: options.modelOptions.thinking ?? anthropicSettings.thinking.default,
|
||||
promptCache: options.modelOptions.promptCache ?? anthropicSettings.promptCache.default,
|
||||
thinkingBudget: options.modelOptions.thinkingBudget ?? anthropicSettings.thinkingBudget.default,
|
||||
};
|
||||
|
||||
/** Couldn't figure out a way to still loop through the object while deleting the overlapping keys when porting this
|
||||
* over from javascript, so for now they are being deleted manually until a better way presents itself.
|
||||
*/
|
||||
if (options.modelOptions) {
|
||||
delete options.modelOptions.thinking;
|
||||
delete options.modelOptions.promptCache;
|
||||
delete options.modelOptions.thinkingBudget;
|
||||
} else {
|
||||
throw new Error('No modelOptions provided');
|
||||
for (let key in systemOptions) {
|
||||
delete options.modelOptions[key];
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
model: anthropicSettings.model.default,
|
||||
maxOutputTokens: anthropicSettings.maxOutputTokens.default,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
const mergedOptions = Object.assign(
|
||||
defaultOptions,
|
||||
options.modelOptions,
|
||||
) as typeof defaultOptions &
|
||||
Partial<AnthropicParameters> & { stop?: string[]; web_search?: boolean };
|
||||
const mergedOptions = Object.assign(defaultOptions, options.modelOptions);
|
||||
|
||||
/** @type {AnthropicClientOptions} */
|
||||
let requestOptions: AnthropicClientOptions & { stream?: boolean } = {
|
||||
let requestOptions = {
|
||||
apiKey,
|
||||
model: mergedOptions.model,
|
||||
stream: mergedOptions.stream,
|
||||
@@ -89,20 +66,20 @@ function getLLMConfig(
|
||||
}
|
||||
|
||||
const supportsCacheControl =
|
||||
systemOptions.promptCache === true && checkPromptCacheSupport(requestOptions.model ?? '');
|
||||
const headers = getClaudeHeaders(requestOptions.model ?? '', supportsCacheControl);
|
||||
if (headers && requestOptions.clientOptions) {
|
||||
systemOptions.promptCache === true && checkPromptCacheSupport(requestOptions.model);
|
||||
const headers = getClaudeHeaders(requestOptions.model, supportsCacheControl);
|
||||
if (headers) {
|
||||
requestOptions.clientOptions.defaultHeaders = headers;
|
||||
}
|
||||
|
||||
if (options.proxy && requestOptions.clientOptions) {
|
||||
if (options.proxy) {
|
||||
const proxyAgent = new ProxyAgent(options.proxy);
|
||||
requestOptions.clientOptions.fetchOptions = {
|
||||
dispatcher: proxyAgent,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.reverseProxyUrl && requestOptions.clientOptions) {
|
||||
if (options.reverseProxyUrl) {
|
||||
requestOptions.clientOptions.baseURL = options.reverseProxyUrl;
|
||||
requestOptions.anthropicApiUrl = options.reverseProxyUrl;
|
||||
}
|
||||
@@ -119,10 +96,8 @@ function getLLMConfig(
|
||||
return {
|
||||
tools,
|
||||
/** @type {AnthropicClientOptions} */
|
||||
llmConfig: removeNullishValues(
|
||||
requestOptions as Record<string, unknown>,
|
||||
) as AnthropicClientOptions,
|
||||
llmConfig: removeNullishValues(requestOptions),
|
||||
};
|
||||
}
|
||||
|
||||
export { getLLMConfig };
|
||||
module.exports = { getLLMConfig };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getLLMConfig } from './llm';
|
||||
const { getLLMConfig } = require('~/server/services/Endpoints/anthropic/llm');
|
||||
|
||||
jest.mock('https-proxy-agent', () => ({
|
||||
HttpsProxyAgent: jest.fn().mockImplementation((proxy) => ({ proxy })),
|
||||
@@ -25,9 +25,9 @@ describe('getLLMConfig', () => {
|
||||
});
|
||||
|
||||
expect(result.llmConfig.clientOptions).toHaveProperty('fetchOptions');
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions).toHaveProperty('dispatcher');
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions?.dispatcher).toBeDefined();
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions?.dispatcher.constructor.name).toBe(
|
||||
expect(result.llmConfig.clientOptions.fetchOptions).toHaveProperty('dispatcher');
|
||||
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher).toBeDefined();
|
||||
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher.constructor.name).toBe(
|
||||
'ProxyAgent',
|
||||
);
|
||||
});
|
||||
@@ -93,10 +93,9 @@ describe('getLLMConfig', () => {
|
||||
};
|
||||
const result = getLLMConfig('test-key', { modelOptions });
|
||||
const clientOptions = result.llmConfig.clientOptions;
|
||||
expect(clientOptions?.defaultHeaders).toBeDefined();
|
||||
expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
const defaultHeaders = clientOptions?.defaultHeaders as Record<string, string>;
|
||||
expect(defaultHeaders['anthropic-beta']).toBe(
|
||||
expect(clientOptions.defaultHeaders).toBeDefined();
|
||||
expect(clientOptions.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
expect(clientOptions.defaultHeaders['anthropic-beta']).toBe(
|
||||
'prompt-caching-2024-07-31,context-1m-2025-08-07',
|
||||
);
|
||||
});
|
||||
@@ -112,10 +111,9 @@ describe('getLLMConfig', () => {
|
||||
const modelOptions = { model, promptCache: true };
|
||||
const result = getLLMConfig('test-key', { modelOptions });
|
||||
const clientOptions = result.llmConfig.clientOptions;
|
||||
expect(clientOptions?.defaultHeaders).toBeDefined();
|
||||
expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
const defaultHeaders = clientOptions?.defaultHeaders as Record<string, string>;
|
||||
expect(defaultHeaders['anthropic-beta']).toBe(
|
||||
expect(clientOptions.defaultHeaders).toBeDefined();
|
||||
expect(clientOptions.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
expect(clientOptions.defaultHeaders['anthropic-beta']).toBe(
|
||||
'prompt-caching-2024-07-31,context-1m-2025-08-07',
|
||||
);
|
||||
});
|
||||
@@ -213,13 +211,13 @@ describe('getLLMConfig', () => {
|
||||
it('should handle empty modelOptions', () => {
|
||||
expect(() => {
|
||||
getLLMConfig('test-api-key', {});
|
||||
}).toThrow('No modelOptions provided');
|
||||
}).toThrow("Cannot read properties of undefined (reading 'thinking')");
|
||||
});
|
||||
|
||||
it('should handle no options parameter', () => {
|
||||
expect(() => {
|
||||
getLLMConfig('test-api-key');
|
||||
}).toThrow('No modelOptions provided');
|
||||
}).toThrow("Cannot read properties of undefined (reading 'thinking')");
|
||||
});
|
||||
|
||||
it('should handle temperature, stop sequences, and stream settings', () => {
|
||||
@@ -256,9 +254,9 @@ describe('getLLMConfig', () => {
|
||||
});
|
||||
|
||||
expect(result.llmConfig.clientOptions).toHaveProperty('fetchOptions');
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions).toHaveProperty('dispatcher');
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions?.dispatcher).toBeDefined();
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions?.dispatcher.constructor.name).toBe(
|
||||
expect(result.llmConfig.clientOptions.fetchOptions).toHaveProperty('dispatcher');
|
||||
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher).toBeDefined();
|
||||
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher.constructor.name).toBe(
|
||||
'ProxyAgent',
|
||||
);
|
||||
expect(result.llmConfig.clientOptions).toHaveProperty('baseURL', 'https://reverse-proxy.com');
|
||||
@@ -274,7 +272,7 @@ describe('getLLMConfig', () => {
|
||||
});
|
||||
|
||||
// claude-3-5-sonnet supports prompt caching and should get the appropriate headers
|
||||
expect(result.llmConfig.clientOptions?.defaultHeaders).toEqual({
|
||||
expect(result.llmConfig.clientOptions.defaultHeaders).toEqual({
|
||||
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31',
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
const { getModelMaxTokens } = require('@librechat/api');
|
||||
const { createContentAggregator } = require('@librechat/agents');
|
||||
const {
|
||||
EModelEndpoint,
|
||||
@@ -8,6 +7,7 @@ const {
|
||||
const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks');
|
||||
const getOptions = require('~/server/services/Endpoints/bedrock/options');
|
||||
const AgentClient = require('~/server/controllers/agents/client');
|
||||
const { getModelMaxTokens } = require('~/utils');
|
||||
|
||||
const initializeClient = async ({ req, res, endpointOption }) => {
|
||||
if (!endpointOption) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { createHandleLLMNewToken } = require('@librechat/api');
|
||||
const {
|
||||
AuthType,
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
bedrockInputParser,
|
||||
bedrockOutputParser,
|
||||
@@ -11,7 +9,6 @@ const {
|
||||
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
|
||||
|
||||
const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
||||
const appConfig = req.config;
|
||||
const {
|
||||
BEDROCK_AWS_SECRET_ACCESS_KEY,
|
||||
BEDROCK_AWS_ACCESS_KEY_ID,
|
||||
@@ -47,10 +44,12 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
||||
checkUserKeyExpiry(expiresAt, EModelEndpoint.bedrock);
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
/*
|
||||
Callback for stream rate no longer awaits and may end the stream prematurely
|
||||
/** @type {number}
|
||||
let streamRate = Constants.DEFAULT_STREAM_RATE;
|
||||
|
||||
/** @type {undefined | TBaseEndpoint} */
|
||||
/** @type {undefined | TBaseEndpoint}
|
||||
const bedrockConfig = appConfig.endpoints?.[EModelEndpoint.bedrock];
|
||||
|
||||
if (bedrockConfig && bedrockConfig.streamRate) {
|
||||
@@ -61,6 +60,7 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
||||
if (allConfig && allConfig.streamRate) {
|
||||
streamRate = allConfig.streamRate;
|
||||
}
|
||||
*/
|
||||
|
||||
/** @type {BedrockClientOptions} */
|
||||
const requestOptions = {
|
||||
@@ -88,12 +88,6 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
||||
llmConfig.endpointHost = BEDROCK_REVERSE_PROXY;
|
||||
}
|
||||
|
||||
llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(streamRate),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
/** @type {BedrockClientOptions} */
|
||||
llmConfig,
|
||||
|
||||
@@ -4,7 +4,6 @@ const {
|
||||
isUserProvided,
|
||||
getOpenAIConfig,
|
||||
getCustomEndpointConfig,
|
||||
createHandleLLMNewToken,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
CacheKeys,
|
||||
@@ -159,11 +158,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||
if (!clientOptions.streamRate) {
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
|
||||
},
|
||||
];
|
||||
options.llmConfig._lc_stream_delay = clientOptions.streamRate;
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
resolveHeaders: jest.fn(),
|
||||
getOpenAIConfig: jest.fn(),
|
||||
createHandleLLMNewToken: jest.fn(),
|
||||
getCustomEndpointConfig: jest.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://test.com',
|
||||
|
||||
@@ -5,7 +5,6 @@ const {
|
||||
isUserProvided,
|
||||
getOpenAIConfig,
|
||||
getAzureCredentials,
|
||||
createHandleLLMNewToken,
|
||||
} = require('@librechat/api');
|
||||
const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService');
|
||||
const OpenAIClient = require('~/app/clients/OpenAIClient');
|
||||
@@ -151,11 +150,7 @@ const initializeClient = async ({
|
||||
if (!streamRate) {
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(streamRate),
|
||||
},
|
||||
];
|
||||
options.llmConfig._lc_stream_delay = streamRate;
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@@ -159,11 +159,9 @@ class STTService {
|
||||
* Prepares the request for the OpenAI STT provider.
|
||||
* @param {Object} sttSchema - The STT schema for OpenAI.
|
||||
* @param {Stream} audioReadStream - The audio data to be transcribed.
|
||||
* @param {Object} audioFile - The audio file object (unused in OpenAI provider).
|
||||
* @param {string} language - The language code for the transcription.
|
||||
* @returns {Array} An array containing the URL, data, and headers for the request.
|
||||
*/
|
||||
openAIProvider(sttSchema, audioReadStream, audioFile, language) {
|
||||
openAIProvider(sttSchema, audioReadStream) {
|
||||
const url = sttSchema?.url || 'https://api.openai.com/v1/audio/transcriptions';
|
||||
const apiKey = extractEnvVariable(sttSchema.apiKey) || '';
|
||||
|
||||
@@ -172,12 +170,6 @@ class STTService {
|
||||
model: sttSchema.model,
|
||||
};
|
||||
|
||||
if (language) {
|
||||
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
|
||||
const isoLanguage = language.split('-')[0];
|
||||
data.language = isoLanguage;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...(apiKey && { Authorization: `Bearer ${apiKey}` }),
|
||||
@@ -192,11 +184,10 @@ class STTService {
|
||||
* @param {Object} sttSchema - The STT schema for Azure OpenAI.
|
||||
* @param {Buffer} audioBuffer - The audio data to be transcribed.
|
||||
* @param {Object} audioFile - The audio file object containing originalname, mimetype, and size.
|
||||
* @param {string} language - The language code for the transcription.
|
||||
* @returns {Array} An array containing the URL, data, and headers for the request.
|
||||
* @throws {Error} If the audio file size exceeds 25MB or the audio file format is not accepted.
|
||||
*/
|
||||
azureOpenAIProvider(sttSchema, audioBuffer, audioFile, language) {
|
||||
azureOpenAIProvider(sttSchema, audioBuffer, audioFile) {
|
||||
const url = `${genAzureEndpoint({
|
||||
azureOpenAIApiInstanceName: extractEnvVariable(sttSchema?.instanceName),
|
||||
azureOpenAIApiDeploymentName: extractEnvVariable(sttSchema?.deploymentName),
|
||||
@@ -220,12 +211,6 @@ class STTService {
|
||||
contentType: audioFile.mimetype,
|
||||
});
|
||||
|
||||
if (language) {
|
||||
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
|
||||
const isoLanguage = language.split('-')[0];
|
||||
formData.append('language', isoLanguage);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...(apiKey && { 'api-key': apiKey }),
|
||||
@@ -244,11 +229,10 @@ class STTService {
|
||||
* @param {Object} requestData - The data required for the STT request.
|
||||
* @param {Buffer} requestData.audioBuffer - The audio data to be transcribed.
|
||||
* @param {Object} requestData.audioFile - The audio file object containing originalname, mimetype, and size.
|
||||
* @param {string} requestData.language - The language code for the transcription.
|
||||
* @returns {Promise<string>} A promise that resolves to the transcribed text.
|
||||
* @throws {Error} If the provider is invalid, the response status is not 200, or the response data is missing.
|
||||
*/
|
||||
async sttRequest(provider, sttSchema, { audioBuffer, audioFile, language }) {
|
||||
async sttRequest(provider, sttSchema, { audioBuffer, audioFile }) {
|
||||
const strategy = this.providerStrategies[provider];
|
||||
if (!strategy) {
|
||||
throw new Error('Invalid provider');
|
||||
@@ -259,13 +243,7 @@ class STTService {
|
||||
const audioReadStream = Readable.from(audioBuffer);
|
||||
audioReadStream.path = `audio.${fileExtension}`;
|
||||
|
||||
const [url, data, headers] = strategy.call(
|
||||
this,
|
||||
sttSchema,
|
||||
audioReadStream,
|
||||
audioFile,
|
||||
language,
|
||||
);
|
||||
const [url, data, headers] = strategy.call(this, sttSchema, audioReadStream, audioFile);
|
||||
|
||||
try {
|
||||
const response = await axios.post(url, data, { headers });
|
||||
@@ -306,8 +284,7 @@ class STTService {
|
||||
|
||||
try {
|
||||
const [provider, sttSchema] = await this.getProviderSchema(req);
|
||||
const language = req.body?.language || '';
|
||||
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile, language });
|
||||
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile });
|
||||
res.json({ text });
|
||||
} catch (error) {
|
||||
logger.error('An error occurred while processing the audio:', error);
|
||||
|
||||
@@ -17,7 +17,7 @@ const { Files } = require('~/models');
|
||||
* @param {IUser} options.user - The user object
|
||||
* @param {AppConfig} options.appConfig - The app configuration object
|
||||
* @param {GraphRunnableConfig['configurable']} options.metadata - The metadata
|
||||
* @param {{ [Tools.file_search]: { sources: Object[]; fileCitations: boolean } }} options.toolArtifact - The tool artifact containing structured data
|
||||
* @param {any} options.toolArtifact - The tool artifact containing structured data
|
||||
* @param {string} options.toolCallId - The tool call ID
|
||||
* @returns {Promise<Object|null>} The file search attachment or null
|
||||
*/
|
||||
@@ -29,14 +29,12 @@ async function processFileCitations({ user, appConfig, toolArtifact, toolCallId,
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
const hasFileCitationsAccess =
|
||||
toolArtifact?.[Tools.file_search]?.fileCitations ??
|
||||
(await checkAccess({
|
||||
user,
|
||||
permissionType: PermissionTypes.FILE_CITATIONS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
}));
|
||||
const hasFileCitationsAccess = await checkAccess({
|
||||
user,
|
||||
permissionType: PermissionTypes.FILE_CITATIONS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
|
||||
if (!hasFileCitationsAccess) {
|
||||
logger.debug(
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
const axios = require('axios');
|
||||
const { Providers } = require('@librechat/agents');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { logAxiosError, inputSchema, processModelData } = require('@librechat/api');
|
||||
const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider');
|
||||
const { inputSchema, extractBaseURL, processModelData } = require('~/utils');
|
||||
const { OllamaClient } = require('~/app/clients/OllamaClient');
|
||||
const { isUserProvided } = require('~/server/utils');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { extractBaseURL } = require('~/utils');
|
||||
|
||||
/**
|
||||
* Splits a string by commas and trims each resulting value.
|
||||
|
||||
@@ -11,8 +11,8 @@ const {
|
||||
getAnthropicModels,
|
||||
} = require('./ModelService');
|
||||
|
||||
jest.mock('@librechat/api', () => {
|
||||
const originalUtils = jest.requireActual('@librechat/api');
|
||||
jest.mock('~/utils', () => {
|
||||
const originalUtils = jest.requireActual('~/utils');
|
||||
return {
|
||||
...originalUtils,
|
||||
processModelData: jest.fn((...args) => {
|
||||
@@ -108,7 +108,7 @@ describe('fetchModels with createTokenConfig true', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Clears the mock's history before each test
|
||||
const _utils = require('@librechat/api');
|
||||
const _utils = require('~/utils');
|
||||
axios.get.mockResolvedValue({ data });
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('fetchModels with createTokenConfig true', () => {
|
||||
createTokenConfig: true,
|
||||
});
|
||||
|
||||
const { processModelData } = require('@librechat/api');
|
||||
const { processModelData } = require('~/utils');
|
||||
expect(processModelData).toHaveBeenCalled();
|
||||
expect(processModelData).toHaveBeenCalledWith(data);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const mongoose = require('mongoose');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Strategy: AppleStrategy } = require('passport-apple');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { createSocialUser, handleExistingUser } = require('./process');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const socialLogin = require('./socialLogin');
|
||||
const { findUser } = require('~/models');
|
||||
const { User } = require('~/db/models');
|
||||
@@ -17,8 +17,6 @@ jest.mock('@librechat/data-schemas', () => {
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -26,19 +24,12 @@ jest.mock('./process', () => ({
|
||||
createSocialUser: jest.fn(),
|
||||
handleExistingUser: jest.fn(),
|
||||
}));
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
jest.mock('~/server/utils', () => ({
|
||||
isEnabled: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/models', () => ({
|
||||
findUser: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn().mockResolvedValue({
|
||||
fileStrategy: 'local',
|
||||
balance: { enabled: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Apple Login Strategy', () => {
|
||||
let mongoServer;
|
||||
@@ -297,14 +288,7 @@ describe('Apple Login Strategy', () => {
|
||||
|
||||
expect(mockVerifyCallback).toHaveBeenCalledWith(null, existingUser);
|
||||
expect(existingUser.avatarUrl).toBeNull(); // As per getProfileDetails
|
||||
expect(handleExistingUser).toHaveBeenCalledWith(
|
||||
existingUser,
|
||||
null,
|
||||
expect.objectContaining({
|
||||
fileStrategy: 'local',
|
||||
balance: { enabled: false },
|
||||
}),
|
||||
);
|
||||
expect(handleExistingUser).toHaveBeenCalledWith(existingUser, null);
|
||||
});
|
||||
|
||||
it('should handle missing idToken gracefully', async () => {
|
||||
|
||||
@@ -183,7 +183,7 @@ const getUserInfo = async (config, accessToken, sub) => {
|
||||
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
|
||||
return await client.fetchUserInfo(config, exchangedAccessToken, sub);
|
||||
} catch (error) {
|
||||
logger.error('[openidStrategy] getUserInfo: Error fetching user info:', error);
|
||||
logger.warn(`[openidStrategy] getUserInfo: Error fetching user info: ${error}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -398,7 +398,6 @@ async function setupOpenId() {
|
||||
);
|
||||
}
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
if (!user) {
|
||||
user = {
|
||||
provider: 'openid',
|
||||
@@ -410,6 +409,7 @@ async function setupOpenId() {
|
||||
idOnTheSource: userinfo.oid,
|
||||
};
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
user = await createUser(user, balanceConfig, true, true);
|
||||
} else {
|
||||
@@ -438,9 +438,7 @@ async function setupOpenId() {
|
||||
userinfo.sub,
|
||||
);
|
||||
if (imageBuffer) {
|
||||
const { saveBuffer } = getStrategyFunctions(
|
||||
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
|
||||
);
|
||||
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
|
||||
const imagePath = await saveBuffer({
|
||||
fileName,
|
||||
userId: user._id.toString(),
|
||||
|
||||
@@ -3,6 +3,7 @@ const { FileSources } = require('librechat-data-provider');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
const { updateUser, createUser, getUserById } = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
|
||||
/**
|
||||
* Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter
|
||||
@@ -11,15 +12,14 @@ const { updateUser, createUser, getUserById } = require('~/models');
|
||||
*
|
||||
* @param {IUser} oldUser - The existing user object that needs to be updated.
|
||||
* @param {string} avatarUrl - The new avatar URL to be set for the user.
|
||||
* @param {AppConfig} appConfig - The application configuration object.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
* The function updates the user's avatar and saves the user object. It does not return any value.
|
||||
*
|
||||
* @throws {Error} Throws an error if there's an issue saving the updated user object.
|
||||
*/
|
||||
const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
|
||||
const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
|
||||
const handleExistingUser = async (oldUser, avatarUrl) => {
|
||||
const fileStrategy = process.env.CDN_PROVIDER;
|
||||
const isLocal = fileStrategy === FileSources.local;
|
||||
|
||||
let updatedAvatar = false;
|
||||
@@ -56,7 +56,6 @@ const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
|
||||
* @param {string} params.providerId - The provider-specific ID of the user.
|
||||
* @param {string} params.username - The username of the new user.
|
||||
* @param {string} params.name - The name of the new user.
|
||||
* @param {AppConfig} appConfig - The application configuration object.
|
||||
* @param {boolean} [params.emailVerified=false] - Optional. Indicates whether the user's email is verified. Defaults to false.
|
||||
*
|
||||
* @returns {Promise<User>}
|
||||
@@ -72,7 +71,6 @@ const createSocialUser = async ({
|
||||
providerId,
|
||||
username,
|
||||
name,
|
||||
appConfig,
|
||||
emailVerified,
|
||||
}) => {
|
||||
const update = {
|
||||
@@ -85,9 +83,10 @@ const createSocialUser = async ({
|
||||
emailVerified,
|
||||
};
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const newUserId = await createUser(update, balanceConfig);
|
||||
const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
|
||||
const fileStrategy = process.env.CDN_PROVIDER;
|
||||
const isLocal = fileStrategy === FileSources.local;
|
||||
|
||||
if (!isLocal) {
|
||||
|
||||
@@ -220,7 +220,6 @@ async function setupSaml() {
|
||||
getUserName(profile) || getGivenName(profile) || getEmail(profile),
|
||||
);
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
if (!user) {
|
||||
user = {
|
||||
provider: 'saml',
|
||||
@@ -230,6 +229,7 @@ async function setupSaml() {
|
||||
emailVerified: true,
|
||||
name: fullName,
|
||||
};
|
||||
const appConfig = await getAppConfig();
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
user = await createUser(user, balanceConfig, true, true);
|
||||
} else {
|
||||
@@ -250,9 +250,7 @@ async function setupSaml() {
|
||||
fileName = profile.nameID + '.png';
|
||||
}
|
||||
|
||||
const { saveBuffer } = getStrategyFunctions(
|
||||
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
|
||||
);
|
||||
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
|
||||
const imagePath = await saveBuffer({
|
||||
fileName,
|
||||
userId: user._id.toString(),
|
||||
|
||||
@@ -2,7 +2,6 @@ const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { createSocialUser, handleExistingUser } = require('./process');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { findUser } = require('~/models');
|
||||
|
||||
const socialLogin =
|
||||
@@ -13,12 +12,11 @@ const socialLogin =
|
||||
profile,
|
||||
});
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
const existingUser = await findUser({ email: email.trim() });
|
||||
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
|
||||
|
||||
if (existingUser?.provider === provider) {
|
||||
await handleExistingUser(existingUser, avatarUrl, appConfig);
|
||||
await handleExistingUser(existingUser, avatarUrl);
|
||||
return cb(null, existingUser);
|
||||
} else if (existingUser) {
|
||||
logger.info(
|
||||
@@ -40,7 +38,6 @@ const socialLogin =
|
||||
username,
|
||||
name,
|
||||
emailVerified,
|
||||
appConfig,
|
||||
});
|
||||
return cb(null, newUser);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const axios = require('axios');
|
||||
const deriveBaseURL = require('./deriveBaseURL');
|
||||
jest.mock('@librechat/api', () => {
|
||||
const originalUtils = jest.requireActual('@librechat/api');
|
||||
jest.mock('~/utils', () => {
|
||||
const originalUtils = jest.requireActual('~/utils');
|
||||
return {
|
||||
...originalUtils,
|
||||
processModelData: jest.fn((...args) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const tokenHelpers = require('./tokens');
|
||||
const deriveBaseURL = require('./deriveBaseURL');
|
||||
const extractBaseURL = require('./extractBaseURL');
|
||||
const findMessageContent = require('./findMessageContent');
|
||||
@@ -5,5 +6,6 @@ const findMessageContent = require('./findMessageContent');
|
||||
module.exports = {
|
||||
deriveBaseURL,
|
||||
extractBaseURL,
|
||||
...tokenHelpers,
|
||||
findMessageContent,
|
||||
};
|
||||
|
||||
@@ -1,23 +1,5 @@
|
||||
import z from 'zod';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
|
||||
/** Configuration object mapping model keys to their respective prompt, completion rates, and context limit
|
||||
*
|
||||
* Note: the [key: string]: unknown is not in the original JSDoc typedef in /api/typedefs.js, but I've included it since
|
||||
* getModelMaxOutputTokens calls getModelTokenValue with a key of 'output', which was not in the original JSDoc typedef,
|
||||
* but would be referenced in a TokenConfig in the if(matchedPattern) portion of getModelTokenValue.
|
||||
* So in order to preserve functionality for that case and any others which might reference an additional key I'm unaware of,
|
||||
* I've included it here until the interface can be typed more tightly.
|
||||
*/
|
||||
export interface TokenConfig {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
context: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** An endpoint's config object mapping model keys to their respective prompt, completion rates, and context limit */
|
||||
export type EndpointTokenConfig = Record<string, TokenConfig>;
|
||||
const z = require('zod');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
|
||||
const openAIModels = {
|
||||
'o4-mini': 200000,
|
||||
@@ -260,7 +242,7 @@ const aggregateModels = {
|
||||
'gpt-oss-120b': 131000,
|
||||
};
|
||||
|
||||
export const maxTokensMap = {
|
||||
const maxTokensMap = {
|
||||
[EModelEndpoint.azureOpenAI]: openAIModels,
|
||||
[EModelEndpoint.openAI]: aggregateModels,
|
||||
[EModelEndpoint.agents]: aggregateModels,
|
||||
@@ -270,7 +252,7 @@ export const maxTokensMap = {
|
||||
[EModelEndpoint.bedrock]: bedrockModels,
|
||||
};
|
||||
|
||||
export const modelMaxOutputs = {
|
||||
const modelMaxOutputs = {
|
||||
o1: 32268, // -500 from max: 32,768
|
||||
'o1-mini': 65136, // -500 from max: 65,536
|
||||
'o1-preview': 32268, // -500 from max: 32,768
|
||||
@@ -279,7 +261,7 @@ export const modelMaxOutputs = {
|
||||
'gpt-5-nano': 128000,
|
||||
'gpt-oss-20b': 131000,
|
||||
'gpt-oss-120b': 131000,
|
||||
system_default: 32000,
|
||||
system_default: 1024,
|
||||
};
|
||||
|
||||
/** Outputs from https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-names */
|
||||
@@ -295,7 +277,7 @@ const anthropicMaxOutputs = {
|
||||
'claude-3-7-sonnet': 128000,
|
||||
};
|
||||
|
||||
export const maxOutputTokensMap = {
|
||||
const maxOutputTokensMap = {
|
||||
[EModelEndpoint.anthropic]: anthropicMaxOutputs,
|
||||
[EModelEndpoint.azureOpenAI]: modelMaxOutputs,
|
||||
[EModelEndpoint.openAI]: modelMaxOutputs,
|
||||
@@ -305,13 +287,10 @@ export const maxOutputTokensMap = {
|
||||
/**
|
||||
* Finds the first matching pattern in the tokens map.
|
||||
* @param {string} modelName
|
||||
* @param {Record<string, number> | EndpointTokenConfig} tokensMap
|
||||
* @param {Record<string, number>} tokensMap
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function findMatchingPattern(
|
||||
modelName: string,
|
||||
tokensMap: Record<string, number> | EndpointTokenConfig,
|
||||
): string | null {
|
||||
function findMatchingPattern(modelName, tokensMap) {
|
||||
const keys = Object.keys(tokensMap);
|
||||
for (let i = keys.length - 1; i >= 0; i--) {
|
||||
const modelKey = keys[i];
|
||||
@@ -326,79 +305,57 @@ export function findMatchingPattern(
|
||||
/**
|
||||
* Retrieves a token value for a given model name from a tokens map.
|
||||
*
|
||||
* @param modelName - The name of the model to look up.
|
||||
* @param tokensMap - The map of model names to token values.
|
||||
* @param [key='context'] - The key to look up in the tokens map.
|
||||
* @returns The token value for the given model or undefined if no match is found.
|
||||
* @param {string} modelName - The name of the model to look up.
|
||||
* @param {EndpointTokenConfig | Record<string, number>} tokensMap - The map of model names to token values.
|
||||
* @param {string} [key='context'] - The key to look up in the tokens map.
|
||||
* @returns {number|undefined} The token value for the given model or undefined if no match is found.
|
||||
*/
|
||||
export function getModelTokenValue(
|
||||
modelName: string,
|
||||
tokensMap?: EndpointTokenConfig | Record<string, number>,
|
||||
key = 'context' as keyof TokenConfig,
|
||||
): number | undefined {
|
||||
function getModelTokenValue(modelName, tokensMap, key = 'context') {
|
||||
if (typeof modelName !== 'string' || !tokensMap) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = tokensMap[modelName];
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
if (tokensMap[modelName]?.context) {
|
||||
return tokensMap[modelName].context;
|
||||
}
|
||||
|
||||
if (value?.context) {
|
||||
return value.context;
|
||||
if (tokensMap[modelName]) {
|
||||
return tokensMap[modelName];
|
||||
}
|
||||
|
||||
const matchedPattern = findMatchingPattern(modelName, tokensMap);
|
||||
|
||||
if (matchedPattern) {
|
||||
const result = tokensMap[matchedPattern];
|
||||
if (typeof result === 'number') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const tokenValue = result?.[key];
|
||||
if (typeof tokenValue === 'number') {
|
||||
return tokenValue;
|
||||
}
|
||||
return tokensMap.system_default as number | undefined;
|
||||
return result?.[key] ?? result ?? tokensMap.system_default;
|
||||
}
|
||||
|
||||
return tokensMap.system_default as number | undefined;
|
||||
return tokensMap.system_default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the maximum tokens for a given model name.
|
||||
*
|
||||
* @param modelName - The name of the model to look up.
|
||||
* @param endpoint - The endpoint (default is 'openAI').
|
||||
* @param [endpointTokenConfig] - Token Config for current endpoint to use for max tokens lookup
|
||||
* @returns The maximum tokens for the given model or undefined if no match is found.
|
||||
* @param {string} modelName - The name of the model to look up.
|
||||
* @param {string} endpoint - The endpoint (default is 'openAI').
|
||||
* @param {EndpointTokenConfig} [endpointTokenConfig] - Token Config for current endpoint to use for max tokens lookup
|
||||
* @returns {number|undefined} The maximum tokens for the given model or undefined if no match is found.
|
||||
*/
|
||||
export function getModelMaxTokens(
|
||||
modelName: string,
|
||||
endpoint = EModelEndpoint.openAI,
|
||||
endpointTokenConfig?: EndpointTokenConfig,
|
||||
): number | undefined {
|
||||
const tokensMap = endpointTokenConfig ?? maxTokensMap[endpoint as keyof typeof maxTokensMap];
|
||||
function getModelMaxTokens(modelName, endpoint = EModelEndpoint.openAI, endpointTokenConfig) {
|
||||
const tokensMap = endpointTokenConfig ?? maxTokensMap[endpoint];
|
||||
return getModelTokenValue(modelName, tokensMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the maximum output tokens for a given model name.
|
||||
*
|
||||
* @param modelName - The name of the model to look up.
|
||||
* @param endpoint - The endpoint (default is 'openAI').
|
||||
* @param [endpointTokenConfig] - Token Config for current endpoint to use for max tokens lookup
|
||||
* @returns The maximum output tokens for the given model or undefined if no match is found.
|
||||
* @param {string} modelName - The name of the model to look up.
|
||||
* @param {string} endpoint - The endpoint (default is 'openAI').
|
||||
* @param {EndpointTokenConfig} [endpointTokenConfig] - Token Config for current endpoint to use for max tokens lookup
|
||||
* @returns {number|undefined} The maximum output tokens for the given model or undefined if no match is found.
|
||||
*/
|
||||
export function getModelMaxOutputTokens(
|
||||
modelName: string,
|
||||
endpoint = EModelEndpoint.openAI,
|
||||
endpointTokenConfig?: EndpointTokenConfig,
|
||||
): number | undefined {
|
||||
const tokensMap =
|
||||
endpointTokenConfig ?? maxOutputTokensMap[endpoint as keyof typeof maxOutputTokensMap];
|
||||
function getModelMaxOutputTokens(modelName, endpoint = EModelEndpoint.openAI, endpointTokenConfig) {
|
||||
const tokensMap = endpointTokenConfig ?? maxOutputTokensMap[endpoint];
|
||||
return getModelTokenValue(modelName, tokensMap, 'output');
|
||||
}
|
||||
|
||||
@@ -406,24 +363,21 @@ export function getModelMaxOutputTokens(
|
||||
* Retrieves the model name key for a given model name input. If the exact model name isn't found,
|
||||
* it searches for partial matches within the model name, checking keys in reverse order.
|
||||
*
|
||||
* @param modelName - The name of the model to look up.
|
||||
* @param endpoint - The endpoint (default is 'openAI').
|
||||
* @returns The model name key for the given model; returns input if no match is found and is string.
|
||||
* @param {string} modelName - The name of the model to look up.
|
||||
* @param {string} endpoint - The endpoint (default is 'openAI').
|
||||
* @returns {string|undefined} The model name key for the given model; returns input if no match is found and is string.
|
||||
*
|
||||
* @example
|
||||
* matchModelName('gpt-4-32k-0613'); // Returns 'gpt-4-32k-0613'
|
||||
* matchModelName('gpt-4-32k-unknown'); // Returns 'gpt-4-32k'
|
||||
* matchModelName('unknown-model'); // Returns undefined
|
||||
*/
|
||||
export function matchModelName(
|
||||
modelName: string,
|
||||
endpoint = EModelEndpoint.openAI,
|
||||
): string | undefined {
|
||||
function matchModelName(modelName, endpoint = EModelEndpoint.openAI) {
|
||||
if (typeof modelName !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokensMap: Record<string, number> = maxTokensMap[endpoint as keyof typeof maxTokensMap];
|
||||
const tokensMap = maxTokensMap[endpoint];
|
||||
if (!tokensMap) {
|
||||
return modelName;
|
||||
}
|
||||
@@ -436,7 +390,7 @@ export function matchModelName(
|
||||
return matchedPattern || modelName;
|
||||
}
|
||||
|
||||
export const modelSchema = z.object({
|
||||
const modelSchema = z.object({
|
||||
id: z.string(),
|
||||
pricing: z.object({
|
||||
prompt: z.string(),
|
||||
@@ -445,7 +399,7 @@ export const modelSchema = z.object({
|
||||
context_length: z.number(),
|
||||
});
|
||||
|
||||
export const inputSchema = z.object({
|
||||
const inputSchema = z.object({
|
||||
data: z.array(modelSchema),
|
||||
});
|
||||
|
||||
@@ -454,7 +408,7 @@ export const inputSchema = z.object({
|
||||
* @param {{ data: Array<z.infer<typeof modelSchema>> }} input The input object containing base URL and data fetched from the API.
|
||||
* @returns {EndpointTokenConfig} The processed model data.
|
||||
*/
|
||||
export function processModelData(input: z.infer<typeof inputSchema>): EndpointTokenConfig {
|
||||
function processModelData(input) {
|
||||
const validationResult = inputSchema.safeParse(input);
|
||||
if (!validationResult.success) {
|
||||
throw new Error('Invalid input data');
|
||||
@@ -462,7 +416,7 @@ export function processModelData(input: z.infer<typeof inputSchema>): EndpointTo
|
||||
const { data } = validationResult.data;
|
||||
|
||||
/** @type {EndpointTokenConfig} */
|
||||
const tokenConfig: EndpointTokenConfig = {};
|
||||
const tokenConfig = {};
|
||||
|
||||
for (const model of data) {
|
||||
const modelKey = model.id;
|
||||
@@ -485,7 +439,7 @@ export function processModelData(input: z.infer<typeof inputSchema>): EndpointTo
|
||||
return tokenConfig;
|
||||
}
|
||||
|
||||
export const tiktokenModels = new Set([
|
||||
const tiktokenModels = new Set([
|
||||
'text-davinci-003',
|
||||
'text-davinci-002',
|
||||
'text-davinci-001',
|
||||
@@ -523,3 +477,17 @@ export const tiktokenModels = new Set([
|
||||
'gpt-3.5-turbo',
|
||||
'gpt-3.5-turbo-0301',
|
||||
]);
|
||||
|
||||
module.exports = {
|
||||
inputSchema,
|
||||
modelSchema,
|
||||
maxTokensMap,
|
||||
tiktokenModels,
|
||||
maxOutputTokensMap,
|
||||
matchModelName,
|
||||
processModelData,
|
||||
getModelMaxTokens,
|
||||
getModelTokenValue,
|
||||
findMatchingPattern,
|
||||
getModelMaxOutputTokens,
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const {
|
||||
maxTokensMap,
|
||||
matchModelName,
|
||||
processModelData,
|
||||
getModelMaxTokens,
|
||||
maxOutputTokensMap,
|
||||
findMatchingPattern,
|
||||
} = require('@librechat/api');
|
||||
getModelMaxTokens,
|
||||
processModelData,
|
||||
matchModelName,
|
||||
maxTokensMap,
|
||||
} = require('./tokens');
|
||||
|
||||
describe('getModelMaxTokens', () => {
|
||||
test('should return correct tokens for exact match', () => {
|
||||
@@ -394,7 +394,7 @@ describe('getModelMaxTokens', () => {
|
||||
});
|
||||
|
||||
test('should return correct max output tokens for GPT-5 models', () => {
|
||||
const { getModelMaxOutputTokens } = require('@librechat/api');
|
||||
const { getModelMaxOutputTokens } = require('./tokens');
|
||||
['gpt-5', 'gpt-5-mini', 'gpt-5-nano'].forEach((model) => {
|
||||
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
|
||||
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
|
||||
@@ -407,7 +407,7 @@ describe('getModelMaxTokens', () => {
|
||||
});
|
||||
|
||||
test('should return correct max output tokens for GPT-OSS models', () => {
|
||||
const { getModelMaxOutputTokens } = require('@librechat/api');
|
||||
const { getModelMaxOutputTokens } = require('./tokens');
|
||||
['gpt-oss-20b', 'gpt-oss-120b'].forEach((model) => {
|
||||
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
|
||||
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
|
||||
|
||||
@@ -140,7 +140,6 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||
pluginTools,
|
||||
activePanel,
|
||||
agentsConfig,
|
||||
startupConfig,
|
||||
setActivePanel,
|
||||
endpointsConfig,
|
||||
setCurrentAgentId,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
|
||||
import type {
|
||||
Agent,
|
||||
AgentProvider,
|
||||
AgentModelParameters,
|
||||
SupportContact,
|
||||
AgentProvider,
|
||||
GraphEdge,
|
||||
Agent,
|
||||
} from 'librechat-data-provider';
|
||||
import type { OptionWithIcon, ExtendedFile } from './types';
|
||||
|
||||
@@ -33,7 +34,9 @@ export type AgentForm = {
|
||||
model_parameters: AgentModelParameters;
|
||||
tools?: string[];
|
||||
provider?: AgentProvider | OptionWithIcon;
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids?: string[];
|
||||
edges?: GraphEdge[];
|
||||
[AgentCapabilities.artifacts]?: ArtifactModes | string;
|
||||
recursion_limit?: number;
|
||||
support_contact?: SupportContact;
|
||||
|
||||
@@ -239,7 +239,6 @@ export type AgentPanelContextType = {
|
||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
agent_id?: string;
|
||||
startupConfig?: t.TStartupConfig | null;
|
||||
agentsConfig?: t.TAgentsEndpoint | null;
|
||||
endpointsConfig?: t.TEndpointsConfig | null;
|
||||
/** Pre-computed MCP server information indexed by server key */
|
||||
|
||||
92
client/src/components/Chat/Messages/Content/AgentHandoff.tsx
Normal file
92
client/src/components/Chat/Messages/Content/AgentHandoff.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { EModelEndpoint, Constants } from 'librechat-data-provider';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface AgentHandoffProps {
|
||||
name: string;
|
||||
args: string | Record<string, unknown>;
|
||||
output?: string | null;
|
||||
}
|
||||
|
||||
const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) => {
|
||||
const localize = useLocalize();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
/** Extracted agent ID from tool name (e.g., "lc_transfer_to_agent_gUV0wMb7zHt3y3Xjz-8_4" -> "agent_gUV0wMb7zHt3y3Xjz-8_4") */
|
||||
const targetAgentId = useMemo(() => {
|
||||
if (typeof name !== 'string' || !name.startsWith(Constants.LC_TRANSFER_TO_)) {
|
||||
return null;
|
||||
}
|
||||
return name.replace(Constants.LC_TRANSFER_TO_, '');
|
||||
}, [name]);
|
||||
|
||||
const targetAgent = useMemo(() => {
|
||||
if (!targetAgentId || !agentsMap) {
|
||||
return null;
|
||||
}
|
||||
return agentsMap[targetAgentId];
|
||||
}, [agentsMap, targetAgentId]);
|
||||
|
||||
const args = useMemo(() => {
|
||||
if (typeof _args === 'string') {
|
||||
return _args;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(_args, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [_args]) as string;
|
||||
|
||||
/** Requires more than 2 characters as can be an empty object: `{}` */
|
||||
const hasInfo = useMemo(() => (args?.trim()?.length ?? 0) > 2, [args]);
|
||||
|
||||
return (
|
||||
<div className="my-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 text-sm text-text-secondary',
|
||||
hasInfo && 'cursor-pointer transition-colors hover:text-text-primary',
|
||||
)}
|
||||
onClick={() => hasInfo && setShowInfo(!showInfo)}
|
||||
>
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<MessageIcon
|
||||
message={
|
||||
{
|
||||
endpoint: EModelEndpoint.agents,
|
||||
isCreatedByUser: false,
|
||||
} as TMessage
|
||||
}
|
||||
agent={targetAgent || undefined}
|
||||
/>
|
||||
</div>
|
||||
<span className="select-none">{localize('com_ui_transferred_to')}</span>
|
||||
<span className="select-none font-medium text-text-primary">
|
||||
{targetAgent?.name || localize('com_ui_agent')}
|
||||
</span>
|
||||
{hasInfo && (
|
||||
<ChevronDown
|
||||
className={cn('ml-1 h-3 w-3 transition-transform', showInfo && 'rotate-180')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{hasInfo && showInfo && (
|
||||
<div className="ml-8 mt-2 rounded-md bg-surface-secondary p-3 text-xs">
|
||||
<div className="mb-1 font-medium text-text-secondary">
|
||||
{localize('com_ui_handoff_instructions')}:
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap text-text-primary">{args}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentHandoff;
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Tools,
|
||||
Constants,
|
||||
ContentTypes,
|
||||
ToolCallTypes,
|
||||
imageGenTools,
|
||||
@@ -10,6 +11,7 @@ import type { TMessageContentParts, TAttachment } from 'librechat-data-provider'
|
||||
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
|
||||
import { ErrorMessage } from './MessageContent';
|
||||
import RetrievalCall from './RetrievalCall';
|
||||
import AgentHandoff from './AgentHandoff';
|
||||
import CodeAnalyze from './CodeAnalyze';
|
||||
import Container from './Container';
|
||||
import WebSearch from './WebSearch';
|
||||
@@ -118,6 +120,14 @@ const Part = memo(
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) {
|
||||
return (
|
||||
<AgentHandoff
|
||||
args={toolCall.args ?? ''}
|
||||
name={toolCall.name || ''}
|
||||
output={toolCall.output ?? ''}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall) {
|
||||
return (
|
||||
<ToolCall
|
||||
|
||||
@@ -11,8 +11,8 @@ interface AgentUpdateProps {
|
||||
|
||||
const AgentUpdate: React.FC<AgentUpdateProps> = ({ currentAgentId }) => {
|
||||
const localize = useLocalize();
|
||||
const agentsMap = useAgentsMapContext() || {};
|
||||
const currentAgent = useMemo(() => agentsMap[currentAgentId], [agentsMap, currentAgentId]);
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const currentAgent = useMemo(() => agentsMap?.[currentAgentId], [agentsMap, currentAgentId]);
|
||||
if (!currentAgentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useFormContext, Controller } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import MaxAgentSteps from './MaxAgentSteps';
|
||||
import AgentHandoffs from './AgentHandoffs';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import AgentChain from './AgentChain';
|
||||
import { Panel } from '~/common';
|
||||
@@ -41,6 +42,12 @@ export default function AdvancedPanel() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-2">
|
||||
<MaxAgentSteps />
|
||||
<Controller
|
||||
name="edges"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
render={({ field }) => <AgentHandoffs field={field} currentAgentId={currentAgentId} />}
|
||||
/>
|
||||
{chainEnabled && (
|
||||
<Controller
|
||||
name="agent_ids"
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { X, Waypoints, PlusCircle, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
Label,
|
||||
Input,
|
||||
Textarea,
|
||||
HoverCard,
|
||||
CircleHelpIcon,
|
||||
HoverCardPortal,
|
||||
ControlCombobox,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@librechat/client';
|
||||
import type { TMessage, GraphEdge } from 'librechat-data-provider';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
import type { AgentForm, OptionWithIcon } from '~/common';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
interface AgentHandoffsProps {
|
||||
field: ControllerRenderProps<AgentForm, 'edges'>;
|
||||
currentAgentId: string;
|
||||
}
|
||||
|
||||
/** TODO: make configurable */
|
||||
const MAX_HANDOFFS = 10;
|
||||
|
||||
const AgentHandoffs: React.FC<AgentHandoffsProps> = ({ field, currentAgentId }) => {
|
||||
const localize = useLocalize();
|
||||
const [newAgentId, setNewAgentId] = useState('');
|
||||
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const edgesValue = field.value;
|
||||
const edges = useMemo(() => edgesValue || [], [edgesValue]);
|
||||
|
||||
const agents = useMemo(() => (agentsMap ? Object.values(agentsMap) : []), [agentsMap]);
|
||||
|
||||
const selectableAgents = useMemo(
|
||||
() =>
|
||||
agents
|
||||
.filter((agent) => agent?.id !== currentAgentId)
|
||||
.map(
|
||||
(agent) =>
|
||||
({
|
||||
label: agent?.name || '',
|
||||
value: agent?.id || '',
|
||||
icon: (
|
||||
<MessageIcon
|
||||
message={
|
||||
{
|
||||
endpoint: EModelEndpoint.agents,
|
||||
isCreatedByUser: false,
|
||||
} as TMessage
|
||||
}
|
||||
agent={agent}
|
||||
/>
|
||||
),
|
||||
}) as OptionWithIcon,
|
||||
),
|
||||
[agents, currentAgentId],
|
||||
);
|
||||
|
||||
const getAgentDetails = useCallback((id: string) => agentsMap?.[id], [agentsMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (newAgentId && edges.length < MAX_HANDOFFS) {
|
||||
const newEdge: GraphEdge = {
|
||||
from: currentAgentId,
|
||||
to: newAgentId,
|
||||
edgeType: 'handoff',
|
||||
};
|
||||
field.onChange([...edges, newEdge]);
|
||||
setNewAgentId('');
|
||||
}
|
||||
}, [newAgentId, edges, field, currentAgentId]);
|
||||
|
||||
const removeHandoffAt = (index: number) => {
|
||||
field.onChange(edges.filter((_, i) => i !== index));
|
||||
// Also remove from expanded set
|
||||
setExpandedIndices((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(index);
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const updateHandoffAt = (index: number, agentId: string) => {
|
||||
const updated = [...edges];
|
||||
updated[index] = { ...updated[index], to: agentId };
|
||||
field.onChange(updated);
|
||||
};
|
||||
|
||||
const updateHandoffDetailsAt = (index: number, updates: Partial<GraphEdge>) => {
|
||||
const updated = [...edges];
|
||||
updated[index] = { ...updated[index], ...updates };
|
||||
field.onChange(updated);
|
||||
};
|
||||
|
||||
const toggleExpanded = (index: number) => {
|
||||
setExpandedIndices((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getTargetAgentId = (to: string | string[]): string => {
|
||||
return Array.isArray(to) ? to[0] : to;
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={50}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="font-semibold text-text-primary">
|
||||
{localize('com_ui_agent_handoffs')}
|
||||
</label>
|
||||
<HoverCardTrigger>
|
||||
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</HoverCardTrigger>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full border border-purple-600/40 bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-700 hover:bg-purple-700/10 dark:text-purple-400">
|
||||
{localize('com_ui_beta')}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
{edges.length} / {MAX_HANDOFFS}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{edges.map((edge, idx) => {
|
||||
const targetAgentId = getTargetAgentId(edge.to);
|
||||
const isExpanded = expandedIndices.has(idx);
|
||||
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-10 items-center gap-2 rounded-md border border-border-medium bg-surface-tertiary pr-2">
|
||||
<ControlCombobox
|
||||
isCollapsed={false}
|
||||
ariaLabel={localize('com_ui_agent_var', { 0: localize('com_ui_select') })}
|
||||
selectedValue={targetAgentId}
|
||||
setValue={(id) => updateHandoffAt(idx, id)}
|
||||
selectPlaceholder={localize('com_ui_agent_var', {
|
||||
0: localize('com_ui_select'),
|
||||
})}
|
||||
searchPlaceholder={localize('com_ui_agent_var', {
|
||||
0: localize('com_ui_search'),
|
||||
})}
|
||||
items={selectableAgents}
|
||||
displayValue={getAgentDetails(targetAgentId)?.name ?? ''}
|
||||
SelectIcon={
|
||||
<MessageIcon
|
||||
message={
|
||||
{
|
||||
endpoint: EModelEndpoint.agents,
|
||||
isCreatedByUser: false,
|
||||
} as TMessage
|
||||
}
|
||||
agent={targetAgentId && agentsMap ? agentsMap[targetAgentId] : undefined}
|
||||
/>
|
||||
}
|
||||
className="flex-1 border-border-heavy"
|
||||
containerClassName="px-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 transition hover:bg-surface-hover"
|
||||
onClick={() => toggleExpanded(idx)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`text-text-secondary transition-transform ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl p-1 transition hover:bg-surface-hover"
|
||||
onClick={() => removeHandoffAt(idx)}
|
||||
>
|
||||
<X size={18} className="text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-3 rounded-md border border-border-light bg-surface-primary p-3">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={`handoff-desc-${idx}`}
|
||||
className="text-xs text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_agent_handoff_description')}
|
||||
</Label>
|
||||
<Input
|
||||
id={`handoff-desc-${idx}`}
|
||||
placeholder={localize('com_ui_agent_handoff_description_placeholder')}
|
||||
value={edge.description || ''}
|
||||
onChange={(e) =>
|
||||
updateHandoffDetailsAt(idx, { description: e.target.value })
|
||||
}
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={`handoff-prompt-${idx}`}
|
||||
className="text-xs text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_agent_handoff_prompt')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`handoff-prompt-${idx}`}
|
||||
placeholder={localize('com_ui_agent_handoff_prompt_placeholder')}
|
||||
value={typeof edge.prompt === 'string' ? edge.prompt : ''}
|
||||
onChange={(e) => updateHandoffDetailsAt(idx, { prompt: e.target.value })}
|
||||
className="mt-1 h-20 resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{edge.prompt && (
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={`handoff-promptkey-${idx}`}
|
||||
className="text-xs text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_agent_handoff_prompt_key')}
|
||||
</Label>
|
||||
<Input
|
||||
id={`handoff-promptkey-${idx}`}
|
||||
placeholder={localize('com_ui_agent_handoff_prompt_key_placeholder')}
|
||||
value={edge.promptKey || ''}
|
||||
onChange={(e) =>
|
||||
updateHandoffDetailsAt(idx, { promptKey: e.target.value })
|
||||
}
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{idx < edges.length - 1 && (
|
||||
<Waypoints className="mx-auto text-text-secondary" size={14} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{edges.length < MAX_HANDOFFS && (
|
||||
<>
|
||||
{edges.length > 0 && <Waypoints className="mx-auto text-text-secondary" size={14} />}
|
||||
<ControlCombobox
|
||||
isCollapsed={false}
|
||||
ariaLabel={localize('com_ui_agent_var', { 0: localize('com_ui_add') })}
|
||||
selectedValue=""
|
||||
setValue={setNewAgentId}
|
||||
selectPlaceholder={localize('com_ui_agent_handoff_add')}
|
||||
searchPlaceholder={localize('com_ui_agent_var', { 0: localize('com_ui_search') })}
|
||||
items={selectableAgents}
|
||||
className="h-10 w-full border-dashed border-border-heavy text-center text-text-secondary hover:text-text-primary"
|
||||
containerClassName="px-0"
|
||||
SelectIcon={<PlusCircle size={16} className="text-text-secondary" />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{edges.length >= MAX_HANDOFFS && (
|
||||
<p className="pt-1 text-center text-xs italic text-text-tertiary">
|
||||
{localize('com_ui_agent_handoff_max', { 0: MAX_HANDOFFS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_agent_handoff_info')}</p>
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_agent_handoff_info_2')}</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentHandoffs;
|
||||
@@ -49,7 +49,6 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
actions,
|
||||
setAction,
|
||||
agentsConfig,
|
||||
startupConfig,
|
||||
mcpServersMap,
|
||||
setActivePanel,
|
||||
endpointsConfig,
|
||||
@@ -309,14 +308,6 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
{fileSearchEnabled && <FileSearch agent_id={agent_id} files={knowledge_files} />}
|
||||
</div>
|
||||
)}
|
||||
{/* MCP Section */}
|
||||
{startupConfig?.mcpServers != null && (
|
||||
<MCPTools
|
||||
agentId={agent_id}
|
||||
mcpServerNames={mcpServerNames}
|
||||
setShowMCPToolDialog={setShowMCPToolDialog}
|
||||
/>
|
||||
)}
|
||||
{/* Agent Tools & Actions */}
|
||||
<div className="mb-4">
|
||||
<label className={labelClass}>
|
||||
@@ -384,6 +375,12 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* MCP Section */}
|
||||
<MCPTools
|
||||
agentId={agent_id}
|
||||
mcpServerNames={mcpServerNames}
|
||||
setShowMCPToolDialog={setShowMCPToolDialog}
|
||||
/>
|
||||
{/* Support Contact (Optional) */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
|
||||
@@ -177,6 +177,7 @@ export default function AgentPanel() {
|
||||
model_parameters,
|
||||
provider: _provider,
|
||||
agent_ids,
|
||||
edges,
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
@@ -201,6 +202,7 @@ export default function AgentPanel() {
|
||||
provider,
|
||||
model_parameters,
|
||||
agent_ids,
|
||||
edges,
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
@@ -234,6 +236,7 @@ export default function AgentPanel() {
|
||||
provider,
|
||||
model_parameters,
|
||||
agent_ids,
|
||||
edges,
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
|
||||
@@ -103,6 +103,11 @@ export default function AgentSelect({
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'edges' && Array.isArray(value)) {
|
||||
formValues[name] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keys.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ const useSpeechToTextExternal = (
|
||||
|
||||
const [minDecibels] = useRecoilState(store.decibelValue);
|
||||
const [autoSendText] = useRecoilState(store.autoSendText);
|
||||
const [languageSTT] = useRecoilState<string>(store.languageSTT);
|
||||
const [speechToText] = useRecoilState<boolean>(store.speechToText);
|
||||
const [autoTranscribeAudio] = useRecoilState<boolean>(store.autoTranscribeAudio);
|
||||
|
||||
@@ -122,9 +121,6 @@ const useSpeechToTextExternal = (
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, `audio.${fileExtension}`);
|
||||
if (languageSTT) {
|
||||
formData.append('language', languageSTT);
|
||||
}
|
||||
setIsRequestBeingMade(true);
|
||||
cleanup();
|
||||
processAudio(formData);
|
||||
|
||||
@@ -653,6 +653,22 @@
|
||||
"com_ui_agent_chain": "Agent Chain (Mixture-of-Agents)",
|
||||
"com_ui_agent_chain_info": "Enables creating sequences of agents. Each agent can access outputs from previous agents in the chain. Based on the \"Mixture-of-Agents\" architecture where agents use previous outputs as auxiliary information.",
|
||||
"com_ui_agent_chain_max": "You have reached the maximum of {{0}} agents.",
|
||||
"com_ui_agent_handoffs": "Agent Handoffs",
|
||||
"com_ui_agent_handoff_add": "Add handoff agent",
|
||||
"com_ui_agent_handoff_description": "Handoff description",
|
||||
"com_ui_agent_handoff_description_placeholder": "e.g., Transfer to data analyst for statistical analysis",
|
||||
"com_ui_agent_handoff_info": "Configure agents that this agent can transfer conversations to when specific expertise is needed.",
|
||||
"com_ui_agent_handoff_info_2": "Each handoff creates a transfer tool that enables seamless routing to specialist agents with context.",
|
||||
"com_ui_agent_handoff_max": "Maximum {{0}} handoff agents reached.",
|
||||
"com_ui_agent_handoff_prompt": "Passthrough content",
|
||||
"com_ui_agent_handoff_prompt_placeholder": "Tell this agent what content to generate and pass to the handoff agent. You need to add something here to enable this feature",
|
||||
"com_ui_agent_handoff_prompt_key": "Content parameter name (default: 'instructions')",
|
||||
"com_ui_agent_handoff_prompt_key_placeholder": "Label the content passed (default: 'instructions')",
|
||||
"com_ui_transferring_to_agent": "Transferring to {{0}}",
|
||||
"com_ui_transferred_to_agent": "Transferred to {{0}}",
|
||||
"com_ui_transferred_to": "Transferred to",
|
||||
"com_ui_handoff_instructions": "Handoff instructions",
|
||||
"com_ui_beta": "Beta",
|
||||
"com_ui_agent_delete_error": "There was an error deleting the agent",
|
||||
"com_ui_agent_deleted": "Successfully deleted agent",
|
||||
"com_ui_agent_duplicate_error": "There was an error duplicating the agent",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,16 +63,3 @@ Create the name of the service account to use
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Define apiVersion of HorizontalPodAutoscaler
|
||||
*/}}
|
||||
{{- define "librechat.hpa.apiVersion" -}}
|
||||
{{- if .Capabilities.APIVersions.Has "autoscaling/v2" -}}
|
||||
{{- print "autoscaling/v2" -}}
|
||||
{{- else if .Capabilities.APIVersions.Has "autoscaling/v2beta2" -}}
|
||||
{{- print "autoscaling/v2beta2" -}}
|
||||
{{- else -}}
|
||||
{{- print "autoscaling/v2beta1" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: {{ include "librechat.hpa.apiVersion" $ }}
|
||||
apiVersion: autoscaling/v2beta1
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "librechat.fullname" $ }}
|
||||
|
||||
@@ -121,14 +121,6 @@ registration:
|
||||
# refillIntervalUnit: 'days'
|
||||
# refillAmount: 10000
|
||||
|
||||
# Example Transactions settings
|
||||
# Controls whether to save transaction records to the database
|
||||
# Default is true (enabled)
|
||||
#transactions:
|
||||
# enabled: false
|
||||
# Note: If balance.enabled is true, transactions will always be enabled
|
||||
# regardless of this setting to ensure balance tracking works correctly
|
||||
|
||||
# speech:
|
||||
# tts:
|
||||
# openai:
|
||||
|
||||
68
package-lock.json
generated
68
package-lock.json
generated
@@ -59,12 +59,12 @@
|
||||
"@googleapis/youtube": "^20.0.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/community": "^0.3.47",
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@langchain/core": "^0.3.72",
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/openai": "^0.5.18",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.76",
|
||||
"@librechat/agents": "^3.0.0-rc10",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
@@ -21355,9 +21355,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "0.3.62",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.62.tgz",
|
||||
"integrity": "sha512-GqRTcoUPnozGRMUcA6QkP7LHL/OvanGdB51Jgb0w7IIPDI3wFugxMHZ4gphnGDtxsD1tQY5ykyEpYNxFK8kl1w==",
|
||||
"version": "0.3.72",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.72.tgz",
|
||||
"integrity": "sha512-WsGWVZYnlKffj2eEfDocPNiaTRoxyYiLSQdQ7oxZvxGZBqo/90vpjbC33UGK1uPNBM4kT+pkdaol/MnvKUh8TQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
@@ -21365,7 +21365,7 @@
|
||||
"camelcase": "6",
|
||||
"decamelize": "1.2.0",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"langsmith": "^0.3.33",
|
||||
"langsmith": "^0.3.46",
|
||||
"mustache": "^4.2.0",
|
||||
"p-queue": "^6.6.2",
|
||||
"p-retry": "4",
|
||||
@@ -21612,13 +21612,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph": {
|
||||
"version": "0.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.3.12.tgz",
|
||||
"integrity": "sha512-4jKvfmxxgQyKnCvXdFbcKt6MdfaJoQ2WWqBR16o2E6D2RxqHvnLMMClZh4FSd6WYw39z5LGWvzRapFbRMqxu1A==",
|
||||
"version": "0.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.4.9.tgz",
|
||||
"integrity": "sha512-+rcdTGi4Ium4X/VtIX3Zw4RhxEkYWpwUyz806V6rffjHOAMamg6/WZDxpJbrP33RV/wJG1GH12Z29oX3Pqq3Aw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph-checkpoint": "~0.0.18",
|
||||
"@langchain/langgraph-sdk": "~0.0.102",
|
||||
"@langchain/langgraph-checkpoint": "^0.1.1",
|
||||
"@langchain/langgraph-sdk": "~0.1.0",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.25.32"
|
||||
},
|
||||
@@ -21636,9 +21636,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-checkpoint": {
|
||||
"version": "0.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.18.tgz",
|
||||
"integrity": "sha512-IS7zJj36VgY+4pf8ZjsVuUWef7oTwt1y9ylvwu0aLuOn1d0fg05Om9DLm3v2GZ2Df6bhLV1kfWAM0IAl9O5rQQ==",
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.1.1.tgz",
|
||||
"integrity": "sha512-h2bP0RUikQZu0Um1ZUPErQLXyhzroJqKRbRcxYRTAh49oNlsfeq4A3K4YEDRbGGuyPZI/Jiqwhks1wZwY73AZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uuid": "^10.0.0"
|
||||
@@ -21647,7 +21647,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": ">=0.2.31 <0.4.0"
|
||||
"@langchain/core": ">=0.2.31 <0.4.0 || ^1.0.0-alpha"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-checkpoint/node_modules/uuid": {
|
||||
@@ -21664,9 +21664,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk": {
|
||||
"version": "0.0.104",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.104.tgz",
|
||||
"integrity": "sha512-wUO6GMy65Y7DsWtjTJ3dA59enrZy2wN4o48AMYN7dF7u/PMXXYyBjBCKSzgVWqO6uWH2yNpyGDrcMwKuk5kQLA==",
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.1.0.tgz",
|
||||
"integrity": "sha512-1EKwzwJpgpNqLcRuGG+kLvvhAaPiFWZ9shl/obhL8qDKtYdbR67WCYE+2jUObZ8vKQuCoul16ewJ78g5VrZlKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15",
|
||||
@@ -21909,19 +21909,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@librechat/agents": {
|
||||
"version": "2.4.76",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.76.tgz",
|
||||
"integrity": "sha512-DkWKpKcLgv9tA6bXJ8pSzHOA3iZRFQRt9oBjEEeW0onhEdPTmHVR3/dY5bxMKSP8rlA65M0yx1KaoLL8bhg06Q==",
|
||||
"version": "3.0.0-rc10",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.0-rc10.tgz",
|
||||
"integrity": "sha512-i1d+0jWwFjEkxne9+a/Y4ugTBg/RC604PK+OXz6fpfVnbJ4YxF2nd61BzflKbkU9rrnE0o8ogSx59Mc2vGhDjg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^0.3.26",
|
||||
"@langchain/aws": "^0.1.12",
|
||||
"@langchain/community": "^0.3.47",
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@langchain/core": "^0.3.72",
|
||||
"@langchain/deepseek": "^0.0.2",
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/langgraph": "^0.3.4",
|
||||
"@langchain/langgraph": "^0.4.9",
|
||||
"@langchain/mistralai": "^0.2.1",
|
||||
"@langchain/ollama": "^0.2.3",
|
||||
"@langchain/openai": "0.5.18",
|
||||
@@ -39297,9 +39297,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
"version": "0.3.33",
|
||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.33.tgz",
|
||||
"integrity": "sha512-imNIaBL6+ElE5eMzNHYwFxo6W/6rHlqcaUjCYoIeGdCYWlARxE3CTGKul5DJnaUgGP2CTLFeNXyvRx5HWC/4KQ==",
|
||||
"version": "0.3.67",
|
||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.67.tgz",
|
||||
"integrity": "sha512-l4y3RmJ9yWF5a29fLg3eWZQxn6Q6dxTOgLGgQHzPGZHF3NUynn+A+airYIe/Yt4rwjGbuVrABAPsXBkVu/Hi7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -39311,9 +39311,21 @@
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "*",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "*",
|
||||
"@opentelemetry/sdk-trace-base": "*",
|
||||
"openai": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/exporter-trace-otlp-proto": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/sdk-trace-base": {
|
||||
"optional": true
|
||||
},
|
||||
"openai": {
|
||||
"optional": true
|
||||
}
|
||||
@@ -51971,8 +51983,8 @@
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@librechat/agents": "^2.4.76",
|
||||
"@langchain/core": "^0.3.72",
|
||||
"@librechat/agents": "^3.0.0-rc10",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"axios": "^1.8.2",
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@librechat/agents": "^2.4.76",
|
||||
"@langchain/core": "^0.3.72",
|
||||
"@librechat/agents": "^3.0.0-rc10",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"axios": "^1.8.2",
|
||||
|
||||
47
packages/api/src/agents/chain.ts
Normal file
47
packages/api/src/agents/chain.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { PromptTemplate } from '@langchain/core/prompts';
|
||||
import { BaseMessage, getBufferString } from '@langchain/core/messages';
|
||||
import type { GraphEdge } from '@librechat/agents';
|
||||
|
||||
const DEFAULT_PROMPT_TEMPLATE = `Based on the following conversation and analysis from previous agents, please provide your insights:\n\n{convo}\n\nPlease add your specific expertise and perspective to this discussion.`;
|
||||
|
||||
/**
|
||||
* Helper function to create sequential chain edges with buffer string prompts
|
||||
*
|
||||
* @deprecated Agent Chain helper
|
||||
* @param agentIds - Array of agent IDs in order of execution
|
||||
* @param promptTemplate - Optional prompt template string; defaults to a predefined template if not provided
|
||||
* @returns Array of edges configured for sequential chain with buffer prompts
|
||||
*/
|
||||
export async function createSequentialChainEdges(
|
||||
agentIds: string[],
|
||||
promptTemplate = DEFAULT_PROMPT_TEMPLATE,
|
||||
): Promise<GraphEdge[]> {
|
||||
const edges: GraphEdge[] = [];
|
||||
|
||||
for (let i = 0; i < agentIds.length - 1; i++) {
|
||||
const fromAgent = agentIds[i];
|
||||
const toAgent = agentIds[i + 1];
|
||||
|
||||
edges.push({
|
||||
from: fromAgent,
|
||||
to: toAgent,
|
||||
edgeType: 'direct',
|
||||
// Use a prompt function to create the buffer string from all previous results
|
||||
prompt: async (messages: BaseMessage[], startIndex: number) => {
|
||||
/** Only the messages from this run (after startIndex) are passed in */
|
||||
const runMessages = messages.slice(startIndex);
|
||||
const bufferString = getBufferString(runMessages);
|
||||
const template = PromptTemplate.fromTemplate(promptTemplate);
|
||||
const result = await template.invoke({
|
||||
convo: bufferString,
|
||||
});
|
||||
return result.value;
|
||||
},
|
||||
/** Critical: exclude previous results so only the prompt is passed */
|
||||
excludeResults: true,
|
||||
description: `Sequential chain from ${fromAgent} to ${toAgent}`,
|
||||
});
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './chain';
|
||||
export * from './config';
|
||||
export * from './memory';
|
||||
export * from './migration';
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from '@librechat/agents';
|
||||
import type { TAttachment, MemoryArtifact } from 'librechat-data-provider';
|
||||
import type { ObjectId, MemoryMethods } from '@librechat/data-schemas';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import type { BaseMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import type { Response as ServerResponse } from 'express';
|
||||
import { Tokenizer } from '~/utils';
|
||||
|
||||
@@ -464,7 +464,7 @@ async function handleMemoryArtifact({
|
||||
data: ToolEndData;
|
||||
metadata?: ToolEndMetadata;
|
||||
}) {
|
||||
const output = data?.output;
|
||||
const output = data?.output as ToolMessage | undefined;
|
||||
if (!output) {
|
||||
return null;
|
||||
}
|
||||
@@ -507,7 +507,7 @@ export function createMemoryCallback({
|
||||
artifactPromises: Promise<Partial<TAttachment> | null>[];
|
||||
}): ToolEndCallback {
|
||||
return async (data: ToolEndData, metadata?: Record<string, unknown>) => {
|
||||
const output = data?.output;
|
||||
const output = data?.output as ToolMessage | undefined;
|
||||
const memoryArtifact = output?.artifact?.[Tools.memory] as MemoryArtifact;
|
||||
if (memoryArtifact == null) {
|
||||
return;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Run, Providers } from '@librechat/agents';
|
||||
import { providerEndpointMap, KnownEndpoints } from 'librechat-data-provider';
|
||||
import type {
|
||||
MultiAgentGraphConfig,
|
||||
OpenAIClientOptions,
|
||||
StandardGraphConfig,
|
||||
EventHandler,
|
||||
AgentInputs,
|
||||
GenericTool,
|
||||
GraphEvents,
|
||||
RunConfig,
|
||||
IState,
|
||||
} from '@librechat/agents';
|
||||
import type { Agent } from 'librechat-data-provider';
|
||||
import type * as t from '~/types';
|
||||
import { resolveHeaders } from '~/utils/env';
|
||||
|
||||
const customProviders = new Set([
|
||||
Providers.XAI,
|
||||
@@ -40,13 +42,18 @@ export function getReasoningKey(
|
||||
return reasoningKey;
|
||||
}
|
||||
|
||||
type RunAgent = Omit<Agent, 'tools'> & {
|
||||
tools?: GenericTool[];
|
||||
maxContextTokens?: number;
|
||||
toolContextMap?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new Run instance with custom handlers and configuration.
|
||||
*
|
||||
* @param options - The options for creating the Run instance.
|
||||
* @param options.agent - The agent for this run.
|
||||
* @param options.agents - The agents for this run.
|
||||
* @param options.signal - The signal for this run.
|
||||
* @param options.req - The server request.
|
||||
* @param options.runId - Optional run ID; otherwise, a new run ID will be generated.
|
||||
* @param options.customHandlers - Custom event handlers.
|
||||
* @param options.streaming - Whether to use streaming.
|
||||
@@ -55,61 +62,108 @@ export function getReasoningKey(
|
||||
*/
|
||||
export async function createRun({
|
||||
runId,
|
||||
agent,
|
||||
signal,
|
||||
agents,
|
||||
requestBody,
|
||||
tokenCounter,
|
||||
customHandlers,
|
||||
indexTokenCountMap,
|
||||
streaming = true,
|
||||
streamUsage = true,
|
||||
}: {
|
||||
agent: Omit<Agent, 'tools'> & { tools?: GenericTool[] };
|
||||
agents: RunAgent[];
|
||||
signal: AbortSignal;
|
||||
runId?: string;
|
||||
streaming?: boolean;
|
||||
streamUsage?: boolean;
|
||||
customHandlers?: Record<GraphEvents, EventHandler>;
|
||||
}): Promise<Run<IState>> {
|
||||
const provider =
|
||||
(providerEndpointMap[
|
||||
agent.provider as keyof typeof providerEndpointMap
|
||||
] as unknown as Providers) ?? agent.provider;
|
||||
requestBody?: t.RequestBody;
|
||||
} & Pick<RunConfig, 'tokenCounter' | 'customHandlers' | 'indexTokenCountMap'>): Promise<
|
||||
Run<IState>
|
||||
> {
|
||||
const agentInputs: AgentInputs[] = [];
|
||||
const buildAgentContext = (agent: RunAgent) => {
|
||||
const provider =
|
||||
(providerEndpointMap[
|
||||
agent.provider as keyof typeof providerEndpointMap
|
||||
] as unknown as Providers) ?? agent.provider;
|
||||
|
||||
const llmConfig: t.RunLLMConfig = Object.assign(
|
||||
{
|
||||
const llmConfig: t.RunLLMConfig = Object.assign(
|
||||
{
|
||||
provider,
|
||||
streaming,
|
||||
streamUsage,
|
||||
},
|
||||
agent.model_parameters,
|
||||
);
|
||||
|
||||
const systemMessage = Object.values(agent.toolContextMap ?? {})
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
const systemContent = [
|
||||
systemMessage,
|
||||
agent.instructions ?? '',
|
||||
agent.additional_instructions ?? '',
|
||||
]
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* Resolve request-based headers for Custom Endpoints. Note: if this is added to
|
||||
* non-custom endpoints, needs consideration of varying provider header configs.
|
||||
* This is done at this step because the request body may contain dynamic values
|
||||
* that need to be resolved after agent initialization.
|
||||
*/
|
||||
if (llmConfig?.configuration?.defaultHeaders != null) {
|
||||
llmConfig.configuration.defaultHeaders = resolveHeaders({
|
||||
headers: llmConfig.configuration.defaultHeaders as Record<string, string>,
|
||||
body: requestBody,
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves issues with new OpenAI usage field */
|
||||
if (
|
||||
customProviders.has(agent.provider) ||
|
||||
(agent.provider === Providers.OPENAI && agent.endpoint !== agent.provider)
|
||||
) {
|
||||
llmConfig.streamUsage = false;
|
||||
llmConfig.usage = true;
|
||||
}
|
||||
|
||||
const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint);
|
||||
const agentInput: AgentInputs = {
|
||||
provider,
|
||||
streaming,
|
||||
streamUsage,
|
||||
},
|
||||
agent.model_parameters,
|
||||
);
|
||||
|
||||
/** Resolves issues with new OpenAI usage field */
|
||||
if (
|
||||
customProviders.has(agent.provider) ||
|
||||
(agent.provider === Providers.OPENAI && agent.endpoint !== agent.provider)
|
||||
) {
|
||||
llmConfig.streamUsage = false;
|
||||
llmConfig.usage = true;
|
||||
}
|
||||
|
||||
const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint);
|
||||
const graphConfig: StandardGraphConfig = {
|
||||
signal,
|
||||
llmConfig,
|
||||
reasoningKey,
|
||||
tools: agent.tools,
|
||||
instructions: agent.instructions,
|
||||
additional_instructions: agent.additional_instructions,
|
||||
// toolEnd: agent.end_after_tools,
|
||||
reasoningKey,
|
||||
agentId: agent.id,
|
||||
tools: agent.tools,
|
||||
clientOptions: llmConfig,
|
||||
instructions: systemContent,
|
||||
maxContextTokens: agent.maxContextTokens,
|
||||
};
|
||||
agentInputs.push(agentInput);
|
||||
};
|
||||
|
||||
// TEMPORARY FOR TESTING
|
||||
if (agent.provider === Providers.ANTHROPIC || agent.provider === Providers.BEDROCK) {
|
||||
graphConfig.streamBuffer = 2000;
|
||||
for (const agent of agents) {
|
||||
buildAgentContext(agent);
|
||||
}
|
||||
|
||||
const graphConfig: RunConfig['graphConfig'] = {
|
||||
signal,
|
||||
agents: agentInputs,
|
||||
edges: agents[0].edges,
|
||||
};
|
||||
|
||||
if (agentInputs.length > 1 || ((graphConfig as MultiAgentGraphConfig).edges?.length ?? 0) > 0) {
|
||||
(graphConfig as unknown as MultiAgentGraphConfig).type = 'multi-agent';
|
||||
} else {
|
||||
(graphConfig as StandardGraphConfig).type = 'standard';
|
||||
}
|
||||
|
||||
return Run.create({
|
||||
runId,
|
||||
graphConfig,
|
||||
tokenCounter,
|
||||
customHandlers,
|
||||
indexTokenCountMap,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,6 +38,17 @@ export const agentSupportContactSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
/** Graph edge schema for agent handoffs */
|
||||
export const graphEdgeSchema = z.object({
|
||||
from: z.union([z.string(), z.array(z.string())]),
|
||||
to: z.union([z.string(), z.array(z.string())]),
|
||||
description: z.string().optional(),
|
||||
edgeType: z.enum(['handoff', 'direct']).optional(),
|
||||
prompt: z.union([z.string(), z.function()]).optional(),
|
||||
excludeResults: z.boolean().optional(),
|
||||
promptKey: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Base agent schema with all common fields */
|
||||
export const agentBaseSchema = z.object({
|
||||
name: z.string().nullable().optional(),
|
||||
@@ -46,7 +57,9 @@ export const agentBaseSchema = z.object({
|
||||
avatar: agentAvatarSchema.nullable().optional(),
|
||||
model_parameters: z.record(z.unknown()).optional(),
|
||||
tools: z.array(z.string()).optional(),
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids: z.array(z.string()).optional(),
|
||||
edges: z.array(graphEdgeSchema).optional(),
|
||||
end_after_tools: z.boolean().optional(),
|
||||
hide_sequential_outputs: z.boolean().optional(),
|
||||
artifacts: z.string().optional(),
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
import { getTransactionsConfig, getBalanceConfig } from './config';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '~/types';
|
||||
import type { TCustomConfig } from 'librechat-data-provider';
|
||||
|
||||
// Helper function to create a minimal AppConfig for testing
|
||||
const createTestAppConfig = (overrides: Partial<AppConfig> = {}): AppConfig => {
|
||||
const minimalConfig: TCustomConfig = {
|
||||
version: '1.0.0',
|
||||
cache: true,
|
||||
interface: {
|
||||
endpointsMenu: true,
|
||||
},
|
||||
registration: {
|
||||
socialLogins: [],
|
||||
},
|
||||
endpoints: {},
|
||||
};
|
||||
|
||||
return {
|
||||
config: minimalConfig,
|
||||
paths: {
|
||||
uploads: '',
|
||||
imageOutput: '',
|
||||
publicPath: '',
|
||||
},
|
||||
fileStrategy: FileSources.local,
|
||||
fileStrategies: {},
|
||||
imageOutputType: 'png',
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
isEnabled: jest.fn((value) => value === 'true'),
|
||||
normalizeEndpointName: jest.fn((name) => name),
|
||||
}));
|
||||
|
||||
describe('getTransactionsConfig', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.CHECK_BALANCE;
|
||||
delete process.env.START_BALANCE;
|
||||
});
|
||||
|
||||
describe('when appConfig is not provided', () => {
|
||||
it('should return default config with enabled: true', () => {
|
||||
const result = getTransactionsConfig();
|
||||
expect(result).toEqual({ enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when appConfig is provided', () => {
|
||||
it('should return transactions config when explicitly set to false', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
transactions: { enabled: false },
|
||||
balance: { enabled: false },
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: false });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return transactions config when explicitly set to true', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
transactions: { enabled: true },
|
||||
balance: { enabled: false },
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return default config when transactions is not defined', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
balance: { enabled: false },
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('balance and transactions interaction', () => {
|
||||
it('should force transactions to be enabled when balance is enabled but transactions is disabled', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
transactions: { enabled: false },
|
||||
balance: { enabled: true },
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Configuration warning: transactions.enabled=false is incompatible with balance.enabled=true. ' +
|
||||
'Transactions will be enabled to ensure balance tracking works correctly.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not override transactions when balance is enabled and transactions is enabled', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
transactions: { enabled: true },
|
||||
balance: { enabled: true },
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow transactions to be disabled when balance is disabled', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
transactions: { enabled: false },
|
||||
balance: { enabled: false },
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: false });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use default when balance is enabled but transactions is not defined', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
balance: { enabled: true },
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with environment variables for balance', () => {
|
||||
it('should force transactions enabled when CHECK_BALANCE env is true and transactions is false', () => {
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
const appConfig = createTestAppConfig({
|
||||
transactions: { enabled: false },
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Configuration warning: transactions.enabled=false is incompatible with balance.enabled=true. ' +
|
||||
'Transactions will be enabled to ensure balance tracking works correctly.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow transactions disabled when CHECK_BALANCE env is false', () => {
|
||||
process.env.CHECK_BALANCE = 'false';
|
||||
const appConfig = createTestAppConfig({
|
||||
transactions: { enabled: false },
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: false });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty appConfig object', () => {
|
||||
const appConfig = createTestAppConfig();
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: true });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle appConfig with null balance', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
transactions: { enabled: false },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
balance: null as any,
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: false });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle appConfig with undefined balance', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
transactions: { enabled: false },
|
||||
balance: undefined,
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: false });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle appConfig with balance enabled undefined', () => {
|
||||
const appConfig = createTestAppConfig({
|
||||
transactions: { enabled: false },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
balance: { enabled: undefined as any },
|
||||
});
|
||||
const result = getTransactionsConfig(appConfig);
|
||||
expect(result).toEqual({ enabled: false });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBalanceConfig', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.CHECK_BALANCE;
|
||||
delete process.env.START_BALANCE;
|
||||
});
|
||||
|
||||
describe('when appConfig is not provided', () => {
|
||||
it('should return config based on environment variables', () => {
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
process.env.START_BALANCE = '1000';
|
||||
const result = getBalanceConfig();
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty config when no env vars are set', () => {
|
||||
const result = getBalanceConfig();
|
||||
expect(result).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('should handle CHECK_BALANCE true without START_BALANCE', () => {
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
const result = getBalanceConfig();
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle START_BALANCE without CHECK_BALANCE', () => {
|
||||
process.env.START_BALANCE = '5000';
|
||||
const result = getBalanceConfig();
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
startBalance: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when appConfig is provided', () => {
|
||||
it('should merge appConfig balance with env config', () => {
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
process.env.START_BALANCE = '1000';
|
||||
const appConfig = createTestAppConfig({
|
||||
balance: {
|
||||
enabled: false,
|
||||
startBalance: 2000,
|
||||
autoRefillEnabled: true,
|
||||
},
|
||||
});
|
||||
const result = getBalanceConfig(appConfig);
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
startBalance: 2000,
|
||||
autoRefillEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use env config when appConfig balance is not provided', () => {
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
process.env.START_BALANCE = '3000';
|
||||
const appConfig = createTestAppConfig();
|
||||
const result = getBalanceConfig(appConfig);
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
startBalance: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle appConfig with null balance', () => {
|
||||
process.env.CHECK_BALANCE = 'true';
|
||||
const appConfig = createTestAppConfig({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
balance: null as any,
|
||||
});
|
||||
const result = getBalanceConfig(appConfig);
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TEndpoint, TTransactionsConfig } from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TEndpoint } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '~/types';
|
||||
import { isEnabled, normalizeEndpointName } from '~/utils';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
/**
|
||||
* Retrieves the balance configuration object
|
||||
@@ -21,32 +20,6 @@ export function getBalanceConfig(appConfig?: AppConfig): Partial<TCustomConfig['
|
||||
return { ...config, ...(appConfig?.['balance'] ?? {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the transactions configuration object
|
||||
* */
|
||||
export function getTransactionsConfig(appConfig?: AppConfig): TTransactionsConfig {
|
||||
const defaultConfig: TTransactionsConfig = { enabled: true };
|
||||
|
||||
if (!appConfig) {
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
const transactionsConfig = appConfig?.['transactions'] ?? defaultConfig;
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
|
||||
// If balance is enabled but transactions are disabled, force transactions to be enabled
|
||||
// and log a warning
|
||||
if (balanceConfig?.enabled && !transactionsConfig.enabled) {
|
||||
logger.warn(
|
||||
'Configuration warning: transactions.enabled=false is incompatible with balance.enabled=true. ' +
|
||||
'Transactions will be enabled to ensure balance tracking works correctly.',
|
||||
);
|
||||
return { ...transactionsConfig, enabled: true };
|
||||
}
|
||||
|
||||
return transactionsConfig;
|
||||
}
|
||||
|
||||
export const getCustomEndpointConfig = ({
|
||||
endpoint,
|
||||
appConfig,
|
||||
|
||||
@@ -17,6 +17,9 @@ export async function findOpenIDUser({
|
||||
strategyName?: string;
|
||||
}): Promise<{ user: IUser | null; error: string | null; migration: boolean }> {
|
||||
let user = await findUser({ openidId });
|
||||
logger.info(`[${strategyName}] user ${user ? 'found' : 'not found'} with openidId: ${openidId}`);
|
||||
|
||||
// If user not found by openidId, try to find by email
|
||||
if (!user && email) {
|
||||
user = await findUser({ email });
|
||||
logger.warn(
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './helpers';
|
||||
export * from './llm';
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './custom';
|
||||
export * from './google';
|
||||
export * from './openai';
|
||||
export * from './anthropic';
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } from 'librechat-data-provider';
|
||||
import type {
|
||||
InitializeOpenAIOptionsParams,
|
||||
OpenAIOptionsResult,
|
||||
OpenAIConfigOptions,
|
||||
LLMConfigResult,
|
||||
UserKeyValues,
|
||||
} from '~/types';
|
||||
import { createHandleLLMNewToken } from '~/utils/generators';
|
||||
import { getAzureCredentials } from '~/utils/azure';
|
||||
import { isUserProvided } from '~/utils/common';
|
||||
import { resolveHeaders } from '~/utils/env';
|
||||
@@ -27,7 +26,7 @@ export const initializeOpenAI = async ({
|
||||
overrideEndpoint,
|
||||
getUserKeyValues,
|
||||
checkUserKeyExpiry,
|
||||
}: InitializeOpenAIOptionsParams): Promise<OpenAIOptionsResult> => {
|
||||
}: InitializeOpenAIOptionsParams): Promise<LLMConfigResult> => {
|
||||
const { PROXY, OPENAI_API_KEY, AZURE_API_KEY, OPENAI_REVERSE_PROXY, AZURE_OPENAI_BASEURL } =
|
||||
process.env;
|
||||
|
||||
@@ -160,17 +159,8 @@ export const initializeOpenAI = async ({
|
||||
}
|
||||
|
||||
if (streamRate) {
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(streamRate),
|
||||
},
|
||||
];
|
||||
options.llmConfig._lc_stream_delay = streamRate;
|
||||
}
|
||||
|
||||
const result: OpenAIOptionsResult = {
|
||||
...options,
|
||||
streamRate,
|
||||
};
|
||||
|
||||
return result;
|
||||
return options;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ProxyAgent } from 'undici';
|
||||
import { Providers } from '@librechat/agents';
|
||||
import type { AnthropicClientOptions } from '@librechat/agents';
|
||||
import { KnownEndpoints, removeNullishValues, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { KnownEndpoints, removeNullishValues } from 'librechat-data-provider';
|
||||
import type { BindToolsInput } from '@langchain/core/language_models/chat_models';
|
||||
import type { AzureOpenAIInput } from '@langchain/openai';
|
||||
import type { OpenAI } from 'openai';
|
||||
import type * as t from '~/types';
|
||||
import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm';
|
||||
import { sanitizeModelName, constructAzureURL } from '~/utils/azure';
|
||||
import { createFetch } from '~/utils/generators';
|
||||
import { isEnabled } from '~/utils/common';
|
||||
@@ -82,134 +80,6 @@ function hasReasoningParams({
|
||||
);
|
||||
}
|
||||
|
||||
function getOpenAILLMConfig({
|
||||
streaming,
|
||||
modelOptions,
|
||||
addParams,
|
||||
dropParams,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
modelOptions: Partial<t.OpenAIParameters>;
|
||||
addParams?: Record<string, unknown>;
|
||||
dropParams?: string[];
|
||||
}): {
|
||||
llmConfig: Partial<t.ClientOptions> & Partial<t.OpenAIParameters> & Partial<AzureOpenAIInput>;
|
||||
tools: BindToolsInput[];
|
||||
} {
|
||||
const { reasoning_effort, reasoning_summary, verbosity, web_search, ...restModelOptions } =
|
||||
modelOptions;
|
||||
|
||||
const llmConfig = Object.assign(
|
||||
{
|
||||
streaming,
|
||||
model: restModelOptions.model ?? '',
|
||||
},
|
||||
restModelOptions,
|
||||
) as Partial<t.ClientOptions> & Partial<t.OpenAIParameters> & Partial<AzureOpenAIInput>;
|
||||
|
||||
const modelKwargs: Record<string, unknown> = {};
|
||||
let hasModelKwargs = false;
|
||||
|
||||
if (verbosity != null && verbosity !== '') {
|
||||
modelKwargs.verbosity = verbosity;
|
||||
hasModelKwargs = true;
|
||||
}
|
||||
|
||||
if (addParams && typeof addParams === 'object') {
|
||||
for (const [key, value] of Object.entries(addParams)) {
|
||||
if (knownOpenAIParams.has(key)) {
|
||||
(llmConfig as Record<string, unknown>)[key] = value;
|
||||
} else {
|
||||
hasModelKwargs = true;
|
||||
modelKwargs[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hasReasoningParams({ reasoning_effort, reasoning_summary }) &&
|
||||
llmConfig.useResponsesApi === true
|
||||
) {
|
||||
llmConfig.reasoning = removeNullishValues(
|
||||
{
|
||||
effort: reasoning_effort,
|
||||
summary: reasoning_summary,
|
||||
},
|
||||
true,
|
||||
) as OpenAI.Reasoning;
|
||||
} else if (hasReasoningParams({ reasoning_effort })) {
|
||||
llmConfig.reasoning_effort = reasoning_effort;
|
||||
}
|
||||
|
||||
if (llmConfig.max_tokens != null) {
|
||||
llmConfig.maxTokens = llmConfig.max_tokens;
|
||||
delete llmConfig.max_tokens;
|
||||
}
|
||||
|
||||
const tools: BindToolsInput[] = [];
|
||||
|
||||
if (web_search) {
|
||||
llmConfig.useResponsesApi = true;
|
||||
tools.push({ type: 'web_search_preview' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: OpenAI Web Search models do not support any known parameters besides `max_tokens`
|
||||
*/
|
||||
if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model as string)) {
|
||||
const searchExcludeParams = [
|
||||
'frequency_penalty',
|
||||
'presence_penalty',
|
||||
'reasoning',
|
||||
'reasoning_effort',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'top_k',
|
||||
'stop',
|
||||
'logit_bias',
|
||||
'seed',
|
||||
'response_format',
|
||||
'n',
|
||||
'logprobs',
|
||||
'user',
|
||||
];
|
||||
|
||||
const updatedDropParams = dropParams || [];
|
||||
const combinedDropParams = [...new Set([...updatedDropParams, ...searchExcludeParams])];
|
||||
|
||||
combinedDropParams.forEach((param) => {
|
||||
if (param in llmConfig) {
|
||||
delete llmConfig[param as keyof t.ClientOptions];
|
||||
}
|
||||
});
|
||||
} else if (dropParams && Array.isArray(dropParams)) {
|
||||
dropParams.forEach((param) => {
|
||||
if (param in llmConfig) {
|
||||
delete llmConfig[param as keyof t.ClientOptions];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (modelKwargs.verbosity && llmConfig.useResponsesApi === true) {
|
||||
modelKwargs.text = { verbosity: modelKwargs.verbosity };
|
||||
delete modelKwargs.verbosity;
|
||||
}
|
||||
|
||||
if (llmConfig.model && /\bgpt-[5-9]\b/i.test(llmConfig.model) && llmConfig.maxTokens != null) {
|
||||
const paramName =
|
||||
llmConfig.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
|
||||
modelKwargs[paramName] = llmConfig.maxTokens;
|
||||
delete llmConfig.maxTokens;
|
||||
hasModelKwargs = true;
|
||||
}
|
||||
|
||||
if (hasModelKwargs) {
|
||||
llmConfig.modelKwargs = modelKwargs;
|
||||
}
|
||||
|
||||
return { llmConfig, tools };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates configuration options for creating a language model (LLM) instance.
|
||||
* @param apiKey - The API key for authentication.
|
||||
@@ -234,30 +104,34 @@ export function getOpenAIConfig(
|
||||
addParams,
|
||||
dropParams,
|
||||
} = options;
|
||||
|
||||
let llmConfig:
|
||||
| (Partial<t.ClientOptions> & Partial<t.OpenAIParameters> & Partial<AzureOpenAIInput>)
|
||||
| AnthropicClientOptions;
|
||||
let tools: BindToolsInput[];
|
||||
|
||||
if (options.customParams?.defaultParamsEndpoint === EModelEndpoint.anthropic) {
|
||||
const anthropicResult = getAnthropicLLMConfig(apiKey, {
|
||||
modelOptions: _modelOptions,
|
||||
userId: options.userId || '',
|
||||
proxy: options.proxy,
|
||||
reverseProxyUrl: options.reverseProxyUrl,
|
||||
});
|
||||
llmConfig = anthropicResult.llmConfig;
|
||||
tools = anthropicResult.tools;
|
||||
} else {
|
||||
const openaiResult = getOpenAILLMConfig({
|
||||
const { reasoning_effort, reasoning_summary, verbosity, ...modelOptions } = _modelOptions;
|
||||
const llmConfig: Partial<t.ClientOptions> &
|
||||
Partial<t.OpenAIParameters> &
|
||||
Partial<AzureOpenAIInput> = Object.assign(
|
||||
{
|
||||
streaming,
|
||||
modelOptions: _modelOptions,
|
||||
addParams,
|
||||
dropParams,
|
||||
});
|
||||
llmConfig = openaiResult.llmConfig;
|
||||
tools = openaiResult.tools;
|
||||
model: modelOptions.model ?? '',
|
||||
},
|
||||
modelOptions,
|
||||
);
|
||||
|
||||
const modelKwargs: Record<string, unknown> = {};
|
||||
let hasModelKwargs = false;
|
||||
|
||||
if (verbosity != null && verbosity !== '') {
|
||||
modelKwargs.verbosity = verbosity;
|
||||
hasModelKwargs = true;
|
||||
}
|
||||
|
||||
if (addParams && typeof addParams === 'object') {
|
||||
for (const [key, value] of Object.entries(addParams)) {
|
||||
if (knownOpenAIParams.has(key)) {
|
||||
(llmConfig as Record<string, unknown>)[key] = value;
|
||||
} else {
|
||||
hasModelKwargs = true;
|
||||
modelKwargs[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let useOpenRouter = false;
|
||||
@@ -360,6 +234,87 @@ export function getOpenAIConfig(
|
||||
configOptions.organization = process.env.OPENAI_ORGANIZATION;
|
||||
}
|
||||
|
||||
if (
|
||||
hasReasoningParams({ reasoning_effort, reasoning_summary }) &&
|
||||
(llmConfig.useResponsesApi === true || useOpenRouter)
|
||||
) {
|
||||
llmConfig.reasoning = removeNullishValues(
|
||||
{
|
||||
effort: reasoning_effort,
|
||||
summary: reasoning_summary,
|
||||
},
|
||||
true,
|
||||
) as OpenAI.Reasoning;
|
||||
} else if (hasReasoningParams({ reasoning_effort })) {
|
||||
llmConfig.reasoning_effort = reasoning_effort;
|
||||
}
|
||||
|
||||
if (llmConfig.max_tokens != null) {
|
||||
llmConfig.maxTokens = llmConfig.max_tokens;
|
||||
delete llmConfig.max_tokens;
|
||||
}
|
||||
|
||||
const tools: BindToolsInput[] = [];
|
||||
|
||||
if (modelOptions.web_search) {
|
||||
llmConfig.useResponsesApi = true;
|
||||
tools.push({ type: 'web_search_preview' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: OpenAI Web Search models do not support any known parameters besides `max_tokens`
|
||||
*/
|
||||
if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model)) {
|
||||
const searchExcludeParams = [
|
||||
'frequency_penalty',
|
||||
'presence_penalty',
|
||||
'reasoning',
|
||||
'reasoning_effort',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'top_k',
|
||||
'stop',
|
||||
'logit_bias',
|
||||
'seed',
|
||||
'response_format',
|
||||
'n',
|
||||
'logprobs',
|
||||
'user',
|
||||
];
|
||||
|
||||
const updatedDropParams = dropParams || [];
|
||||
const combinedDropParams = [...new Set([...updatedDropParams, ...searchExcludeParams])];
|
||||
|
||||
combinedDropParams.forEach((param) => {
|
||||
if (param in llmConfig) {
|
||||
delete llmConfig[param as keyof t.ClientOptions];
|
||||
}
|
||||
});
|
||||
} else if (dropParams && Array.isArray(dropParams)) {
|
||||
dropParams.forEach((param) => {
|
||||
if (param in llmConfig) {
|
||||
delete llmConfig[param as keyof t.ClientOptions];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (modelKwargs.verbosity && llmConfig.useResponsesApi === true) {
|
||||
modelKwargs.text = { verbosity: modelKwargs.verbosity };
|
||||
delete modelKwargs.verbosity;
|
||||
}
|
||||
|
||||
if (llmConfig.model && /\bgpt-[5-9]\b/i.test(llmConfig.model) && llmConfig.maxTokens != null) {
|
||||
const paramName =
|
||||
llmConfig.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
|
||||
modelKwargs[paramName] = llmConfig.maxTokens;
|
||||
delete llmConfig.maxTokens;
|
||||
hasModelKwargs = true;
|
||||
}
|
||||
|
||||
if (hasModelKwargs) {
|
||||
llmConfig.modelKwargs = modelKwargs;
|
||||
}
|
||||
|
||||
if (directEndpoint === true && configOptions?.baseURL != null) {
|
||||
configOptions.fetch = createFetch({
|
||||
directEndpoint: directEndpoint,
|
||||
|
||||
@@ -280,7 +280,6 @@ Please follow these instructions when using tools from the respective MCP server
|
||||
CallToolResultSchema,
|
||||
{
|
||||
timeout: connection.timeout,
|
||||
resetTimeoutOnProgress: true,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,166 +5,6 @@ import type { JsonSchemaType } from '~/types';
|
||||
import { resolveJsonSchemaRefs, convertJsonSchemaToZod, convertWithResolvedRefs } from '../zod';
|
||||
|
||||
describe('convertJsonSchemaToZod', () => {
|
||||
describe('integer type handling', () => {
|
||||
// Before the fix, integer types were falling through to the default case
|
||||
// and being converted to something like:
|
||||
// "anyOf": [{"anyOf": [{"not": {}}, {}]}, {"type": "null"}]
|
||||
// This test ensures that integer is now properly handled
|
||||
it('should convert integer type to z.number() and NOT to anyOf', () => {
|
||||
const schema = {
|
||||
type: 'integer' as const,
|
||||
};
|
||||
const result = convertJsonSchemaToZod(schema);
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// The schema should be a ZodNumber, not a ZodUnion
|
||||
expect(result).toBeInstanceOf(z.ZodNumber);
|
||||
|
||||
// It should parse numbers correctly
|
||||
expect(result?.parse(42)).toBe(42);
|
||||
expect(result?.parse(3.14)).toBe(3.14); // z.number() accepts floats too
|
||||
});
|
||||
|
||||
it('should NOT convert optional integer fields to anyOf structures', () => {
|
||||
// User reported that before the fix, this schema:
|
||||
// "max_results": { "default": 10, "title": "Max Results", "type": "integer" }
|
||||
// Was being converted to:
|
||||
// "max_results": {"anyOf":[{"anyOf":[{"not":{}},{}]},{"type":"null"}]}
|
||||
const searchSchema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
query: {
|
||||
title: 'Query',
|
||||
type: 'string' as const,
|
||||
},
|
||||
max_results: {
|
||||
default: 10,
|
||||
title: 'Max Results',
|
||||
type: 'integer' as const,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
title: 'searchArguments',
|
||||
};
|
||||
|
||||
const result = convertJsonSchemaToZod(searchSchema);
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Check the shape to ensure max_results is not a union type
|
||||
if (result instanceof z.ZodObject) {
|
||||
const shape = result.shape;
|
||||
expect(shape.query).toBeInstanceOf(z.ZodString);
|
||||
|
||||
// max_results should be ZodOptional(ZodNullable(ZodNumber)), not a ZodUnion
|
||||
const maxResultsSchema = shape.max_results;
|
||||
expect(maxResultsSchema).toBeDefined();
|
||||
|
||||
// It should NOT be a ZodUnion (which would indicate the anyOf structure)
|
||||
expect(maxResultsSchema).not.toBeInstanceOf(z.ZodUnion);
|
||||
|
||||
// Extract the inner type (it's wrapped in ZodOptional and ZodNullable)
|
||||
let innerType = maxResultsSchema;
|
||||
while (innerType instanceof z.ZodOptional || innerType instanceof z.ZodNullable) {
|
||||
if (innerType instanceof z.ZodOptional) {
|
||||
innerType = innerType._def.innerType;
|
||||
} else if (innerType instanceof z.ZodNullable) {
|
||||
innerType = innerType._def.innerType;
|
||||
}
|
||||
}
|
||||
|
||||
// The core type should be ZodNumber
|
||||
expect(innerType).toBeInstanceOf(z.ZodNumber);
|
||||
}
|
||||
|
||||
// Test with valid data
|
||||
const validData = { query: 'test search' };
|
||||
const parsedValid = result?.parse(validData);
|
||||
expect(parsedValid).toBeDefined();
|
||||
expect(parsedValid.query).toBe('test search');
|
||||
// max_results is optional and may not be in the result when not provided
|
||||
|
||||
// Test with max_results included
|
||||
const dataWithMaxResults = { query: 'test search', max_results: 5 };
|
||||
expect(result?.parse(dataWithMaxResults)).toEqual(dataWithMaxResults);
|
||||
|
||||
// Test that integer values work
|
||||
const dataWithIntegerMaxResults = { query: 'test', max_results: 20 };
|
||||
expect(result?.parse(dataWithIntegerMaxResults)).toEqual(dataWithIntegerMaxResults);
|
||||
});
|
||||
|
||||
it('should handle float type correctly', () => {
|
||||
const schema = {
|
||||
type: 'float' as const,
|
||||
};
|
||||
const result = convertJsonSchemaToZod(schema);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.parse(3.14159)).toBe(3.14159);
|
||||
expect(result?.parse(42)).toBe(42); // integers are valid floats
|
||||
});
|
||||
|
||||
it('should handle mixed number, integer, and float in object properties', () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
numberField: { type: 'number' as const },
|
||||
integerField: { type: 'integer' as const },
|
||||
floatField: { type: 'float' as const },
|
||||
},
|
||||
required: ['numberField'],
|
||||
};
|
||||
|
||||
const result = convertJsonSchemaToZod(schema);
|
||||
expect(result).toBeDefined();
|
||||
|
||||
const testData = {
|
||||
numberField: 1.5,
|
||||
integerField: 42,
|
||||
floatField: 3.14,
|
||||
};
|
||||
|
||||
expect(result?.parse(testData)).toEqual(testData);
|
||||
|
||||
// Test with optional fields omitted
|
||||
const minimalData = { numberField: 2.5 };
|
||||
const parsedMinimal = result?.parse(minimalData);
|
||||
expect(parsedMinimal).toBeDefined();
|
||||
expect(parsedMinimal.numberField).toBe(2.5);
|
||||
// Optional fields may be undefined or null when not provided
|
||||
expect(parsedMinimal.integerField ?? null).toBe(null);
|
||||
expect(parsedMinimal.floatField ?? null).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing functionality preservation', () => {
|
||||
it('should still handle string types correctly', () => {
|
||||
const schema = {
|
||||
type: 'string' as const,
|
||||
};
|
||||
const result = convertJsonSchemaToZod(schema);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.parse('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should still handle number types correctly', () => {
|
||||
const schema = {
|
||||
type: 'number' as const,
|
||||
};
|
||||
const result = convertJsonSchemaToZod(schema);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.parse(123.45)).toBe(123.45);
|
||||
});
|
||||
|
||||
it('should still handle boolean types correctly', () => {
|
||||
const schema = {
|
||||
type: 'boolean' as const,
|
||||
};
|
||||
const result = convertJsonSchemaToZod(schema);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.parse(true)).toBe(true);
|
||||
expect(result?.parse(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('primitive types', () => {
|
||||
it('should convert string schema', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { fetch as undiciFetch, Agent } from 'undici';
|
||||
import {
|
||||
StdioClientTransport,
|
||||
getDefaultEnvironment,
|
||||
@@ -12,17 +11,10 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type {
|
||||
RequestInit as UndiciRequestInit,
|
||||
RequestInfo as UndiciRequestInfo,
|
||||
Response as UndiciResponse,
|
||||
} from 'undici';
|
||||
import type { MCPOAuthTokens } from './oauth/types';
|
||||
import { mcpConfig } from './mcpConfig';
|
||||
import type * as t from './types';
|
||||
|
||||
type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
function isStdioOptions(options: t.MCPOptions): options is t.StdioOptions {
|
||||
return 'command' in options;
|
||||
}
|
||||
@@ -149,18 +141,11 @@ export class MCPConnection extends EventEmitter {
|
||||
*/
|
||||
private createFetchFunction(
|
||||
getHeaders: () => Record<string, string> | null | undefined,
|
||||
): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise<UndiciResponse> {
|
||||
return function customFetch(
|
||||
input: UndiciRequestInfo,
|
||||
init?: UndiciRequestInit,
|
||||
): Promise<UndiciResponse> {
|
||||
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
||||
return function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const requestHeaders = getHeaders();
|
||||
const agent = new Agent({
|
||||
bodyTimeout: 0,
|
||||
headersTimeout: 0,
|
||||
});
|
||||
if (!requestHeaders) {
|
||||
return undiciFetch(input, { ...init, dispatcher: agent });
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
let initHeaders: Record<string, string> = {};
|
||||
@@ -174,13 +159,12 @@ export class MCPConnection extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
return undiciFetch(input, {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...initHeaders,
|
||||
...requestHeaders,
|
||||
},
|
||||
dispatcher: agent,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -251,20 +235,13 @@ export class MCPConnection extends EventEmitter {
|
||||
eventSourceInit: {
|
||||
fetch: (url, init) => {
|
||||
const fetchHeaders = new Headers(Object.assign({}, init?.headers, headers));
|
||||
const agent = new Agent({
|
||||
bodyTimeout: 0,
|
||||
headersTimeout: 0,
|
||||
});
|
||||
return undiciFetch(url, {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
dispatcher: agent,
|
||||
headers: fetchHeaders,
|
||||
});
|
||||
},
|
||||
},
|
||||
fetch: this.createFetchFunction(
|
||||
this.getRequestHeaders.bind(this),
|
||||
) as unknown as FetchLike,
|
||||
fetch: this.createFetchFunction(this.getRequestHeaders.bind(this)),
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
@@ -302,9 +279,7 @@ export class MCPConnection extends EventEmitter {
|
||||
headers,
|
||||
signal: abortController.signal,
|
||||
},
|
||||
fetch: this.createFetchFunction(
|
||||
this.getRequestHeaders.bind(this),
|
||||
) as unknown as FetchLike,
|
||||
fetch: this.createFetchFunction(this.getRequestHeaders.bind(this)),
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
|
||||
@@ -350,7 +350,7 @@ export function convertJsonSchemaToZod(
|
||||
} else {
|
||||
zodSchema = z.string();
|
||||
}
|
||||
} else if (schema.type === 'number' || schema.type === 'integer' || schema.type === 'float') {
|
||||
} else if (schema.type === 'number') {
|
||||
zodSchema = z.number();
|
||||
} else if (schema.type === 'boolean') {
|
||||
zodSchema = z.boolean();
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { Dispatcher } from 'undici';
|
||||
import { anthropicSchema } from 'librechat-data-provider';
|
||||
import { AnthropicClientOptions } from '@librechat/agents';
|
||||
|
||||
export type AnthropicParameters = z.infer<typeof anthropicSchema>;
|
||||
|
||||
/**
|
||||
* Configuration options for the getLLMConfig function
|
||||
*/
|
||||
export interface AnthropicConfigOptions {
|
||||
modelOptions?: Partial<AnthropicParameters>;
|
||||
/** The user ID for tracking and personalization */
|
||||
userId?: string;
|
||||
/** Proxy server URL */
|
||||
proxy?: string;
|
||||
/** URL for a reverse proxy, if used */
|
||||
reverseProxyUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for getLLMConfig function
|
||||
*/
|
||||
export interface AnthropicLLMConfigResult {
|
||||
/** Configuration options for creating an Anthropic LLM instance */
|
||||
llmConfig: AnthropicClientOptions & {
|
||||
clientOptions?: {
|
||||
fetchOptions?: { dispatcher: Dispatcher };
|
||||
};
|
||||
};
|
||||
/** Array of tools to be used */
|
||||
tools: Array<{
|
||||
type: string;
|
||||
name?: string;
|
||||
}>;
|
||||
}
|
||||
@@ -51,8 +51,6 @@ export interface AppConfig {
|
||||
turnstileConfig?: TCustomConfig['turnstile'];
|
||||
/** Balance configuration */
|
||||
balance?: TCustomConfig['balance'];
|
||||
/** Transactions configuration */
|
||||
transactions?: TCustomConfig['transactions'];
|
||||
/** Speech configuration */
|
||||
speech?: TCustomConfig['speech'];
|
||||
/** MCP server configuration */
|
||||
|
||||
@@ -22,16 +22,13 @@ export interface OpenAIConfigOptions {
|
||||
streaming?: boolean;
|
||||
addParams?: Record<string, unknown>;
|
||||
dropParams?: string[];
|
||||
customParams?: {
|
||||
defaultParamsEndpoint?: string;
|
||||
};
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export type OpenAIConfiguration = OpenAIClientOptions['configuration'];
|
||||
|
||||
export type ClientOptions = OpenAIClientOptions & {
|
||||
include_reasoning?: boolean;
|
||||
_lc_stream_delay?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -98,10 +95,3 @@ export interface InitializeOpenAIOptionsParams {
|
||||
getUserKeyValues: GetUserKeyValuesFunction;
|
||||
checkUserKeyExpiry: CheckUserKeyExpiryFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended LLM config result with stream rate handling
|
||||
*/
|
||||
export interface OpenAIOptionsResult extends LLMConfigResult {
|
||||
streamRate?: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type JsonSchemaType = {
|
||||
type: 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'array' | 'object';
|
||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||||
enum?: string[];
|
||||
items?: JsonSchemaType;
|
||||
properties?: Record<string, JsonSchemaType>;
|
||||
|
||||
@@ -15,4 +15,3 @@ export * from './text';
|
||||
export { default as Tokenizer } from './tokenizer';
|
||||
export * from './yaml';
|
||||
export * from './http';
|
||||
export * from './tokens';
|
||||
|
||||
@@ -577,7 +577,6 @@ export const interfaceSchema = z
|
||||
|
||||
export type TInterfaceConfig = z.infer<typeof interfaceSchema>;
|
||||
export type TBalanceConfig = z.infer<typeof balanceSchema>;
|
||||
export type TTransactionsConfig = z.infer<typeof transactionsSchema>;
|
||||
|
||||
export const turnstileOptionsSchema = z
|
||||
.object({
|
||||
@@ -602,7 +601,6 @@ export type TStartupConfig = {
|
||||
interface?: TInterfaceConfig;
|
||||
turnstile?: TTurnstileConfig;
|
||||
balance?: TBalanceConfig;
|
||||
transactions?: TTransactionsConfig;
|
||||
discordLoginEnabled: boolean;
|
||||
facebookLoginEnabled: boolean;
|
||||
githubLoginEnabled: boolean;
|
||||
@@ -770,10 +768,6 @@ export const balanceSchema = z.object({
|
||||
refillAmount: z.number().optional().default(10000),
|
||||
});
|
||||
|
||||
export const transactionsSchema = z.object({
|
||||
enabled: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export const memorySchema = z.object({
|
||||
disabled: z.boolean().optional(),
|
||||
validKeys: z.array(z.string()).optional(),
|
||||
@@ -827,7 +821,6 @@ export const configSchema = z.object({
|
||||
})
|
||||
.default({ socialLogins: defaultSocialLogins }),
|
||||
balance: balanceSchema.optional(),
|
||||
transactions: transactionsSchema.optional(),
|
||||
speech: z
|
||||
.object({
|
||||
tts: ttsSchema.optional(),
|
||||
@@ -1573,6 +1566,10 @@ export enum Constants {
|
||||
* This helps inform the UI if the mcp server was previously added.
|
||||
* */
|
||||
mcp_server = 'sys__server__sys',
|
||||
/**
|
||||
* Handoff Tool Name Prefix
|
||||
*/
|
||||
LC_TRANSFER_TO_ = 'lc_transfer_to_',
|
||||
/** Placeholder Agent ID for Ephemeral Agents */
|
||||
EPHEMERAL_AGENT_ID = 'ephemeral',
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ export const defaultAgentFormValues = {
|
||||
tools: [],
|
||||
provider: {},
|
||||
projectIds: [],
|
||||
edges: [],
|
||||
artifacts: '',
|
||||
/** @deprecated Use ACL permissions instead */
|
||||
isCollaborative: false,
|
||||
@@ -619,14 +620,14 @@ export const tConversationSchema = z.object({
|
||||
userLabel: z.string().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
promptPrefix: z.string().nullable().optional(),
|
||||
temperature: z.number().nullable().optional(),
|
||||
temperature: z.number().optional(),
|
||||
topP: z.number().optional(),
|
||||
topK: z.number().optional(),
|
||||
top_p: z.number().optional(),
|
||||
frequency_penalty: z.number().optional(),
|
||||
presence_penalty: z.number().optional(),
|
||||
parentMessageId: z.string().optional(),
|
||||
maxOutputTokens: coerceNumber.nullable().optional(),
|
||||
maxOutputTokens: coerceNumber.optional(),
|
||||
maxContextTokens: coerceNumber.optional(),
|
||||
max_tokens: coerceNumber.optional(),
|
||||
/* Anthropic */
|
||||
@@ -634,7 +635,6 @@ export const tConversationSchema = z.object({
|
||||
system: z.string().optional(),
|
||||
thinking: z.boolean().optional(),
|
||||
thinkingBudget: coerceNumber.optional(),
|
||||
stream: z.boolean().optional(),
|
||||
/* artifacts */
|
||||
artifacts: z.string().optional(),
|
||||
/* google */
|
||||
@@ -1153,8 +1153,6 @@ export const anthropicBaseSchema = tConversationSchema.pick({
|
||||
maxContextTokens: true,
|
||||
web_search: true,
|
||||
fileTokenLimit: true,
|
||||
stop: true,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
export const anthropicSchema = anthropicBaseSchema
|
||||
|
||||
@@ -355,3 +355,45 @@ export type AgentToolType = {
|
||||
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id?: string });
|
||||
|
||||
export type ToolMetadata = TPlugin;
|
||||
|
||||
export interface BaseMessage {
|
||||
content: string;
|
||||
role?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BaseGraphState {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type GraphEdge = {
|
||||
/** Agent ID, use a list for multiple sources */
|
||||
from: string | string[];
|
||||
/** Agent ID, use a list for multiple destinations */
|
||||
to: string | string[];
|
||||
description?: string;
|
||||
/** Can return boolean or specific destination(s) */
|
||||
condition?: (state: BaseGraphState) => boolean | string | string[];
|
||||
/** 'handoff' creates tools for dynamic routing, 'direct' creates direct edges, which also allow parallel execution */
|
||||
edgeType?: 'handoff' | 'direct';
|
||||
/**
|
||||
* For direct edges: Optional prompt to add when transitioning through this edge.
|
||||
* String prompts can include variables like {results} which will be replaced with
|
||||
* messages from startIndex onwards. When {results} is used, excludeResults defaults to true.
|
||||
*
|
||||
* For handoff edges: Description for the input parameter that the handoff tool accepts,
|
||||
* allowing the supervisor to pass specific instructions/context to the transferred agent.
|
||||
*/
|
||||
prompt?: string | ((messages: BaseMessage[], runStartIndex: number) => string | undefined);
|
||||
/**
|
||||
* When true, excludes messages from startIndex when adding prompt.
|
||||
* Automatically set to true when {results} variable is used in prompt.
|
||||
*/
|
||||
excludeResults?: boolean;
|
||||
/**
|
||||
* For handoff edges: Customizes the parameter name for the handoff input.
|
||||
* Defaults to "instructions" if not specified.
|
||||
* Only applies when prompt is provided for handoff edges.
|
||||
*/
|
||||
promptKey?: string;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
import type { AssistantsEndpoint, AgentProvider } from 'src/schemas';
|
||||
import type { Agents, GraphEdge } from './agents';
|
||||
import type { ContentTypes } from './runs';
|
||||
import type { Agents } from './agents';
|
||||
import type { TFile } from './files';
|
||||
import { ArtifactModes } from 'src/artifacts';
|
||||
|
||||
@@ -225,7 +225,9 @@ export type Agent = {
|
||||
/** @deprecated Use ACL permissions instead */
|
||||
isCollaborative?: boolean;
|
||||
tool_resources?: AgentToolResources;
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids?: string[];
|
||||
edges?: GraphEdge[];
|
||||
end_after_tools?: boolean;
|
||||
hide_sequential_outputs?: boolean;
|
||||
artifacts?: ArtifactModes;
|
||||
@@ -251,6 +253,7 @@ export type AgentCreateParams = {
|
||||
} & Pick<
|
||||
Agent,
|
||||
| 'agent_ids'
|
||||
| 'edges'
|
||||
| 'end_after_tools'
|
||||
| 'hide_sequential_outputs'
|
||||
| 'artifacts'
|
||||
@@ -276,6 +279,7 @@ export type AgentUpdateParams = {
|
||||
} & Pick<
|
||||
Agent,
|
||||
| 'agent_ids'
|
||||
| 'edges'
|
||||
| 'end_after_tools'
|
||||
| 'hide_sequential_outputs'
|
||||
| 'artifacts'
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import type * as t from '~/types';
|
||||
import { createTokenMethods } from './token';
|
||||
import tokenSchema from '~/schema/token';
|
||||
|
||||
/** Mocking logger */
|
||||
jest.mock('~/config/winston', () => ({
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
let mongoServer: MongoMemoryServer;
|
||||
let Token: mongoose.Model<t.IToken>;
|
||||
let methods: ReturnType<typeof createTokenMethods>;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
|
||||
/** Register models */
|
||||
Token = mongoose.models.Token || mongoose.model<t.IToken>('Token', tokenSchema);
|
||||
|
||||
/** Initialize methods */
|
||||
methods = createTokenMethods(mongoose);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
});
|
||||
|
||||
describe('Token Methods - Detailed Tests', () => {
|
||||
describe('createToken', () => {
|
||||
test('should create a token with correct expiry time', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const tokenData = {
|
||||
token: 'test-token-123',
|
||||
userId: userId,
|
||||
email: 'test@example.com',
|
||||
expiresIn: 3600, // 1 hour
|
||||
};
|
||||
|
||||
const token = await methods.createToken(tokenData);
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(token.token).toBe(tokenData.token);
|
||||
expect(token.userId.toString()).toBe(userId.toString());
|
||||
expect(token.email).toBe(tokenData.email);
|
||||
|
||||
// Check expiry time
|
||||
const expectedExpiry = new Date(token.createdAt.getTime() + tokenData.expiresIn * 1000);
|
||||
expect(token.expiresAt.getTime()).toBe(expectedExpiry.getTime());
|
||||
});
|
||||
|
||||
test('should create token with all required fields', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const tokenData = {
|
||||
token: 'minimal-token',
|
||||
userId: userId,
|
||||
expiresIn: 1800,
|
||||
};
|
||||
|
||||
const token = await methods.createToken(tokenData);
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(token.token).toBe(tokenData.token);
|
||||
expect(token.userId.toString()).toBe(userId.toString());
|
||||
expect(token.email).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should create token with identifier field', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const tokenData = {
|
||||
token: 'identifier-token',
|
||||
userId: userId,
|
||||
identifier: 'oauth-identifier-123',
|
||||
expiresIn: 7200,
|
||||
};
|
||||
|
||||
const token = await methods.createToken(tokenData);
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(token.identifier).toBe(tokenData.identifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findToken', () => {
|
||||
let user1Id: mongoose.Types.ObjectId;
|
||||
let user2Id: mongoose.Types.ObjectId;
|
||||
|
||||
beforeEach(async () => {
|
||||
user1Id = new mongoose.Types.ObjectId();
|
||||
user2Id = new mongoose.Types.ObjectId();
|
||||
|
||||
await Token.create([
|
||||
{
|
||||
token: 'token-1',
|
||||
userId: user1Id,
|
||||
email: 'user1@example.com',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
{
|
||||
token: 'token-2',
|
||||
userId: user2Id,
|
||||
email: 'user2@example.com',
|
||||
identifier: 'oauth-123',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
{
|
||||
token: 'token-3',
|
||||
userId: user1Id,
|
||||
email: 'user1@example.com',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should find token by token value', async () => {
|
||||
const found = await methods.findToken({ token: 'token-1' });
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.token).toBe('token-1');
|
||||
expect(found?.userId.toString()).toBe(user1Id.toString());
|
||||
});
|
||||
|
||||
test('should find token by userId', async () => {
|
||||
const found = await methods.findToken({ userId: user2Id.toString() });
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.token).toBe('token-2');
|
||||
expect(found?.email).toBe('user2@example.com');
|
||||
});
|
||||
|
||||
test('should find token by email', async () => {
|
||||
const found = await methods.findToken({ email: 'user2@example.com' });
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.token).toBe('token-2');
|
||||
expect(found?.userId.toString()).toBe(user2Id.toString());
|
||||
});
|
||||
|
||||
test('should find token by identifier', async () => {
|
||||
const found = await methods.findToken({ identifier: 'oauth-123' });
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.token).toBe('token-2');
|
||||
expect(found?.identifier).toBe('oauth-123');
|
||||
});
|
||||
|
||||
test('should find token by multiple criteria (AND condition)', async () => {
|
||||
const found = await methods.findToken({
|
||||
userId: user1Id.toString(),
|
||||
email: 'user1@example.com',
|
||||
});
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.token).toBe('token-1'); // Should find first matching
|
||||
});
|
||||
|
||||
test('should return null for non-existent token', async () => {
|
||||
const found = await methods.findToken({ token: 'non-existent' });
|
||||
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
test('should return null when criteria do not match together', async () => {
|
||||
const found = await methods.findToken({
|
||||
userId: user1Id.toString(),
|
||||
email: 'user2@example.com', // Mismatched email
|
||||
});
|
||||
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateToken', () => {
|
||||
let updateUserId: mongoose.Types.ObjectId;
|
||||
|
||||
beforeEach(async () => {
|
||||
updateUserId = new mongoose.Types.ObjectId();
|
||||
await Token.create({
|
||||
token: 'update-token',
|
||||
userId: updateUserId,
|
||||
email: 'update@example.com',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
});
|
||||
});
|
||||
|
||||
test('should update token by token value', async () => {
|
||||
const updated = await methods.updateToken(
|
||||
{ token: 'update-token' },
|
||||
{ email: 'newemail@example.com' },
|
||||
);
|
||||
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated?.email).toBe('newemail@example.com');
|
||||
expect(updated?.userId.toString()).toBe(updateUserId.toString()); // Unchanged
|
||||
});
|
||||
|
||||
test('should update token by userId', async () => {
|
||||
const updated = await methods.updateToken(
|
||||
{ userId: updateUserId.toString() },
|
||||
{ email: 'newemail@example.com' },
|
||||
);
|
||||
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated?.email).toBe('newemail@example.com');
|
||||
expect(updated?.token).toBe('update-token'); // Unchanged
|
||||
});
|
||||
|
||||
test('should return null for non-existent token', async () => {
|
||||
const updated = await methods.updateToken(
|
||||
{ token: 'non-existent' },
|
||||
{ email: 'newemail@example.com' },
|
||||
);
|
||||
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTokens', () => {
|
||||
let user1Id: mongoose.Types.ObjectId;
|
||||
let user2Id: mongoose.Types.ObjectId;
|
||||
let user3Id: mongoose.Types.ObjectId;
|
||||
let oauthUserId: mongoose.Types.ObjectId;
|
||||
|
||||
beforeEach(async () => {
|
||||
user1Id = new mongoose.Types.ObjectId();
|
||||
user2Id = new mongoose.Types.ObjectId();
|
||||
user3Id = new mongoose.Types.ObjectId();
|
||||
oauthUserId = new mongoose.Types.ObjectId();
|
||||
|
||||
await Token.create([
|
||||
{
|
||||
token: 'verify-token-1',
|
||||
userId: user1Id,
|
||||
email: 'user1@example.com',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
{
|
||||
token: 'verify-token-2',
|
||||
userId: user2Id,
|
||||
email: 'user2@example.com',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
{
|
||||
token: 'verify-token-3',
|
||||
userId: user3Id,
|
||||
email: 'user3@example.com',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
{
|
||||
token: 'oauth-token',
|
||||
userId: oauthUserId,
|
||||
identifier: 'oauth-identifier-456',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should delete only tokens matching specific token value', async () => {
|
||||
const result = await methods.deleteTokens({ token: 'verify-token-1' });
|
||||
|
||||
expect(result.deletedCount).toBe(1);
|
||||
|
||||
// Verify other tokens still exist
|
||||
const remainingTokens = await Token.find({});
|
||||
expect(remainingTokens).toHaveLength(3);
|
||||
expect(remainingTokens.find((t) => t.token === 'verify-token-2')).toBeDefined();
|
||||
expect(remainingTokens.find((t) => t.token === 'verify-token-3')).toBeDefined();
|
||||
expect(remainingTokens.find((t) => t.token === 'oauth-token')).toBeDefined();
|
||||
});
|
||||
|
||||
test('should delete only tokens matching specific userId', async () => {
|
||||
// Create another token for user-1
|
||||
await Token.create({
|
||||
token: 'another-user-1-token',
|
||||
userId: user1Id,
|
||||
email: 'user1@example.com',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
});
|
||||
|
||||
const result = await methods.deleteTokens({ userId: user1Id.toString() });
|
||||
|
||||
expect(result.deletedCount).toBe(2); // Both tokens for user-1
|
||||
|
||||
const remainingTokens = await Token.find({});
|
||||
expect(remainingTokens).toHaveLength(3);
|
||||
expect(remainingTokens.every((t) => t.userId.toString() !== user1Id.toString())).toBe(true);
|
||||
});
|
||||
|
||||
test('should delete only tokens matching specific email', async () => {
|
||||
const result = await methods.deleteTokens({ email: 'user2@example.com' });
|
||||
|
||||
expect(result.deletedCount).toBe(1);
|
||||
|
||||
const remainingTokens = await Token.find({});
|
||||
expect(remainingTokens).toHaveLength(3);
|
||||
expect(remainingTokens.find((t) => t.email === 'user2@example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should delete only tokens matching specific identifier', async () => {
|
||||
const result = await methods.deleteTokens({ identifier: 'oauth-identifier-456' });
|
||||
|
||||
expect(result.deletedCount).toBe(1);
|
||||
|
||||
const remainingTokens = await Token.find({});
|
||||
expect(remainingTokens).toHaveLength(3);
|
||||
expect(remainingTokens.find((t) => t.identifier === 'oauth-identifier-456')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not delete tokens when undefined fields are passed', async () => {
|
||||
// This is the critical test case for the bug fix
|
||||
const result = await methods.deleteTokens({
|
||||
token: 'verify-token-1',
|
||||
identifier: undefined,
|
||||
userId: undefined,
|
||||
email: undefined,
|
||||
});
|
||||
|
||||
expect(result.deletedCount).toBe(1); // Only the token with 'verify-token-1'
|
||||
|
||||
const remainingTokens = await Token.find({});
|
||||
expect(remainingTokens).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('should delete multiple tokens when they match OR conditions', async () => {
|
||||
// Create tokens that will match multiple conditions
|
||||
await Token.create({
|
||||
token: 'multi-match',
|
||||
userId: user2Id, // Will match userId condition
|
||||
email: 'different@example.com',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
});
|
||||
|
||||
const result = await methods.deleteTokens({
|
||||
token: 'verify-token-1',
|
||||
userId: user2Id.toString(),
|
||||
});
|
||||
|
||||
// Should delete: verify-token-1 (by token) + verify-token-2 (by userId) + multi-match (by userId)
|
||||
expect(result.deletedCount).toBe(3);
|
||||
|
||||
const remainingTokens = await Token.find({});
|
||||
expect(remainingTokens).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should throw error when no query parameters provided', async () => {
|
||||
await expect(methods.deleteTokens({})).rejects.toThrow(
|
||||
'At least one query parameter must be provided',
|
||||
);
|
||||
|
||||
// Verify no tokens were deleted
|
||||
const tokens = await Token.find({});
|
||||
expect(tokens).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('should handle deletion when no tokens match', async () => {
|
||||
const result = await methods.deleteTokens({ token: 'non-existent-token' });
|
||||
|
||||
expect(result.deletedCount).toBe(0);
|
||||
|
||||
// Verify all tokens still exist
|
||||
const tokens = await Token.find({});
|
||||
expect(tokens).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('should handle email verification scenario correctly', async () => {
|
||||
// This simulates the exact scenario from the bug report
|
||||
// Multiple users register and get verification tokens
|
||||
const newUser1Id = new mongoose.Types.ObjectId();
|
||||
const newUser2Id = new mongoose.Types.ObjectId();
|
||||
const newUser3Id = new mongoose.Types.ObjectId();
|
||||
|
||||
await Token.create([
|
||||
{
|
||||
token: 'email-verify-token-1',
|
||||
userId: newUser1Id,
|
||||
email: 'newuser1@example.com',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 86400000), // 24 hours
|
||||
},
|
||||
{
|
||||
token: 'email-verify-token-2',
|
||||
userId: newUser2Id,
|
||||
email: 'newuser2@example.com',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 86400000),
|
||||
},
|
||||
{
|
||||
token: 'email-verify-token-3',
|
||||
userId: newUser3Id,
|
||||
email: 'newuser3@example.com',
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 86400000),
|
||||
},
|
||||
]);
|
||||
|
||||
// User 2 verifies their email - only their token should be deleted
|
||||
const result = await methods.deleteTokens({ token: 'email-verify-token-2' });
|
||||
|
||||
expect(result.deletedCount).toBe(1);
|
||||
|
||||
// Verify other users' tokens still exist
|
||||
const remainingTokens = await Token.find({ token: { $regex: /^email-verify-token-/ } });
|
||||
expect(remainingTokens).toHaveLength(2);
|
||||
expect(remainingTokens.find((t) => t.token === 'email-verify-token-1')).toBeDefined();
|
||||
expect(remainingTokens.find((t) => t.token === 'email-verify-token-3')).toBeDefined();
|
||||
expect(remainingTokens.find((t) => t.token === 'email-verify-token-2')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,30 +47,13 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) {
|
||||
async function deleteTokens(query: TokenQuery): Promise<TokenDeleteResult> {
|
||||
try {
|
||||
const Token = mongoose.models.Token;
|
||||
const conditions = [];
|
||||
|
||||
if (query.userId !== undefined) {
|
||||
conditions.push({ userId: query.userId });
|
||||
}
|
||||
if (query.token !== undefined) {
|
||||
conditions.push({ token: query.token });
|
||||
}
|
||||
if (query.email !== undefined) {
|
||||
conditions.push({ email: query.email });
|
||||
}
|
||||
if (query.identifier !== undefined) {
|
||||
conditions.push({ identifier: query.identifier });
|
||||
}
|
||||
|
||||
/**
|
||||
* If no conditions are specified, throw an error to prevent accidental deletion of all tokens
|
||||
*/
|
||||
if (conditions.length === 0) {
|
||||
throw new Error('At least one query parameter must be provided');
|
||||
}
|
||||
|
||||
return await Token.deleteMany({
|
||||
$or: conditions,
|
||||
$or: [
|
||||
{ userId: query.userId },
|
||||
{ token: query.token },
|
||||
{ email: query.email },
|
||||
{ identifier: query.identifier },
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.debug('An error occurred while deleting tokens:', error);
|
||||
|
||||
@@ -68,9 +68,14 @@ const agentSchema = new Schema<IAgent>(
|
||||
end_after_tools: {
|
||||
type: Boolean,
|
||||
},
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids: {
|
||||
type: [String],
|
||||
},
|
||||
edges: {
|
||||
type: [{ type: Schema.Types.Mixed }],
|
||||
default: [],
|
||||
},
|
||||
isCollaborative: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Document, Types } from 'mongoose';
|
||||
import type { GraphEdge } from 'librechat-data-provider';
|
||||
|
||||
export interface ISupportContact {
|
||||
name?: string;
|
||||
@@ -27,7 +28,9 @@ export interface IAgent extends Omit<Document, 'model'> {
|
||||
authorName?: string;
|
||||
hide_sequential_outputs?: boolean;
|
||||
end_after_tools?: boolean;
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids?: string[];
|
||||
edges?: GraphEdge[];
|
||||
/** @deprecated Use ACL permissions instead */
|
||||
isCollaborative?: boolean;
|
||||
conversation_starters?: string[];
|
||||
|
||||
Reference in New Issue
Block a user