Compare commits
27 Commits
v0.8.0-rc3
...
refactor/o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e9fb86ba8 | ||
|
|
6137a089b3 | ||
|
|
a928115a84 | ||
|
|
1b14721e75 | ||
|
|
f4301e3fb0 | ||
|
|
fc71c6b358 | ||
|
|
3acb000090 | ||
|
|
f6924567ce | ||
|
|
1fa055b4ec | ||
|
|
0e18faf44b | ||
|
|
74474815aa | ||
|
|
5bec40e574 | ||
|
|
71a3d97058 | ||
|
|
a21891f692 | ||
|
|
cbc38b8263 | ||
|
|
83e2766187 | ||
|
|
8d6110342f | ||
|
|
472c2f14e4 | ||
|
|
8d51f450e8 | ||
|
|
5a2eb74c2d | ||
|
|
fc8e8d2a3b | ||
|
|
d653e96209 | ||
|
|
6bb6d2044b | ||
|
|
76d75030b9 | ||
|
|
abc32e66ce | ||
|
|
2ee68f9e4a | ||
|
|
e49b49af6c |
13
.env.example
13
.env.example
@@ -742,16 +742,3 @@ OPENWEATHER_API_KEY=
|
||||
# JINA_API_KEY=your_jina_api_key
|
||||
# or
|
||||
# COHERE_API_KEY=your_cohere_api_key
|
||||
|
||||
#======================#
|
||||
# MCP Configuration #
|
||||
#======================#
|
||||
|
||||
# Treat 401/403 responses as OAuth requirement when no oauth metadata found
|
||||
# MCP_OAUTH_ON_AUTH_ERROR=true
|
||||
|
||||
# Timeout for OAuth detection requests in milliseconds
|
||||
# MCP_OAUTH_DETECTION_TIMEOUT=5000
|
||||
|
||||
# Cache connection status checks for this many milliseconds to avoid expensive verification
|
||||
# MCP_CONNECTION_CHECK_TTL=60000
|
||||
|
||||
4
.github/CONTRIBUTING.md
vendored
4
.github/CONTRIBUTING.md
vendored
@@ -147,7 +147,7 @@ Apply the following naming conventions to branches, labels, and other Git-relate
|
||||
## 8. Module Import Conventions
|
||||
|
||||
- `npm` packages first,
|
||||
- from longest line (top) to shortest (bottom)
|
||||
- from shortest line (top) to longest (bottom)
|
||||
|
||||
- Followed by typescript types (pertains to data-provider and client workspaces)
|
||||
- longest line (top) to shortest (bottom)
|
||||
@@ -157,8 +157,6 @@ Apply the following naming conventions to branches, labels, and other Git-relate
|
||||
- longest line (top) to shortest (bottom)
|
||||
- imports with alias `~` treated the same as relative import with respect to line length
|
||||
|
||||
**Note:** ESLint will automatically enforce these import conventions when you run `npm run lint --fix` or through pre-commit hooks.
|
||||
|
||||
---
|
||||
|
||||
Please ensure that you adapt this summary to fit the specific context and nuances of your project.
|
||||
|
||||
12
.github/workflows/data-provider.yml
vendored
12
.github/workflows/data-provider.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Publish `librechat-data-provider` to NPM
|
||||
name: Node.js Package
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,12 +6,6 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'packages/data-provider/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: 'Reason for manual trigger'
|
||||
required: false
|
||||
default: 'Manual publish requested'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -20,7 +14,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 16
|
||||
- run: cd packages/data-provider && npm ci
|
||||
- run: cd packages/data-provider && npm run build
|
||||
|
||||
@@ -31,7 +25,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 16
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- run: cd packages/data-provider && npm ci
|
||||
- run: cd packages/data-provider && npm run build
|
||||
|
||||
33
.github/workflows/i18n-unused-keys.yml
vendored
33
.github/workflows/i18n-unused-keys.yml
vendored
@@ -1,10 +1,5 @@
|
||||
name: Detect Unused i18next Strings
|
||||
|
||||
# This workflow checks for unused i18n keys in translation files.
|
||||
# It has special handling for:
|
||||
# - com_ui_special_var_* keys that are dynamically constructed
|
||||
# - com_agents_category_* keys that are stored in the database and used dynamically
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -12,7 +7,6 @@ on:
|
||||
- "api/**"
|
||||
- "packages/data-provider/src/**"
|
||||
- "packages/client/**"
|
||||
- "packages/data-schemas/src/**"
|
||||
|
||||
jobs:
|
||||
detect-unused-i18n-keys:
|
||||
@@ -30,7 +24,7 @@ jobs:
|
||||
|
||||
# Define paths
|
||||
I18N_FILE="client/src/locales/en/translation.json"
|
||||
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client" "packages/data-schemas/src")
|
||||
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client")
|
||||
|
||||
# Check if translation file exists
|
||||
if [[ ! -f "$I18N_FILE" ]]; then
|
||||
@@ -58,31 +52,6 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
# Also check if the key is directly used somewhere
|
||||
if [[ "$FOUND" == false ]]; then
|
||||
for DIR in "${SOURCE_DIRS[@]}"; do
|
||||
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
|
||||
FOUND=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# Special case for agent category keys that are dynamically used from database
|
||||
elif [[ "$KEY" == com_agents_category_* ]]; then
|
||||
# Check if agent category localization is being used
|
||||
for DIR in "${SOURCE_DIRS[@]}"; do
|
||||
# Check for dynamic category label/description usage
|
||||
if grep -r --include=\*.{js,jsx,ts,tsx} -E "category\.(label|description).*startsWith.*['\"]com_" "$DIR" > /dev/null 2>&1 || \
|
||||
# Check for the method that defines these keys
|
||||
grep -r --include=\*.{js,jsx,ts,tsx} "ensureDefaultCategories" "$DIR" > /dev/null 2>&1 || \
|
||||
# Check for direct usage in agentCategory.ts
|
||||
grep -r --include=\*.ts -E "label:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1 || \
|
||||
grep -r --include=\*.ts -E "description:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1; then
|
||||
FOUND=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Also check if the key is directly used somewhere
|
||||
if [[ "$FOUND" == false ]]; then
|
||||
for DIR in "${SOURCE_DIRS[@]}"; do
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -137,4 +137,3 @@ helm/**/.values.yaml
|
||||
/.openai/
|
||||
/.tabnine/
|
||||
/.codeium
|
||||
*.local.md
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -1,4 +1,4 @@
|
||||
# v0.8.0-rc3
|
||||
# v0.8.0-rc2
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
@@ -19,12 +19,7 @@ WORKDIR /app
|
||||
|
||||
USER node
|
||||
|
||||
COPY --chown=node:node package.json package-lock.json ./
|
||||
COPY --chown=node:node api/package.json ./api/package.json
|
||||
COPY --chown=node:node client/package.json ./client/package.json
|
||||
COPY --chown=node:node packages/data-provider/package.json ./packages/data-provider/package.json
|
||||
COPY --chown=node:node packages/data-schemas/package.json ./packages/data-schemas/package.json
|
||||
COPY --chown=node:node packages/api/package.json ./packages/api/package.json
|
||||
COPY --chown=node:node . .
|
||||
|
||||
RUN \
|
||||
# Allow mounting of these files, which have no default
|
||||
@@ -34,11 +29,7 @@ RUN \
|
||||
npm config set fetch-retry-maxtimeout 600000 ; \
|
||||
npm config set fetch-retries 5 ; \
|
||||
npm config set fetch-retry-mintimeout 15000 ; \
|
||||
npm ci --no-audit
|
||||
|
||||
COPY --chown=node:node . .
|
||||
|
||||
RUN \
|
||||
npm install --no-audit; \
|
||||
# React client build
|
||||
NODE_OPTIONS="--max-old-space-size=2048" npm run frontend; \
|
||||
npm prune --production; \
|
||||
@@ -56,4 +47,4 @@ CMD ["npm", "run", "backend"]
|
||||
# WORKDIR /usr/share/nginx/html
|
||||
# COPY --from=node /app/client/dist /usr/share/nginx/html
|
||||
# COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# ENTRYPOINT ["nginx", "-g", "daemon off;"]
|
||||
# ENTRYPOINT ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.8.0-rc3
|
||||
# v0.8.0-rc2
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
||||
15
README.md
15
README.md
@@ -65,10 +65,8 @@
|
||||
|
||||
- 🔦 **Agents & Tools Integration**:
|
||||
- **[LibreChat Agents](https://www.librechat.ai/docs/features/agents)**:
|
||||
- No-Code Custom Assistants: Build specialized, AI-driven helpers
|
||||
- Agent Marketplace: Discover and deploy community-built agents
|
||||
- Collaborative Sharing: Share agents with specific users and groups
|
||||
- Flexible & Extensible: Use MCP Servers, tools, file search, code execution, and more
|
||||
- No-Code Custom Assistants: Build specialized, AI-driven helpers without coding
|
||||
- Flexible & Extensible: Use MCP Servers, tools, file search, code execution, and more
|
||||
- Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API, and more
|
||||
- [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools
|
||||
|
||||
@@ -89,18 +87,15 @@
|
||||
- Create, Save, & Share Custom Presets
|
||||
- Switch between AI Endpoints and Presets mid-chat
|
||||
- Edit, Resubmit, and Continue Messages with Conversation branching
|
||||
- Create and share prompts with specific users and groups
|
||||
- [Fork Messages & Conversations](https://www.librechat.ai/docs/features/fork) for Advanced Context control
|
||||
|
||||
- 💬 **Multimodal & File Interactions**:
|
||||
- Upload and analyze images with Claude 3, GPT-4.5, GPT-4o, o1, Llama-Vision, and Gemini 📸
|
||||
- Chat with Files using Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, & Google 🗃️
|
||||
|
||||
- 🌎 **Multilingual UI**:
|
||||
- English, 中文 (简体), 中文 (繁體), العربية, Deutsch, Español, Français, Italiano
|
||||
- Polski, Português (PT), Português (BR), Русский, 日本語, Svenska, 한국어, Tiếng Việt
|
||||
- Türkçe, Nederlands, עברית, Català, Čeština, Dansk, Eesti, فارسی
|
||||
- Suomi, Magyar, Հայերեն, Bahasa Indonesia, ქართული, Latviešu, ไทย, ئۇيغۇرچە
|
||||
- 🌎 **Multilingual UI**:
|
||||
- English, 中文, Deutsch, Español, Français, Italiano, Polski, Português Brasileiro
|
||||
- Русский, 日本語, Svenska, 한국어, Tiếng Việt, 繁體中文, العربية, Türkçe, Nederlands, עברית
|
||||
|
||||
- 🧠 **Reasoning UI**:
|
||||
- Dynamic Reasoning UI for Chain-of-Thought/Reasoning AI models like DeepSeek-R1
|
||||
|
||||
@@ -37,8 +37,6 @@ class BaseClient {
|
||||
this.conversationId;
|
||||
/** @type {string} */
|
||||
this.responseMessageId;
|
||||
/** @type {string} */
|
||||
this.parentMessageId;
|
||||
/** @type {TAttachment[]} */
|
||||
this.attachments;
|
||||
/** The key for the usage object's input tokens
|
||||
@@ -187,8 +185,7 @@ class BaseClient {
|
||||
this.user = user;
|
||||
const saveOptions = this.getSaveOptions();
|
||||
this.abortController = opts.abortController ?? new AbortController();
|
||||
const requestConvoId = overrideConvoId ?? opts.conversationId;
|
||||
const conversationId = requestConvoId ?? crypto.randomUUID();
|
||||
const conversationId = overrideConvoId ?? opts.conversationId ?? crypto.randomUUID();
|
||||
const parentMessageId = opts.parentMessageId ?? Constants.NO_PARENT;
|
||||
const userMessageId =
|
||||
overrideUserMessageId ?? opts.overrideParentMessageId ?? crypto.randomUUID();
|
||||
@@ -213,12 +210,11 @@ class BaseClient {
|
||||
...opts,
|
||||
user,
|
||||
head,
|
||||
saveOptions,
|
||||
userMessageId,
|
||||
requestConvoId,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
userMessageId,
|
||||
responseMessageId,
|
||||
saveOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,12 +233,11 @@ class BaseClient {
|
||||
const {
|
||||
user,
|
||||
head,
|
||||
saveOptions,
|
||||
userMessageId,
|
||||
requestConvoId,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
userMessageId,
|
||||
responseMessageId,
|
||||
saveOptions,
|
||||
} = await this.setMessageOptions(opts);
|
||||
|
||||
const userMessage = opts.isEdited
|
||||
@@ -264,8 +259,7 @@ class BaseClient {
|
||||
}
|
||||
|
||||
if (typeof opts?.onStart === 'function') {
|
||||
const isNewConvo = !requestConvoId && parentMessageId === Constants.NO_PARENT;
|
||||
opts.onStart(userMessage, responseMessageId, isNewConvo);
|
||||
opts.onStart(userMessage, responseMessageId);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -620,19 +614,15 @@ class BaseClient {
|
||||
this.currentMessages.push(userMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the userMessage is pushed to currentMessages, the parentMessage is the userMessageId.
|
||||
* this only matters when buildMessages is utilizing the parentMessageId, and may vary on implementation
|
||||
*/
|
||||
const parentMessageId = isEdited ? head : userMessage.messageId;
|
||||
this.parentMessageId = parentMessageId;
|
||||
let {
|
||||
prompt: payload,
|
||||
tokenCountMap,
|
||||
promptTokens,
|
||||
} = await this.buildMessages(
|
||||
this.currentMessages,
|
||||
parentMessageId,
|
||||
// When the userMessage is pushed to currentMessages, the parentMessage is the userMessageId.
|
||||
// this only matters when buildMessages is utilizing the parentMessageId, and may vary on implementation
|
||||
isEdited ? head : userMessage.messageId,
|
||||
this.getBuildMessagesOptions(opts),
|
||||
opts,
|
||||
);
|
||||
|
||||
@@ -653,10 +653,8 @@ class OpenAIClient extends BaseClient {
|
||||
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
|
||||
configOptions.baseOptions = {
|
||||
headers: resolveHeaders({
|
||||
headers: {
|
||||
...headers,
|
||||
...configOptions?.baseOptions?.headers,
|
||||
},
|
||||
...headers,
|
||||
...configOptions?.baseOptions?.headers,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -751,7 +749,7 @@ class OpenAIClient extends BaseClient {
|
||||
groupMap,
|
||||
});
|
||||
|
||||
this.options.headers = resolveHeaders({ headers });
|
||||
this.options.headers = resolveHeaders(headers);
|
||||
this.options.reverseProxyUrl = baseURL ?? null;
|
||||
this.langchainProxy = extractBaseURL(this.options.reverseProxyUrl);
|
||||
this.apiKey = azureOptions.azureOpenAIApiKey;
|
||||
@@ -1183,7 +1181,7 @@ ${convo}
|
||||
modelGroupMap,
|
||||
groupMap,
|
||||
});
|
||||
opts.defaultHeaders = resolveHeaders({ headers });
|
||||
opts.defaultHeaders = resolveHeaders(headers);
|
||||
this.langchainProxy = extractBaseURL(baseURL);
|
||||
this.apiKey = azureOptions.azureOpenAIApiKey;
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ describe('AnthropicClient', () => {
|
||||
});
|
||||
|
||||
describe('Claude 4 model headers', () => {
|
||||
it('should add "prompt-caching" and "context-1m" beta headers for claude-sonnet-4 model', () => {
|
||||
it('should add "prompt-caching" beta header for claude-sonnet-4 model', () => {
|
||||
const client = new AnthropicClient('test-api-key');
|
||||
const modelOptions = {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
@@ -255,30 +255,10 @@ describe('AnthropicClient', () => {
|
||||
expect(anthropicClient._options.defaultHeaders).toBeDefined();
|
||||
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
|
||||
'prompt-caching-2024-07-31,context-1m-2025-08-07',
|
||||
'prompt-caching-2024-07-31',
|
||||
);
|
||||
});
|
||||
|
||||
it('should add "prompt-caching" and "context-1m" beta headers for claude-sonnet-4 model formats', () => {
|
||||
const client = new AnthropicClient('test-api-key');
|
||||
const modelVariations = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4-latest',
|
||||
'anthropic/claude-sonnet-4-20250514',
|
||||
];
|
||||
|
||||
modelVariations.forEach((model) => {
|
||||
const modelOptions = { model };
|
||||
client.setOptions({ modelOptions, promptCache: true });
|
||||
const anthropicClient = client.getClient(modelOptions);
|
||||
expect(anthropicClient._options.defaultHeaders).toBeDefined();
|
||||
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
|
||||
'prompt-caching-2024-07-31,context-1m-2025-08-07',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should add "prompt-caching" beta header for claude-opus-4 model', () => {
|
||||
const client = new AnthropicClient('test-api-key');
|
||||
const modelOptions = {
|
||||
@@ -293,6 +273,20 @@ describe('AnthropicClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should add "prompt-caching" beta header for claude-4-sonnet model', () => {
|
||||
const client = new AnthropicClient('test-api-key');
|
||||
const modelOptions = {
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
};
|
||||
client.setOptions({ modelOptions, promptCache: true });
|
||||
const anthropicClient = client.getClient(modelOptions);
|
||||
expect(anthropicClient._options.defaultHeaders).toBeDefined();
|
||||
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
|
||||
'prompt-caching-2024-07-31',
|
||||
);
|
||||
});
|
||||
|
||||
it('should add "prompt-caching" beta header for claude-4-opus model', () => {
|
||||
const client = new AnthropicClient('test-api-key');
|
||||
const modelOptions = {
|
||||
|
||||
@@ -579,8 +579,6 @@ describe('BaseClient', () => {
|
||||
expect(onStart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: 'Hello, world!' }),
|
||||
expect.any(String),
|
||||
/** `isNewConvo` */
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ const { SerpAPI } = require('@langchain/community/tools/serpapi');
|
||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||
const { mcpToolPattern, loadWebSearchAuth } = require('@librechat/api');
|
||||
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
|
||||
const { Tools, Constants, EToolResources, replaceSpecialVars } = require('librechat-data-provider');
|
||||
const { Tools, EToolResources, replaceSpecialVars } = require('librechat-data-provider');
|
||||
const {
|
||||
availableTools,
|
||||
manifestToolMap,
|
||||
@@ -24,9 +24,9 @@ const {
|
||||
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
||||
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
||||
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 { createMCPTool } = require('~/server/services/MCP');
|
||||
|
||||
/**
|
||||
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
|
||||
@@ -123,8 +123,6 @@ const getAuthFields = (toolKey) => {
|
||||
*
|
||||
* @param {object} object
|
||||
* @param {string} object.user
|
||||
* @param {Record<string, Record<string, string>>} [object.userMCPAuthMap]
|
||||
* @param {AbortSignal} [object.signal]
|
||||
* @param {Pick<Agent, 'id' | 'provider' | 'model'>} [object.agent]
|
||||
* @param {string} [object.model]
|
||||
* @param {EModelEndpoint} [object.endpoint]
|
||||
@@ -139,9 +137,7 @@ const loadTools = async ({
|
||||
user,
|
||||
agent,
|
||||
model,
|
||||
signal,
|
||||
endpoint,
|
||||
userMCPAuthMap,
|
||||
tools = [],
|
||||
options = {},
|
||||
functions = true,
|
||||
@@ -235,7 +231,6 @@ const loadTools = async ({
|
||||
/** @type {Record<string, string>} */
|
||||
const toolContextMap = {};
|
||||
const cachedTools = (await getCachedTools({ userId: user, includeGlobal: true })) ?? {};
|
||||
const requestedMCPTools = {};
|
||||
|
||||
for (const tool of tools) {
|
||||
if (tool === Tools.execute_code) {
|
||||
@@ -304,35 +299,14 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||
};
|
||||
continue;
|
||||
} else if (tool && cachedTools && mcpToolPattern.test(tool)) {
|
||||
const [toolName, serverName] = tool.split(Constants.mcp_delimiter);
|
||||
if (toolName === Constants.mcp_all) {
|
||||
const currentMCPGenerator = async (index) =>
|
||||
createMCPTools({
|
||||
req: options.req,
|
||||
res: options.res,
|
||||
index,
|
||||
serverName,
|
||||
userMCPAuthMap,
|
||||
model: agent?.model ?? model,
|
||||
provider: agent?.provider ?? endpoint,
|
||||
signal,
|
||||
});
|
||||
requestedMCPTools[serverName] = [currentMCPGenerator];
|
||||
continue;
|
||||
}
|
||||
const currentMCPGenerator = async (index) =>
|
||||
requestedTools[tool] = async () =>
|
||||
createMCPTool({
|
||||
index,
|
||||
req: options.req,
|
||||
res: options.res,
|
||||
toolKey: tool,
|
||||
userMCPAuthMap,
|
||||
model: agent?.model ?? model,
|
||||
provider: agent?.provider ?? endpoint,
|
||||
signal,
|
||||
});
|
||||
requestedMCPTools[serverName] = requestedMCPTools[serverName] || [];
|
||||
requestedMCPTools[serverName].push(currentMCPGenerator);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -372,34 +346,6 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||
}
|
||||
|
||||
const loadedTools = (await Promise.all(toolPromises)).flatMap((plugin) => plugin || []);
|
||||
const mcpToolPromises = [];
|
||||
/** MCP server tools are initialized sequentially by server */
|
||||
let index = -1;
|
||||
for (const [serverName, generators] of Object.entries(requestedMCPTools)) {
|
||||
index++;
|
||||
for (const generator of generators) {
|
||||
try {
|
||||
if (generator && generators.length === 1) {
|
||||
mcpToolPromises.push(
|
||||
generator(index).catch((error) => {
|
||||
logger.error(`Error loading ${serverName} tools:`, error);
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const mcpTool = await generator(index);
|
||||
if (Array.isArray(mcpTool)) {
|
||||
loadedTools.push(...mcpTool);
|
||||
} else if (mcpTool) {
|
||||
loadedTools.push(mcpTool);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error loading MCP tool for server ${serverName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadedTools.push(...(await Promise.all(mcpToolPromises)).flatMap((plugin) => plugin || []));
|
||||
return { loadedTools, toolContextMap };
|
||||
};
|
||||
|
||||
|
||||
3
api/cache/cacheConfig.js
vendored
3
api/cache/cacheConfig.js
vendored
@@ -52,8 +52,7 @@ const cacheConfig = {
|
||||
REDIS_CONNECT_TIMEOUT: math(process.env.REDIS_CONNECT_TIMEOUT, 10000),
|
||||
/** Queue commands when disconnected */
|
||||
REDIS_ENABLE_OFFLINE_QUEUE: isEnabled(process.env.REDIS_ENABLE_OFFLINE_QUEUE ?? 'true'),
|
||||
/** Enable redis cluster without the need of multiple URIs */
|
||||
USE_REDIS_CLUSTER: isEnabled(process.env.USE_REDIS_CLUSTER ?? 'false'),
|
||||
|
||||
CI: isEnabled(process.env.CI),
|
||||
DEBUG_MEMORY_CACHE: isEnabled(process.env.DEBUG_MEMORY_CACHE),
|
||||
|
||||
|
||||
33
api/cache/cacheConfig.spec.js
vendored
33
api/cache/cacheConfig.spec.js
vendored
@@ -14,7 +14,6 @@ describe('cacheConfig', () => {
|
||||
delete process.env.REDIS_KEY_PREFIX_VAR;
|
||||
delete process.env.REDIS_KEY_PREFIX;
|
||||
delete process.env.USE_REDIS;
|
||||
delete process.env.USE_REDIS_CLUSTER;
|
||||
delete process.env.REDIS_PING_INTERVAL;
|
||||
delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES;
|
||||
|
||||
@@ -102,38 +101,6 @@ describe('cacheConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('USE_REDIS_CLUSTER configuration', () => {
|
||||
test('should default to false when USE_REDIS_CLUSTER is not set', () => {
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.USE_REDIS_CLUSTER).toBe(false);
|
||||
});
|
||||
|
||||
test('should be false when USE_REDIS_CLUSTER is set to false', () => {
|
||||
process.env.USE_REDIS_CLUSTER = 'false';
|
||||
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.USE_REDIS_CLUSTER).toBe(false);
|
||||
});
|
||||
|
||||
test('should be true when USE_REDIS_CLUSTER is set to true', () => {
|
||||
process.env.USE_REDIS_CLUSTER = 'true';
|
||||
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.USE_REDIS_CLUSTER).toBe(true);
|
||||
});
|
||||
|
||||
test('should work with USE_REDIS enabled and REDIS_URI set', () => {
|
||||
process.env.USE_REDIS_CLUSTER = 'true';
|
||||
process.env.USE_REDIS = 'true';
|
||||
process.env.REDIS_URI = 'redis://localhost:6379';
|
||||
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.USE_REDIS_CLUSTER).toBe(true);
|
||||
expect(cacheConfig.USE_REDIS).toBe(true);
|
||||
expect(cacheConfig.REDIS_URI).toBe('redis://localhost:6379');
|
||||
});
|
||||
});
|
||||
|
||||
describe('REDIS_CA file reading', () => {
|
||||
test('should be null when REDIS_CA is not set', () => {
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
|
||||
1
api/cache/getLogStores.js
vendored
1
api/cache/getLogStores.js
vendored
@@ -31,6 +31,7 @@ const namespaces = {
|
||||
[CacheKeys.SAML_SESSION]: sessionCache(CacheKeys.SAML_SESSION),
|
||||
|
||||
[CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
|
||||
[CacheKeys.MCP_TOOLS]: standardCache(CacheKeys.MCP_TOOLS),
|
||||
[CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
|
||||
[CacheKeys.STATIC_CONFIG]: standardCache(CacheKeys.STATIC_CONFIG),
|
||||
[CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ),
|
||||
|
||||
45
api/cache/redisClients.js
vendored
45
api/cache/redisClients.js
vendored
@@ -38,7 +38,7 @@ if (cacheConfig.USE_REDIS) {
|
||||
const targetError = 'READONLY';
|
||||
if (err.message.includes(targetError)) {
|
||||
logger.warn('ioredis reconnecting due to READONLY error');
|
||||
return 2; // Return retry delay instead of boolean
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -48,29 +48,26 @@ if (cacheConfig.USE_REDIS) {
|
||||
};
|
||||
|
||||
ioredisClient =
|
||||
urls.length === 1 && !cacheConfig.USE_REDIS_CLUSTER
|
||||
urls.length === 1
|
||||
? new IoRedis(cacheConfig.REDIS_URI, redisOptions)
|
||||
: new IoRedis.Cluster(
|
||||
urls.map((url) => ({ host: url.hostname, port: parseInt(url.port, 10) || 6379 })),
|
||||
{
|
||||
redisOptions,
|
||||
clusterRetryStrategy: (times) => {
|
||||
if (
|
||||
cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 &&
|
||||
times > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS
|
||||
) {
|
||||
logger.error(
|
||||
`ioredis cluster giving up after ${cacheConfig.REDIS_RETRY_MAX_ATTEMPTS} reconnection attempts`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const delay = Math.min(times * 100, cacheConfig.REDIS_RETRY_MAX_DELAY);
|
||||
logger.info(`ioredis cluster reconnecting... attempt ${times}, delay ${delay}ms`);
|
||||
return delay;
|
||||
},
|
||||
enableOfflineQueue: cacheConfig.REDIS_ENABLE_OFFLINE_QUEUE,
|
||||
: new IoRedis.Cluster(cacheConfig.REDIS_URI, {
|
||||
redisOptions,
|
||||
clusterRetryStrategy: (times) => {
|
||||
if (
|
||||
cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 &&
|
||||
times > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS
|
||||
) {
|
||||
logger.error(
|
||||
`ioredis cluster giving up after ${cacheConfig.REDIS_RETRY_MAX_ATTEMPTS} reconnection attempts`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const delay = Math.min(times * 100, cacheConfig.REDIS_RETRY_MAX_DELAY);
|
||||
logger.info(`ioredis cluster reconnecting... attempt ${times}, delay ${delay}ms`);
|
||||
return delay;
|
||||
},
|
||||
);
|
||||
enableOfflineQueue: cacheConfig.REDIS_ENABLE_OFFLINE_QUEUE,
|
||||
});
|
||||
|
||||
ioredisClient.on('error', (err) => {
|
||||
logger.error('ioredis client error:', err);
|
||||
@@ -148,10 +145,10 @@ if (cacheConfig.USE_REDIS) {
|
||||
};
|
||||
|
||||
keyvRedisClient =
|
||||
urls.length === 1 && !cacheConfig.USE_REDIS_CLUSTER
|
||||
urls.length === 1
|
||||
? createClient({ url: cacheConfig.REDIS_URI, ...redisOptions })
|
||||
: createCluster({
|
||||
rootNodes: urls.map((url) => ({ url: url.href })),
|
||||
rootNodes: cacheConfig.REDIS_URI.split(',').map((url) => ({ url })),
|
||||
defaults: redisOptions,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
const { MCPManager, FlowStateManager } = require('@librechat/api');
|
||||
const { EventSource } = require('eventsource');
|
||||
const { Time } = require('librechat-data-provider');
|
||||
const { MCPManager, FlowStateManager } = require('@librechat/api');
|
||||
const logger = require('./winston');
|
||||
|
||||
global.EventSource = EventSource;
|
||||
|
||||
/** @type {MCPManager} */
|
||||
let mcpManager = null;
|
||||
let flowManager = null;
|
||||
|
||||
/**
|
||||
* @param {string} [userId] - Optional user ID, to avoid disconnecting the current user.
|
||||
* @returns {MCPManager}
|
||||
*/
|
||||
function getMCPManager(userId) {
|
||||
if (!mcpManager) {
|
||||
mcpManager = MCPManager.getInstance();
|
||||
} else {
|
||||
mcpManager.checkIdleConnections(userId);
|
||||
}
|
||||
return mcpManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Keyv} flowsCache
|
||||
* @returns {FlowStateManager}
|
||||
@@ -23,7 +37,6 @@ function getFlowStateManager(flowsCache) {
|
||||
|
||||
module.exports = {
|
||||
logger,
|
||||
createMCPManager: MCPManager.createInstance,
|
||||
getMCPManager: MCPManager.getInstance,
|
||||
getMCPManager,
|
||||
getFlowStateManager,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ const mongoose = require('mongoose');
|
||||
const crypto = require('node:crypto');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ResourceType, SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
|
||||
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_all, mcp_delimiter } =
|
||||
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
||||
require('librechat-data-provider').Constants;
|
||||
const {
|
||||
removeAgentFromAllProjects,
|
||||
@@ -78,7 +78,6 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
|
||||
tools.push(Tools.web_search);
|
||||
}
|
||||
|
||||
const addedServers = new Set();
|
||||
if (mcpServers.size > 0) {
|
||||
for (const toolName of Object.keys(availableTools)) {
|
||||
if (!toolName.includes(mcp_delimiter)) {
|
||||
@@ -86,17 +85,9 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
|
||||
}
|
||||
const mcpServer = toolName.split(mcp_delimiter)?.[1];
|
||||
if (mcpServer && mcpServers.has(mcpServer)) {
|
||||
addedServers.add(mcpServer);
|
||||
tools.push(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
for (const mcpServer of mcpServers) {
|
||||
if (addedServers.has(mcpServer)) {
|
||||
continue;
|
||||
}
|
||||
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
|
||||
}
|
||||
}
|
||||
|
||||
const instructions = req.body.promptPrefix;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.0-rc3",
|
||||
"version": "v0.8.0-rc2",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -49,12 +49,12 @@
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/openai": "^0.5.18",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.76",
|
||||
"@librechat/agents": "^2.4.75",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||
"axios": "^1.8.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/** WeakMap to hold temporary data associated with requests */
|
||||
// WeakMap to hold temporary data associated with requests
|
||||
const requestDataMap = new WeakMap();
|
||||
|
||||
const FinalizationRegistry = global.FinalizationRegistry || null;
|
||||
@@ -23,7 +23,7 @@ const clientRegistry = FinalizationRegistry
|
||||
} else {
|
||||
logger.debug('[FinalizationRegistry] Cleaning up client');
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
})
|
||||
@@ -55,9 +55,6 @@ function disposeClient(client) {
|
||||
if (client.responseMessageId) {
|
||||
client.responseMessageId = null;
|
||||
}
|
||||
if (client.parentMessageId) {
|
||||
client.parentMessageId = null;
|
||||
}
|
||||
if (client.message_file_map) {
|
||||
client.message_file_map = null;
|
||||
}
|
||||
@@ -337,7 +334,7 @@ function disposeClient(client) {
|
||||
}
|
||||
}
|
||||
client.options = null;
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Ignore errors during disposal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ const refreshController = async (req, res) => {
|
||||
}
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
const user = await getUserById(payload.id, '-password -__v -totpSecret -backupCodes');
|
||||
const user = await getUserById(payload.id, '-password -__v -totpSecret');
|
||||
if (!user) {
|
||||
return res.status(401).redirect('/login');
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ const getUserEffectivePermissions = async (req, res) => {
|
||||
*/
|
||||
const searchPrincipals = async (req, res) => {
|
||||
try {
|
||||
const { q: query, limit = 20, types } = req.query;
|
||||
const { q: query, limit = 20, type } = req.query;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return res.status(400).json({
|
||||
@@ -379,34 +379,22 @@ const searchPrincipals = async (req, res) => {
|
||||
}
|
||||
|
||||
const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50);
|
||||
const typeFilter = [PrincipalType.USER, PrincipalType.GROUP, PrincipalType.ROLE].includes(type)
|
||||
? type
|
||||
: null;
|
||||
|
||||
let typeFilters = null;
|
||||
if (types) {
|
||||
const typesArray = Array.isArray(types) ? types : types.split(',');
|
||||
const validTypes = typesArray.filter((t) =>
|
||||
[PrincipalType.USER, PrincipalType.GROUP, PrincipalType.ROLE].includes(t),
|
||||
);
|
||||
typeFilters = validTypes.length > 0 ? validTypes : null;
|
||||
}
|
||||
|
||||
const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilters);
|
||||
const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilter);
|
||||
let allPrincipals = [...localResults];
|
||||
|
||||
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
|
||||
|
||||
if (useEntraId && localResults.length < searchLimit) {
|
||||
try {
|
||||
let graphType = 'all';
|
||||
if (typeFilters && typeFilters.length === 1) {
|
||||
const graphTypeMap = {
|
||||
[PrincipalType.USER]: 'users',
|
||||
[PrincipalType.GROUP]: 'groups',
|
||||
};
|
||||
const mappedType = graphTypeMap[typeFilters[0]];
|
||||
if (mappedType) {
|
||||
graphType = mappedType;
|
||||
}
|
||||
}
|
||||
const graphTypeMap = {
|
||||
user: 'users',
|
||||
group: 'groups',
|
||||
null: 'all',
|
||||
};
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
const accessToken =
|
||||
@@ -417,7 +405,7 @@ const searchPrincipals = async (req, res) => {
|
||||
accessToken,
|
||||
req.user.openidId,
|
||||
query.trim(),
|
||||
graphType,
|
||||
graphTypeMap[typeFilter],
|
||||
searchLimit - localResults.length,
|
||||
);
|
||||
|
||||
@@ -448,22 +436,21 @@ const searchPrincipals = async (req, res) => {
|
||||
_searchScore: calculateRelevanceScore(item, query.trim()),
|
||||
}));
|
||||
|
||||
const finalResults = sortPrincipalsByRelevance(scoredResults)
|
||||
allPrincipals = sortPrincipalsByRelevance(scoredResults)
|
||||
.slice(0, searchLimit)
|
||||
.map((result) => {
|
||||
const { _searchScore, ...resultWithoutScore } = result;
|
||||
return resultWithoutScore;
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
query: query.trim(),
|
||||
limit: searchLimit,
|
||||
types: typeFilters,
|
||||
results: finalResults,
|
||||
count: finalResults.length,
|
||||
type: typeFilter,
|
||||
results: allPrincipals,
|
||||
count: allPrincipals.length,
|
||||
sources: {
|
||||
local: finalResults.filter((r) => r.source === 'local').length,
|
||||
entra: finalResults.filter((r) => r.source === 'entra').length,
|
||||
local: allPrincipals.filter((r) => r.source === 'local').length,
|
||||
entra: allPrincipals.filter((r) => r.source === 'entra').length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,18 +4,11 @@ const {
|
||||
getToolkitKey,
|
||||
checkPluginAuth,
|
||||
filterUniquePlugins,
|
||||
convertMCPToolToPlugin,
|
||||
convertMCPToolsToPlugins,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
getCachedTools,
|
||||
setCachedTools,
|
||||
mergeUserTools,
|
||||
getCustomConfig,
|
||||
} = require('~/server/services/Config');
|
||||
const { loadAndFormatTools } = require('~/server/services/ToolService');
|
||||
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
|
||||
const { availableTools, toolkits } = require('~/app/clients/tools');
|
||||
const { getMCPManager } = require('~/config');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
const getAvailablePluginsController = async (req, res) => {
|
||||
@@ -29,7 +22,6 @@ const getAvailablePluginsController = async (req, res) => {
|
||||
|
||||
/** @type {{ filteredTools: string[], includedTools: string[] }} */
|
||||
const { filteredTools = [], includedTools = [] } = req.app.locals;
|
||||
/** @type {import('@librechat/api').LCManifestTool[]} */
|
||||
const pluginManifest = availableTools;
|
||||
|
||||
const uniquePlugins = filterUniquePlugins(pluginManifest);
|
||||
@@ -55,6 +47,45 @@ const getAvailablePluginsController = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
function createServerToolsCallback() {
|
||||
/**
|
||||
* @param {string} serverName
|
||||
* @param {TPlugin[] | null} serverTools
|
||||
*/
|
||||
return async function (serverName, serverTools) {
|
||||
try {
|
||||
const mcpToolsCache = getLogStores(CacheKeys.MCP_TOOLS);
|
||||
if (!serverName || !mcpToolsCache) {
|
||||
return;
|
||||
}
|
||||
await mcpToolsCache.set(serverName, serverTools);
|
||||
logger.debug(`MCP tools for ${serverName} added to cache.`);
|
||||
} catch (error) {
|
||||
logger.error('Error retrieving MCP tools from cache:', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createGetServerTools() {
|
||||
/**
|
||||
* Retrieves cached server tools
|
||||
* @param {string} serverName
|
||||
* @returns {Promise<TPlugin[] | null>}
|
||||
*/
|
||||
return async function (serverName) {
|
||||
try {
|
||||
const mcpToolsCache = getLogStores(CacheKeys.MCP_TOOLS);
|
||||
if (!mcpToolsCache) {
|
||||
return null;
|
||||
}
|
||||
return await mcpToolsCache.get(serverName);
|
||||
} catch (error) {
|
||||
logger.error('Error retrieving MCP tools from cache:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and returns a list of available tools, either from a cache or by reading a plugin manifest file.
|
||||
*
|
||||
@@ -70,18 +101,11 @@ const getAvailablePluginsController = async (req, res) => {
|
||||
const getAvailableTools = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
logger.warn('[getAvailableTools] User ID not found in request');
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const customConfig = await getCustomConfig();
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
|
||||
const cachedUserTools = await getCachedTools({ userId });
|
||||
const userPlugins =
|
||||
cachedUserTools != null
|
||||
? convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig })
|
||||
: undefined;
|
||||
const userPlugins = convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig });
|
||||
|
||||
if (cachedToolsArray != null && userPlugins != null) {
|
||||
const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
|
||||
@@ -89,51 +113,25 @@ const getAvailableTools = async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {Record<string, FunctionTool> | null} Get tool definitions to filter which tools are actually available */
|
||||
let toolDefinitions = await getCachedTools({ includeGlobal: true });
|
||||
let prelimCachedTools;
|
||||
|
||||
// TODO: this is a temp fix until app config is refactored
|
||||
if (!toolDefinitions) {
|
||||
toolDefinitions = loadAndFormatTools({
|
||||
adminFilter: req.app.locals?.filteredTools,
|
||||
adminIncluded: req.app.locals?.includedTools,
|
||||
directory: req.app.locals?.paths.structuredTools,
|
||||
});
|
||||
prelimCachedTools = toolDefinitions;
|
||||
}
|
||||
|
||||
/** @type {import('@librechat/api').LCManifestTool[]} */
|
||||
// If not in cache, build from manifest
|
||||
let pluginManifest = availableTools;
|
||||
if (customConfig?.mcpServers != null) {
|
||||
try {
|
||||
const mcpManager = getMCPManager();
|
||||
const mcpTools = await mcpManager.getAllToolFunctions(userId);
|
||||
prelimCachedTools = prelimCachedTools ?? {};
|
||||
for (const [toolKey, toolData] of Object.entries(mcpTools)) {
|
||||
const plugin = convertMCPToolToPlugin({
|
||||
toolKey,
|
||||
toolData,
|
||||
customConfig,
|
||||
});
|
||||
if (plugin) {
|
||||
pluginManifest.push(plugin);
|
||||
}
|
||||
prelimCachedTools[toolKey] = toolData;
|
||||
}
|
||||
await mergeUserTools({ userId, cachedUserTools, userTools: prelimCachedTools });
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'[getAvailableTools] Error loading MCP Tools, servers may still be initializing:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else if (prelimCachedTools != null) {
|
||||
await setCachedTools(prelimCachedTools, { isGlobal: true });
|
||||
const mcpManager = getMCPManager();
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
|
||||
const serverToolsCallback = createServerToolsCallback();
|
||||
const getServerTools = createGetServerTools();
|
||||
const mcpTools = await mcpManager.loadManifestTools({
|
||||
flowManager,
|
||||
serverToolsCallback,
|
||||
getServerTools,
|
||||
});
|
||||
pluginManifest = [...mcpTools, ...pluginManifest];
|
||||
}
|
||||
|
||||
/** @type {TPlugin[]} Deduplicate and authenticate plugins */
|
||||
/** @type {TPlugin[]} */
|
||||
const uniquePlugins = filterUniquePlugins(pluginManifest);
|
||||
|
||||
const authenticatedPlugins = uniquePlugins.map((plugin) => {
|
||||
if (checkPluginAuth(plugin)) {
|
||||
return { ...plugin, authenticated: true };
|
||||
@@ -142,7 +140,8 @@ const getAvailableTools = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Filter plugins based on availability and add MCP-specific auth config */
|
||||
const toolDefinitions = (await getCachedTools({ includeGlobal: true })) || {};
|
||||
|
||||
const toolsOutput = [];
|
||||
for (const plugin of authenticatedPlugins) {
|
||||
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
|
||||
@@ -158,36 +157,41 @@ const getAvailableTools = async (req, res) => {
|
||||
|
||||
const toolToAdd = { ...plugin };
|
||||
|
||||
if (plugin.pluginKey.includes(Constants.mcp_delimiter)) {
|
||||
const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
|
||||
const serverName = parts[parts.length - 1];
|
||||
const serverConfig = customConfig?.mcpServers?.[serverName];
|
||||
if (!plugin.pluginKey.includes(Constants.mcp_delimiter)) {
|
||||
toolsOutput.push(toolToAdd);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (serverConfig?.customUserVars) {
|
||||
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
||||
if (customVarKeys.length === 0) {
|
||||
toolToAdd.authConfig = [];
|
||||
toolToAdd.authenticated = true;
|
||||
} else {
|
||||
toolToAdd.authConfig = Object.entries(serverConfig.customUserVars).map(
|
||||
([key, value]) => ({
|
||||
authField: key,
|
||||
label: value.title || key,
|
||||
description: value.description || '',
|
||||
}),
|
||||
);
|
||||
toolToAdd.authenticated = false;
|
||||
}
|
||||
}
|
||||
const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
|
||||
const serverName = parts[parts.length - 1];
|
||||
const serverConfig = customConfig?.mcpServers?.[serverName];
|
||||
|
||||
if (!serverConfig?.customUserVars) {
|
||||
toolsOutput.push(toolToAdd);
|
||||
continue;
|
||||
}
|
||||
|
||||
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
||||
|
||||
if (customVarKeys.length === 0) {
|
||||
toolToAdd.authConfig = [];
|
||||
toolToAdd.authenticated = true;
|
||||
} else {
|
||||
toolToAdd.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
|
||||
authField: key,
|
||||
label: value.title || key,
|
||||
description: value.description || '',
|
||||
}));
|
||||
toolToAdd.authenticated = false;
|
||||
}
|
||||
|
||||
toolsOutput.push(toolToAdd);
|
||||
}
|
||||
|
||||
const finalTools = filterUniquePlugins(toolsOutput);
|
||||
await cache.set(CacheKeys.TOOLS, finalTools);
|
||||
|
||||
const dedupedTools = filterUniquePlugins([...(userPlugins ?? []), ...finalTools]);
|
||||
const dedupedTools = filterUniquePlugins([...userPlugins, ...finalTools]);
|
||||
|
||||
res.status(200).json(dedupedTools);
|
||||
} catch (error) {
|
||||
logger.error('[getAvailableTools]', error);
|
||||
|
||||
@@ -13,18 +13,15 @@ jest.mock('@librechat/data-schemas', () => ({
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getCustomConfig: jest.fn(),
|
||||
getCachedTools: jest.fn(),
|
||||
setCachedTools: jest.fn(),
|
||||
mergeUserTools: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/ToolService', () => ({
|
||||
getToolkitKey: jest.fn(),
|
||||
loadAndFormatTools: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPManager: jest.fn(() => ({
|
||||
loadAllManifestTools: jest.fn().mockResolvedValue([]),
|
||||
loadManifestTools: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
getFlowStateManager: jest.fn(),
|
||||
}));
|
||||
@@ -38,31 +35,31 @@ jest.mock('~/cache', () => ({
|
||||
getLogStores: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
getToolkitKey: jest.fn(),
|
||||
checkPluginAuth: jest.fn(),
|
||||
filterUniquePlugins: jest.fn(),
|
||||
convertMCPToolsToPlugins: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import the actual module with the function we want to test
|
||||
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
|
||||
const { loadAndFormatTools } = require('~/server/services/ToolService');
|
||||
const {
|
||||
filterUniquePlugins,
|
||||
checkPluginAuth,
|
||||
convertMCPToolsToPlugins,
|
||||
getToolkitKey,
|
||||
} = require('@librechat/api');
|
||||
|
||||
describe('PluginController', () => {
|
||||
let mockReq, mockRes, mockCache;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockReq = {
|
||||
user: { id: 'test-user-id' },
|
||||
app: {
|
||||
locals: {
|
||||
paths: { structuredTools: '/mock/path' },
|
||||
filteredTools: null,
|
||||
includedTools: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
mockReq = { user: { id: 'test-user-id' } };
|
||||
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||||
mockCache = { get: jest.fn(), set: jest.fn() };
|
||||
getLogStores.mockReturnValue(mockCache);
|
||||
|
||||
// Clear availableTools and toolkits arrays before each test
|
||||
require('~/app/clients/tools').availableTools.length = 0;
|
||||
require('~/app/clients/tools').toolkits.length = 0;
|
||||
});
|
||||
|
||||
describe('getAvailablePluginsController', () => {
|
||||
@@ -71,39 +68,38 @@ describe('PluginController', () => {
|
||||
});
|
||||
|
||||
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
|
||||
// Add plugins with duplicates to availableTools
|
||||
const mockPlugins = [
|
||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
|
||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' },
|
||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
||||
];
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(...mockPlugins);
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
filterUniquePlugins.mockReturnValue(mockPlugins);
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
expect(filterUniquePlugins).toHaveBeenCalled();
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData).toHaveLength(2);
|
||||
expect(responseData[0].pluginKey).toBe('key1');
|
||||
expect(responseData[1].pluginKey).toBe('key2');
|
||||
// The response includes authenticated: true for each plugin when checkPluginAuth returns true
|
||||
expect(mockRes.json).toHaveBeenCalledWith([
|
||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First', authenticated: true },
|
||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second', authenticated: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use checkPluginAuth to verify plugin authentication', async () => {
|
||||
// checkPluginAuth returns false for plugins without authConfig
|
||||
// so authenticated property won't be added
|
||||
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
filterUniquePlugins.mockReturnValue([mockPlugin]);
|
||||
checkPluginAuth.mockReturnValueOnce(true);
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
// checkPluginAuth returns false, so authenticated property is not added
|
||||
expect(responseData[0].authenticated).toBeUndefined();
|
||||
expect(responseData[0].authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('should return cached plugins when available', async () => {
|
||||
@@ -115,7 +111,8 @@ describe('PluginController', () => {
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
// When cache is hit, we return immediately without processing
|
||||
expect(filterUniquePlugins).not.toHaveBeenCalled();
|
||||
expect(checkPluginAuth).not.toHaveBeenCalled();
|
||||
expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
|
||||
});
|
||||
|
||||
@@ -125,9 +122,10 @@ describe('PluginController', () => {
|
||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
||||
];
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(...mockPlugins);
|
||||
mockReq.app.locals.includedTools = ['key1'];
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
filterUniquePlugins.mockReturnValue(mockPlugins);
|
||||
checkPluginAuth.mockReturnValue(false);
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
@@ -141,102 +139,70 @@ describe('PluginController', () => {
|
||||
it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => {
|
||||
const mockUserTools = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: `tool1${Constants.mcp_delimiter}server1`,
|
||||
description: 'Tool 1',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
function: { name: 'tool1', description: 'Tool 1' },
|
||||
},
|
||||
};
|
||||
const mockConvertedPlugins = [
|
||||
{
|
||||
name: 'tool1',
|
||||
pluginKey: `tool1${Constants.mcp_delimiter}server1`,
|
||||
description: 'Tool 1',
|
||||
},
|
||||
];
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||
convertMCPToolsToPlugins.mockReturnValue(mockConvertedPlugins);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
// Mock second call to return tool definitions
|
||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
// convertMCPToolsToPlugins should have converted the tool
|
||||
expect(responseData.length).toBeGreaterThan(0);
|
||||
const convertedTool = responseData.find(
|
||||
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}server1`,
|
||||
);
|
||||
expect(convertedTool).toBeDefined();
|
||||
expect(convertedTool.name).toBe('tool1');
|
||||
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
|
||||
functionTools: mockUserTools,
|
||||
customConfig: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
|
||||
const mockUserTools = {
|
||||
'user-tool': {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'user-tool',
|
||||
description: 'User tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockCachedPlugins = [
|
||||
{ name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' },
|
||||
const mockUserPlugins = [
|
||||
{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' },
|
||||
];
|
||||
const mockManifestPlugins = [
|
||||
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
|
||||
];
|
||||
|
||||
mockCache.get.mockResolvedValue(mockCachedPlugins);
|
||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||
mockCache.get.mockResolvedValue(mockManifestPlugins);
|
||||
getCachedTools.mockResolvedValueOnce({});
|
||||
convertMCPToolsToPlugins.mockReturnValue(mockUserPlugins);
|
||||
filterUniquePlugins.mockReturnValue([...mockUserPlugins, ...mockManifestPlugins]);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
// Mock second call to return tool definitions
|
||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
// Should have deduplicated tools with same pluginKey
|
||||
const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
|
||||
expect(userToolCount).toBe(1);
|
||||
// Should be called to deduplicate the combined array
|
||||
expect(filterUniquePlugins).toHaveBeenLastCalledWith([
|
||||
...mockUserPlugins,
|
||||
...mockManifestPlugins,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use checkPluginAuth to verify authentication status', async () => {
|
||||
// Add a plugin to availableTools that will be checked
|
||||
const mockPlugin = {
|
||||
name: 'Tool1',
|
||||
pluginKey: 'tool1',
|
||||
description: 'Tool 1',
|
||||
// No authConfig means checkPluginAuth returns false
|
||||
};
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
||||
const mockPlugin = { name: 'Tool1', pluginKey: 'tool1', description: 'Tool 1' };
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue({});
|
||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
||||
filterUniquePlugins.mockReturnValue([mockPlugin]);
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
// Mock loadAndFormatTools to return tool definitions including our tool
|
||||
loadAndFormatTools.mockReturnValue({
|
||||
tool1: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'tool1',
|
||||
description: 'Tool 1',
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
// Mock getCachedTools second call to return tool definitions
|
||||
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ tool1: true });
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(Array.isArray(responseData)).toBe(true);
|
||||
const tool = responseData.find((t) => t.pluginKey === 'tool1');
|
||||
expect(tool).toBeDefined();
|
||||
// checkPluginAuth returns false, so authenticated property is not added
|
||||
expect(tool.authenticated).toBeUndefined();
|
||||
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
|
||||
});
|
||||
|
||||
it('should use getToolkitKey for toolkit validation', async () => {
|
||||
@@ -247,38 +213,22 @@ describe('PluginController', () => {
|
||||
toolkit: true,
|
||||
};
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(mockToolkit);
|
||||
|
||||
// Mock toolkits to have a mapping
|
||||
require('~/app/clients/tools').toolkits.push({
|
||||
name: 'Toolkit1',
|
||||
pluginKey: 'toolkit1',
|
||||
tools: ['toolkit1_function'],
|
||||
});
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue({});
|
||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
||||
filterUniquePlugins.mockReturnValue([mockToolkit]);
|
||||
checkPluginAuth.mockReturnValue(false);
|
||||
getToolkitKey.mockReturnValue('toolkit1');
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
// Mock loadAndFormatTools to return tool definitions
|
||||
loadAndFormatTools.mockReturnValue({
|
||||
toolkit1_function: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'toolkit1_function',
|
||||
description: 'Toolkit function',
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
// Mock getCachedTools second call to return tool definitions
|
||||
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({
|
||||
toolkit1_function: true,
|
||||
});
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(Array.isArray(responseData)).toBe(true);
|
||||
const toolkit = responseData.find((t) => t.pluginKey === 'toolkit1');
|
||||
expect(toolkit).toBeDefined();
|
||||
expect(getToolkitKey).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -289,33 +239,32 @@ describe('PluginController', () => {
|
||||
|
||||
const functionTools = {
|
||||
[`test-tool${Constants.mcp_delimiter}test-server`]: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: `test-tool${Constants.mcp_delimiter}test-server`,
|
||||
description: 'A test tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
function: { name: 'test-tool', description: 'A test tool' },
|
||||
},
|
||||
};
|
||||
|
||||
// Mock the MCP manager to return tools
|
||||
const mockMCPManager = {
|
||||
getAllToolFunctions: jest.fn().mockResolvedValue(functionTools),
|
||||
const mockConvertedPlugin = {
|
||||
name: 'test-tool',
|
||||
pluginKey: `test-tool${Constants.mcp_delimiter}test-server`,
|
||||
description: 'A test tool',
|
||||
icon: mcpServers['test-server']?.iconPath,
|
||||
authenticated: true,
|
||||
authConfig: [],
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
||||
|
||||
getCachedTools.mockResolvedValueOnce({});
|
||||
|
||||
// Mock loadAndFormatTools to return empty object since these are MCP tools
|
||||
loadAndFormatTools.mockReturnValue({});
|
||||
|
||||
getCachedTools.mockResolvedValueOnce(functionTools);
|
||||
convertMCPToolsToPlugins.mockReturnValue([mockConvertedPlugin]);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins);
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
getToolkitKey.mockReturnValue(undefined);
|
||||
|
||||
getCachedTools.mockResolvedValueOnce({
|
||||
[`test-tool${Constants.mcp_delimiter}test-server`]: true,
|
||||
});
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
return responseData.find(
|
||||
(tool) => tool.pluginKey === `test-tool${Constants.mcp_delimiter}test-server`,
|
||||
);
|
||||
return responseData.find((tool) => tool.name === 'test-tool');
|
||||
};
|
||||
|
||||
it('should set plugin.icon when iconPath is defined', async () => {
|
||||
@@ -349,21 +298,19 @@ describe('PluginController', () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Mock MCP tools returned by getAllToolFunctions
|
||||
const mcpToolFunctions = {
|
||||
[`tool1${Constants.mcp_delimiter}test-server`]: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: `tool1${Constants.mcp_delimiter}test-server`,
|
||||
description: 'Tool 1',
|
||||
parameters: {},
|
||||
},
|
||||
// We need to test the actual flow where MCP manager tools are included
|
||||
const mcpManagerTools = [
|
||||
{
|
||||
name: 'tool1',
|
||||
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
|
||||
description: 'Tool 1',
|
||||
authenticated: true,
|
||||
},
|
||||
};
|
||||
];
|
||||
|
||||
// Mock the MCP manager to return tools
|
||||
const mockMCPManager = {
|
||||
getAllToolFunctions: jest.fn().mockResolvedValue(mcpToolFunctions),
|
||||
loadManifestTools: jest.fn().mockResolvedValue(mcpManagerTools),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
||||
|
||||
@@ -373,11 +320,19 @@ describe('PluginController', () => {
|
||||
// First call returns user tools (empty in this case)
|
||||
getCachedTools.mockResolvedValueOnce({});
|
||||
|
||||
// Mock loadAndFormatTools to return empty object for MCP tools
|
||||
loadAndFormatTools.mockReturnValue({});
|
||||
// Mock convertMCPToolsToPlugins to return empty array for user tools
|
||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
||||
|
||||
// Second call returns tool definitions including our MCP tool
|
||||
getCachedTools.mockResolvedValueOnce(mcpToolFunctions);
|
||||
// Mock filterUniquePlugins to pass through
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
||||
|
||||
// Mock checkPluginAuth
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
|
||||
// Second call returns tool definitions
|
||||
getCachedTools.mockResolvedValueOnce({
|
||||
[`tool1${Constants.mcp_delimiter}test-server`]: true,
|
||||
});
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
@@ -418,23 +373,25 @@ describe('PluginController', () => {
|
||||
it('should handle null cachedTools and cachedUserTools', async () => {
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue(null);
|
||||
convertMCPToolsToPlugins.mockReturnValue(undefined);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
// Mock loadAndFormatTools to return empty object when getCachedTools returns null
|
||||
loadAndFormatTools.mockReturnValue({});
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
// Should handle null values gracefully
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
|
||||
functionTools: null,
|
||||
customConfig: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle when getCachedTools returns undefined', async () => {
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue(undefined);
|
||||
convertMCPToolsToPlugins.mockReturnValue(undefined);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
// Mock loadAndFormatTools to return empty object when getCachedTools returns undefined
|
||||
loadAndFormatTools.mockReturnValue({});
|
||||
checkPluginAuth.mockReturnValue(false);
|
||||
|
||||
// Mock getCachedTools to return undefined for both calls
|
||||
getCachedTools.mockReset();
|
||||
@@ -442,40 +399,37 @@ describe('PluginController', () => {
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
// Should handle undefined values gracefully
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
|
||||
functionTools: undefined,
|
||||
customConfig: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cachedToolsArray and userPlugins both being defined', async () => {
|
||||
const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }];
|
||||
// Use MCP delimiter for the user tool so convertMCPToolsToPlugins works
|
||||
const userTools = {
|
||||
[`user-tool${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: `user-tool${Constants.mcp_delimiter}server1`,
|
||||
description: 'User tool',
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
'user-tool': { function: { name: 'user-tool', description: 'User tool' } },
|
||||
};
|
||||
const userPlugins = [{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' }];
|
||||
|
||||
mockCache.get.mockResolvedValue(cachedTools);
|
||||
getCachedTools.mockResolvedValue(userTools);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
convertMCPToolsToPlugins.mockReturnValue(userPlugins);
|
||||
filterUniquePlugins.mockReturnValue([...userPlugins, ...cachedTools]);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
// Should have both cached and user tools
|
||||
expect(responseData.length).toBeGreaterThanOrEqual(2);
|
||||
expect(mockRes.json).toHaveBeenCalledWith([...userPlugins, ...cachedTools]);
|
||||
});
|
||||
|
||||
it('should handle empty toolDefinitions object', async () => {
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({});
|
||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
@@ -502,6 +456,18 @@ describe('PluginController', () => {
|
||||
getCustomConfig.mockResolvedValue(customConfig);
|
||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||
|
||||
const mockPlugin = {
|
||||
name: 'tool1',
|
||||
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
|
||||
description: 'Tool 1',
|
||||
authenticated: true,
|
||||
authConfig: [],
|
||||
};
|
||||
|
||||
convertMCPToolsToPlugins.mockReturnValue([mockPlugin]);
|
||||
filterUniquePlugins.mockImplementation((plugins) => plugins);
|
||||
checkPluginAuth.mockReturnValue(true);
|
||||
|
||||
getCachedTools.mockResolvedValueOnce({
|
||||
[`tool1${Constants.mcp_delimiter}test-server`]: true,
|
||||
});
|
||||
@@ -517,6 +483,8 @@ describe('PluginController', () => {
|
||||
it('should handle req.app.locals with undefined filteredTools and includedTools', async () => {
|
||||
mockReq.app = { locals: {} };
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
filterUniquePlugins.mockReturnValue([]);
|
||||
checkPluginAuth.mockReturnValue(false);
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
@@ -532,25 +500,14 @@ describe('PluginController', () => {
|
||||
toolkit: true,
|
||||
};
|
||||
|
||||
// Ensure req.app.locals is properly mocked
|
||||
mockReq.app = {
|
||||
locals: {
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
paths: { structuredTools: '/mock/path' },
|
||||
},
|
||||
};
|
||||
|
||||
// Add the toolkit to availableTools
|
||||
require('~/app/clients/tools').availableTools.push(mockToolkit);
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValue({});
|
||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
||||
filterUniquePlugins.mockReturnValue([mockToolkit]);
|
||||
checkPluginAuth.mockReturnValue(false);
|
||||
getToolkitKey.mockReturnValue(undefined);
|
||||
getCustomConfig.mockResolvedValue(null);
|
||||
|
||||
// Mock loadAndFormatTools to return an empty object when toolDefinitions is null
|
||||
loadAndFormatTools.mockReturnValue({});
|
||||
|
||||
// Mock getCachedTools second call to return null
|
||||
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce(null);
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ const verify2FA = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token, backupCode } = req.body;
|
||||
const user = await getUserById(userId, '_id totpSecret backupCodes');
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA not initiated' });
|
||||
@@ -79,7 +79,7 @@ const confirm2FA = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token } = req.body;
|
||||
const user = await getUserById(userId, '_id totpSecret');
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA not initiated' });
|
||||
@@ -105,7 +105,7 @@ const disable2FA = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token, backupCode } = req.body;
|
||||
const user = await getUserById(userId, '_id totpSecret backupCodes');
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA is not setup for this user' });
|
||||
|
||||
@@ -24,13 +24,7 @@ const { getMCPManager } = require('~/config');
|
||||
const getUserController = async (req, res) => {
|
||||
/** @type {MongoUser} */
|
||||
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
|
||||
/**
|
||||
* These fields should not exist due to secure field selection, but deletion
|
||||
* is done in case of alternate database incompatibility with Mongo API
|
||||
* */
|
||||
delete userData.password;
|
||||
delete userData.totpSecret;
|
||||
delete userData.backupCodes;
|
||||
if (req.app.locals.fileStrategy === FileSources.s3 && userData.avatar) {
|
||||
const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600);
|
||||
if (!avatarNeedsRefresh) {
|
||||
|
||||
@@ -33,13 +33,18 @@ const {
|
||||
bedrockInputSchema,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
findPluginAuthsByKeys,
|
||||
getFormattedMemories,
|
||||
deleteMemory,
|
||||
setMemory,
|
||||
} = require('~/models');
|
||||
const { getMCPAuthMap, checkCapability, hasCustomUserVars } = require('~/server/services/Config');
|
||||
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 { checkCapability } = require('~/server/services/Config');
|
||||
const BaseClient = require('~/app/clients/BaseClient');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
const { loadAgent } = require('~/models/Agent');
|
||||
@@ -610,7 +615,6 @@ class AgentClient extends BaseClient {
|
||||
await this.chatCompletion({
|
||||
payload,
|
||||
onProgress: opts.onProgress,
|
||||
userMCPAuthMap: opts.userMCPAuthMap,
|
||||
abortController: opts.abortController,
|
||||
});
|
||||
return this.contentParts;
|
||||
@@ -743,13 +747,7 @@ class AgentClient extends BaseClient {
|
||||
return currentMessageTokens > 0 ? currentMessageTokens : originalEstimate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {string | ChatCompletionMessageParam[]} params.payload
|
||||
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
|
||||
* @param {AbortController} [params.abortController]
|
||||
*/
|
||||
async chatCompletion({ payload, userMCPAuthMap, abortController = null }) {
|
||||
async chatCompletion({ payload, abortController = null }) {
|
||||
/** @type {Partial<GraphRunnableConfig>} */
|
||||
let config;
|
||||
/** @type {ReturnType<createRun>} */
|
||||
@@ -770,11 +768,6 @@ class AgentClient extends BaseClient {
|
||||
last_agent_index: this.agentConfigs?.size ?? 0,
|
||||
user_id: this.user ?? this.options.req.user?.id,
|
||||
hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
|
||||
requestBody: {
|
||||
messageId: this.responseMessageId,
|
||||
conversationId: this.conversationId,
|
||||
parentMessageId: this.parentMessageId,
|
||||
},
|
||||
user: this.options.req.user,
|
||||
},
|
||||
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
|
||||
@@ -905,9 +898,21 @@ class AgentClient extends BaseClient {
|
||||
run.Graph.contentData = contentData;
|
||||
}
|
||||
|
||||
if (userMCPAuthMap != null) {
|
||||
config.configurable.userMCPAuthMap = userMCPAuthMap;
|
||||
try {
|
||||
if (await hasCustomUserVars()) {
|
||||
config.configurable.userMCPAuthMap = await getMCPAuthMap({
|
||||
tools: agent.tools,
|
||||
userId: this.options.req.user.id,
|
||||
findPluginAuthsByKeys,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent ${agent.id}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
await run.processStream({ messages }, config, {
|
||||
keepContent: i !== 0,
|
||||
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||
@@ -1075,6 +1080,7 @@ class AgentClient extends BaseClient {
|
||||
|
||||
/** @type {import('@librechat/agents').ClientOptions} */
|
||||
let clientOptions = {
|
||||
maxTokens: 75,
|
||||
model: agent.model || agent.model_parameters.model,
|
||||
};
|
||||
|
||||
@@ -1141,13 +1147,15 @@ class AgentClient extends BaseClient {
|
||||
clientOptions.configuration = options.configOptions;
|
||||
}
|
||||
|
||||
if (clientOptions.maxTokens != null) {
|
||||
const shouldRemoveMaxTokens = /\b(o\d|gpt-[5-9])\b/i.test(clientOptions.model);
|
||||
if (shouldRemoveMaxTokens && clientOptions.maxTokens != null) {
|
||||
delete clientOptions.maxTokens;
|
||||
} else if (!shouldRemoveMaxTokens && !clientOptions.maxTokens) {
|
||||
clientOptions.maxTokens = 75;
|
||||
}
|
||||
if (clientOptions?.modelKwargs?.max_completion_tokens != null) {
|
||||
if (shouldRemoveMaxTokens && clientOptions?.modelKwargs?.max_completion_tokens != null) {
|
||||
delete clientOptions.modelKwargs.max_completion_tokens;
|
||||
}
|
||||
if (clientOptions?.modelKwargs?.max_output_tokens != null) {
|
||||
} else if (shouldRemoveMaxTokens && clientOptions?.modelKwargs?.max_output_tokens != null) {
|
||||
delete clientOptions.modelKwargs.max_output_tokens;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,24 +9,6 @@ const {
|
||||
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
|
||||
const { saveMessage } = require('~/models');
|
||||
|
||||
function createCloseHandler(abortController) {
|
||||
return function (manual) {
|
||||
if (!manual) {
|
||||
logger.debug('[AgentController] Request closed');
|
||||
}
|
||||
if (!abortController) {
|
||||
return;
|
||||
} else if (abortController.signal.aborted) {
|
||||
return;
|
||||
} else if (abortController.requestCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
abortController.abort();
|
||||
logger.debug('[AgentController] Request aborted on close');
|
||||
};
|
||||
}
|
||||
|
||||
const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
let {
|
||||
text,
|
||||
@@ -49,6 +31,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
let userMessagePromise;
|
||||
let getAbortData;
|
||||
let client = null;
|
||||
// Initialize as an array
|
||||
let cleanupHandlers = [];
|
||||
|
||||
const newConvo = !conversationId;
|
||||
@@ -79,7 +62,9 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
// Create a function to handle final cleanup
|
||||
const performCleanup = () => {
|
||||
logger.debug('[AgentController] Performing cleanup');
|
||||
// Make sure cleanupHandlers is an array before iterating
|
||||
if (Array.isArray(cleanupHandlers)) {
|
||||
// Execute all cleanup handlers
|
||||
for (const handler of cleanupHandlers) {
|
||||
try {
|
||||
if (typeof handler === 'function') {
|
||||
@@ -120,33 +105,8 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
};
|
||||
|
||||
try {
|
||||
let prelimAbortController = new AbortController();
|
||||
const prelimCloseHandler = createCloseHandler(prelimAbortController);
|
||||
res.on('close', prelimCloseHandler);
|
||||
const removePrelimHandler = (manual) => {
|
||||
try {
|
||||
prelimCloseHandler(manual);
|
||||
res.removeListener('close', prelimCloseHandler);
|
||||
} catch (e) {
|
||||
logger.error('[AgentController] Error removing close listener', e);
|
||||
}
|
||||
};
|
||||
cleanupHandlers.push(removePrelimHandler);
|
||||
/** @type {{ client: TAgentClient; userMCPAuthMap?: Record<string, Record<string, string>> }} */
|
||||
const result = await initializeClient({
|
||||
req,
|
||||
res,
|
||||
endpointOption,
|
||||
signal: prelimAbortController.signal,
|
||||
});
|
||||
if (prelimAbortController.signal?.aborted) {
|
||||
prelimAbortController = null;
|
||||
throw new Error('Request was aborted before initialization could complete');
|
||||
} else {
|
||||
prelimAbortController = null;
|
||||
removePrelimHandler(true);
|
||||
cleanupHandlers.pop();
|
||||
}
|
||||
/** @type {{ client: TAgentClient }} */
|
||||
const result = await initializeClient({ req, res, endpointOption });
|
||||
client = result.client;
|
||||
|
||||
// Register client with finalization registry if available
|
||||
@@ -178,7 +138,22 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
};
|
||||
|
||||
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
|
||||
const closeHandler = createCloseHandler(abortController);
|
||||
|
||||
// Simple handler to avoid capturing scope
|
||||
const closeHandler = () => {
|
||||
logger.debug('[AgentController] Request closed');
|
||||
if (!abortController) {
|
||||
return;
|
||||
} else if (abortController.signal.aborted) {
|
||||
return;
|
||||
} else if (abortController.requestCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
abortController.abort();
|
||||
logger.debug('[AgentController] Request aborted on close');
|
||||
};
|
||||
|
||||
res.on('close', closeHandler);
|
||||
cleanupHandlers.push(() => {
|
||||
try {
|
||||
@@ -200,7 +175,6 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
abortController,
|
||||
overrideParentMessageId,
|
||||
isEdited: !!editedContent,
|
||||
userMCPAuthMap: result.userMCPAuthMap,
|
||||
responseMessageId: editedResponseMessageId,
|
||||
progressOptions: {
|
||||
res,
|
||||
|
||||
@@ -22,11 +22,10 @@ const verify2FAWithTempToken = async (req, res) => {
|
||||
try {
|
||||
payload = jwt.verify(tempToken, process.env.JWT_SECRET);
|
||||
} catch (err) {
|
||||
logger.error('Failed to verify temporary token:', err);
|
||||
return res.status(401).json({ message: 'Invalid or expired temporary token' });
|
||||
}
|
||||
|
||||
const user = await getUserById(payload.userId, '+totpSecret +backupCodes');
|
||||
const user = await getUserById(payload.userId);
|
||||
if (!user || !user.twoFactorEnabled) {
|
||||
return res.status(400).json({ message: '2FA is not enabled for this user' });
|
||||
}
|
||||
@@ -43,11 +42,11 @@ const verify2FAWithTempToken = async (req, res) => {
|
||||
return res.status(401).json({ message: 'Invalid 2FA code or backup code' });
|
||||
}
|
||||
|
||||
// Prepare user data to return (omit sensitive fields).
|
||||
const userData = user.toObject ? user.toObject() : { ...user };
|
||||
delete userData.__v;
|
||||
delete userData.password;
|
||||
delete userData.__v;
|
||||
delete userData.totpSecret;
|
||||
delete userData.backupCodes;
|
||||
userData.id = user._id.toString();
|
||||
|
||||
const authToken = await setAuthTokens(user._id, res);
|
||||
|
||||
@@ -14,7 +14,6 @@ const { isEnabled, ErrorController } = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
||||
const { checkMigrations } = require('./services/start/migration');
|
||||
const initializeMCPs = require('./services/initializeMCPs');
|
||||
const configureSocialLogins = require('./socialLogins');
|
||||
const AppService = require('./services/AppService');
|
||||
@@ -146,7 +145,7 @@ const startServer = async () => {
|
||||
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
|
||||
}
|
||||
|
||||
initializeMCPs(app).then(() => checkMigrations());
|
||||
initializeMCPs(app);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { countTokens, isEnabled, sendEvent } = require('@librechat/api');
|
||||
const { isAssistantsEndpoint, ErrorTypes, Constants } = require('librechat-data-provider');
|
||||
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
|
||||
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
|
||||
const clearPendingReq = require('~/cache/clearPendingReq');
|
||||
const { sendError } = require('~/server/middleware/error');
|
||||
@@ -11,10 +11,6 @@ const { abortRun } = require('./abortRun');
|
||||
|
||||
const abortDataMap = new WeakMap();
|
||||
|
||||
/**
|
||||
* @param {string} abortKey
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function cleanupAbortController(abortKey) {
|
||||
if (!abortControllers.has(abortKey)) {
|
||||
return false;
|
||||
@@ -75,20 +71,6 @@ function cleanupAbortController(abortKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} abortKey
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
function createCleanUpHandler(abortKey) {
|
||||
return function () {
|
||||
try {
|
||||
cleanupAbortController(abortKey);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function abortMessage(req, res) {
|
||||
let { abortKey, endpoint } = req.body;
|
||||
|
||||
@@ -190,15 +172,11 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
|
||||
/**
|
||||
* @param {TMessage} userMessage
|
||||
* @param {string} responseMessageId
|
||||
* @param {boolean} [isNewConvo]
|
||||
*/
|
||||
const onStart = (userMessage, responseMessageId, isNewConvo) => {
|
||||
const onStart = (userMessage, responseMessageId) => {
|
||||
sendEvent(res, { message: userMessage, created: true });
|
||||
|
||||
const prelimAbortKey = userMessage?.conversationId ?? req.user.id;
|
||||
const abortKey = isNewConvo
|
||||
? `${prelimAbortKey}${Constants.COMMON_DIVIDER}${Constants.NEW_CONVO}`
|
||||
: prelimAbortKey;
|
||||
const abortKey = userMessage?.conversationId ?? req.user.id;
|
||||
getReqData({ abortKey });
|
||||
const prevRequest = abortControllers.get(abortKey);
|
||||
const { overrideUserMessageId } = req?.body ?? {};
|
||||
@@ -216,7 +194,16 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
|
||||
};
|
||||
|
||||
abortControllers.set(addedAbortKey, { abortController, ...minimalOptions });
|
||||
const cleanupHandler = createCleanUpHandler(addedAbortKey);
|
||||
|
||||
// Use a simple function for cleanup to avoid capturing context
|
||||
const cleanupHandler = () => {
|
||||
try {
|
||||
cleanupAbortController(addedAbortKey);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
};
|
||||
|
||||
res.on('finish', cleanupHandler);
|
||||
return;
|
||||
}
|
||||
@@ -229,7 +216,16 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
|
||||
};
|
||||
|
||||
abortControllers.set(abortKey, { abortController, ...minimalOptions });
|
||||
const cleanupHandler = createCleanUpHandler(abortKey);
|
||||
|
||||
// Use a simple function for cleanup to avoid capturing context
|
||||
const cleanupHandler = () => {
|
||||
try {
|
||||
cleanupAbortController(abortKey);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
};
|
||||
|
||||
res.on('finish', cleanupHandler);
|
||||
};
|
||||
|
||||
@@ -368,7 +364,15 @@ const handleAbortError = async (res, req, error, data) => {
|
||||
};
|
||||
}
|
||||
|
||||
const callback = createCleanUpHandler(conversationId);
|
||||
// Create a simple callback without capturing parent scope
|
||||
const callback = async () => {
|
||||
try {
|
||||
cleanupAbortController(conversationId);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
};
|
||||
|
||||
await sendError(req, res, options, callback);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const validateEndpoint = require('./validateEndpoint');
|
||||
const requireLocalAuth = require('./requireLocalAuth');
|
||||
const canDeleteAccount = require('./canDeleteAccount');
|
||||
const accessResources = require('./accessResources');
|
||||
const setBalanceConfig = require('./setBalanceConfig');
|
||||
const requireLdapAuth = require('./requireLdapAuth');
|
||||
const abortMiddleware = require('./abortMiddleware');
|
||||
const checkInviteUser = require('./checkInviteUser');
|
||||
@@ -43,6 +44,7 @@ module.exports = {
|
||||
requireLocalAuth,
|
||||
canDeleteAccount,
|
||||
validateEndpoint,
|
||||
setBalanceConfig,
|
||||
concurrentLimiter,
|
||||
checkDomainAllowed,
|
||||
validateMessageReq,
|
||||
|
||||
91
api/server/middleware/setBalanceConfig.js
Normal file
91
api/server/middleware/setBalanceConfig.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getBalanceConfig } = require('~/server/services/Config');
|
||||
const { Balance } = require('~/db/models');
|
||||
|
||||
/**
|
||||
* Middleware to synchronize user balance settings with current balance configuration.
|
||||
* @function
|
||||
* @param {Object} req - Express request object containing user information.
|
||||
* @param {Object} res - Express response object.
|
||||
* @param {import('express').NextFunction} next - Next middleware function.
|
||||
*/
|
||||
const setBalanceConfig = async (req, res, next) => {
|
||||
try {
|
||||
const balanceConfig = await getBalanceConfig();
|
||||
if (!balanceConfig?.enabled) {
|
||||
return next();
|
||||
}
|
||||
if (balanceConfig.startBalance == null) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const userId = req.user._id;
|
||||
const userBalanceRecord = await Balance.findOne({ user: userId }).lean();
|
||||
const updateFields = buildUpdateFields(balanceConfig, userBalanceRecord);
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
await Balance.findOneAndUpdate(
|
||||
{ user: userId },
|
||||
{ $set: updateFields },
|
||||
{ upsert: true, new: true },
|
||||
);
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Error setting user balance:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an object containing fields that need updating
|
||||
* @param {Object} config - The balance configuration
|
||||
* @param {Object|null} userRecord - The user's current balance record, if any
|
||||
* @returns {Object} Fields that need updating
|
||||
*/
|
||||
function buildUpdateFields(config, userRecord) {
|
||||
const updateFields = {};
|
||||
|
||||
// Ensure user record has the required fields
|
||||
if (!userRecord) {
|
||||
updateFields.user = userRecord?.user;
|
||||
updateFields.tokenCredits = config.startBalance;
|
||||
}
|
||||
|
||||
if (userRecord?.tokenCredits == null && config.startBalance != null) {
|
||||
updateFields.tokenCredits = config.startBalance;
|
||||
}
|
||||
|
||||
const isAutoRefillConfigValid =
|
||||
config.autoRefillEnabled &&
|
||||
config.refillIntervalValue != null &&
|
||||
config.refillIntervalUnit != null &&
|
||||
config.refillAmount != null;
|
||||
|
||||
if (!isAutoRefillConfigValid) {
|
||||
return updateFields;
|
||||
}
|
||||
|
||||
if (userRecord?.autoRefillEnabled !== config.autoRefillEnabled) {
|
||||
updateFields.autoRefillEnabled = config.autoRefillEnabled;
|
||||
}
|
||||
|
||||
if (userRecord?.refillIntervalValue !== config.refillIntervalValue) {
|
||||
updateFields.refillIntervalValue = config.refillIntervalValue;
|
||||
}
|
||||
|
||||
if (userRecord?.refillIntervalUnit !== config.refillIntervalUnit) {
|
||||
updateFields.refillIntervalUnit = config.refillIntervalUnit;
|
||||
}
|
||||
|
||||
if (userRecord?.refillAmount !== config.refillAmount) {
|
||||
updateFields.refillAmount = config.refillAmount;
|
||||
}
|
||||
|
||||
return updateFields;
|
||||
}
|
||||
|
||||
module.exports = setBalanceConfig;
|
||||
@@ -4,14 +4,12 @@ const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
MCPOAuthHandler: {
|
||||
initiateOAuthFlow: jest.fn(),
|
||||
getFlowState: jest.fn(),
|
||||
completeOAuthFlow: jest.fn(),
|
||||
generateFlowId: jest.fn(),
|
||||
},
|
||||
getUserMCPAuthMap: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
@@ -38,7 +36,6 @@ jest.mock('~/models', () => ({
|
||||
updateToken: jest.fn(),
|
||||
createToken: jest.fn(),
|
||||
deleteTokens: jest.fn(),
|
||||
findPluginAuthsByKeys: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
@@ -47,10 +44,6 @@ jest.mock('~/server/services/Config', () => ({
|
||||
loadCustomConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config/mcpToolsCache', () => ({
|
||||
updateMCPUserTools: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/MCP', () => ({
|
||||
getMCPSetupData: jest.fn(),
|
||||
getServerConnectionStatus: jest.fn(),
|
||||
@@ -73,10 +66,6 @@ jest.mock('~/server/middleware', () => ({
|
||||
requireJwtAuth: (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Tools/mcp', () => ({
|
||||
reinitMCPServer: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('MCP Routes', () => {
|
||||
let app;
|
||||
let mongoServer;
|
||||
@@ -505,9 +494,12 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 500 when token retrieval throws an unexpected error', async () => {
|
||||
getLogStores.mockImplementation(() => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn().mockRejectedValue(new Error('Database connection failed')),
|
||||
};
|
||||
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:error-flow');
|
||||
|
||||
@@ -571,8 +563,8 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
describe('POST /oauth/cancel/:serverName', () => {
|
||||
const { MCPOAuthHandler } = require('@librechat/api');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { MCPOAuthHandler } = require('@librechat/api');
|
||||
|
||||
it('should cancel OAuth flow successfully', async () => {
|
||||
const mockFlowManager = {
|
||||
@@ -652,15 +644,15 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
describe('POST /:serverName/reinitialize', () => {
|
||||
it('should return 404 when server is not found in configuration', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue(null),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
};
|
||||
const { loadCustomConfig } = require('~/server/services/Config');
|
||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
it('should return 404 when server is not found in configuration', async () => {
|
||||
loadCustomConfig.mockResolvedValue({
|
||||
mcpServers: {
|
||||
'other-server': {},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app).post('/api/mcp/non-existent-server/reinitialize');
|
||||
|
||||
@@ -671,11 +663,16 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should handle OAuth requirement during reinitialize', async () => {
|
||||
loadCustomConfig.mockResolvedValue({
|
||||
mcpServers: {
|
||||
'oauth-server': {
|
||||
customUserVars: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: {},
|
||||
}),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
disconnectServer: jest.fn().mockResolvedValue(),
|
||||
mcpConfigs: {},
|
||||
getUserConnection: jest.fn().mockImplementation(async ({ oauthStart }) => {
|
||||
if (oauthStart) {
|
||||
@@ -688,19 +685,12 @@ describe('MCP Routes', () => {
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
|
||||
success: true,
|
||||
message: "MCP server 'oauth-server' ready for OAuth authentication",
|
||||
serverName: 'oauth-server',
|
||||
oauthRequired: true,
|
||||
oauthUrl: 'https://oauth.example.com/auth',
|
||||
});
|
||||
|
||||
const response = await request(app).post('/api/mcp/oauth-server/reinitialize');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
success: 'https://oauth.example.com/auth',
|
||||
message: "MCP server 'oauth-server' ready for OAuth authentication",
|
||||
serverName: 'oauth-server',
|
||||
oauthRequired: true,
|
||||
@@ -709,9 +699,14 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 500 when reinitialize fails with non-OAuth error', async () => {
|
||||
loadCustomConfig.mockResolvedValue({
|
||||
mcpServers: {
|
||||
'error-server': {},
|
||||
},
|
||||
});
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
disconnectServer: jest.fn().mockResolvedValue(),
|
||||
mcpConfigs: {},
|
||||
getUserConnection: jest.fn().mockRejectedValue(new Error('Connection failed')),
|
||||
};
|
||||
@@ -719,7 +714,6 @@ describe('MCP Routes', () => {
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app).post('/api/mcp/error-server/reinitialize');
|
||||
|
||||
@@ -730,13 +724,7 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 500 when unexpected error occurs', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockImplementation(() => {
|
||||
throw new Error('Config loading failed');
|
||||
}),
|
||||
};
|
||||
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
loadCustomConfig.mockRejectedValue(new Error('Config loading failed'));
|
||||
|
||||
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
||||
|
||||
@@ -759,105 +747,75 @@ describe('MCP Routes', () => {
|
||||
expect(response.body).toEqual({ error: 'User not authenticated' });
|
||||
});
|
||||
|
||||
it('should successfully reinitialize server and cache tools', async () => {
|
||||
const mockUserConnection = {
|
||||
fetchTools: jest.fn().mockResolvedValue([
|
||||
{ name: 'tool1', description: 'Test tool 1', inputSchema: { type: 'object' } },
|
||||
{ name: 'tool2', description: 'Test tool 2', inputSchema: { type: 'object' } },
|
||||
]),
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({ endpoint: 'http://test-server.com' }),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
};
|
||||
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
|
||||
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
||||
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
|
||||
getCachedTools.mockResolvedValue({});
|
||||
setCachedTools.mockResolvedValue();
|
||||
updateMCPUserTools.mockResolvedValue();
|
||||
|
||||
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
|
||||
success: true,
|
||||
message: "MCP server 'test-server' reinitialized successfully",
|
||||
serverName: 'test-server',
|
||||
oauthRequired: false,
|
||||
oauthUrl: null,
|
||||
it('should handle errors when fetching custom user variables', async () => {
|
||||
loadCustomConfig.mockResolvedValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
customUserVars: {
|
||||
API_KEY: 'test-key-var',
|
||||
SECRET_TOKEN: 'test-secret-var',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
||||
getUserPluginAuthValue
|
||||
.mockResolvedValueOnce('test-api-key-value')
|
||||
.mockRejectedValueOnce(new Error('Database error'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
message: "MCP server 'test-server' reinitialized successfully",
|
||||
serverName: 'test-server',
|
||||
oauthRequired: false,
|
||||
oauthUrl: null,
|
||||
});
|
||||
expect(mockMcpManager.disconnectUserConnection).toHaveBeenCalledWith(
|
||||
'test-user-id',
|
||||
'test-server',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle server with custom user variables', async () => {
|
||||
const mockUserConnection = {
|
||||
fetchTools: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
endpoint: 'http://test-server.com',
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
},
|
||||
}),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
disconnectServer: jest.fn().mockResolvedValue(),
|
||||
mcpConfigs: {},
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
};
|
||||
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
require('@librechat/api').getUserMCPAuthMap.mockResolvedValue({
|
||||
'mcp:test-server': {
|
||||
API_KEY: 'api-key-value',
|
||||
},
|
||||
});
|
||||
require('~/models').findPluginAuthsByKeys.mockResolvedValue([
|
||||
{ key: 'API_KEY', value: 'api-key-value' },
|
||||
]);
|
||||
|
||||
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
||||
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
|
||||
getCachedTools.mockResolvedValue({});
|
||||
setCachedTools.mockResolvedValue();
|
||||
updateMCPUserTools.mockResolvedValue();
|
||||
|
||||
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
|
||||
success: true,
|
||||
message: "MCP server 'test-server' reinitialized successfully",
|
||||
serverName: 'test-server',
|
||||
oauthRequired: false,
|
||||
oauthUrl: null,
|
||||
});
|
||||
|
||||
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(require('@librechat/api').getUserMCPAuthMap).toHaveBeenCalledWith({
|
||||
userId: 'test-user-id',
|
||||
servers: ['test-server'],
|
||||
findPluginAuthsByKeys: require('~/models').findPluginAuthsByKeys,
|
||||
});
|
||||
|
||||
it('should return failure message when reinitialize completely fails', async () => {
|
||||
loadCustomConfig.mockResolvedValue({
|
||||
mcpServers: {
|
||||
'test-server': {},
|
||||
},
|
||||
});
|
||||
|
||||
const mockMcpManager = {
|
||||
disconnectServer: jest.fn().mockResolvedValue(),
|
||||
mcpConfigs: {},
|
||||
getUserConnection: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
|
||||
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
||||
const { Constants } = require('librechat-data-provider');
|
||||
getCachedTools.mockResolvedValue({
|
||||
[`existing-tool${Constants.mcp_delimiter}test-server`]: { type: 'function' },
|
||||
});
|
||||
setCachedTools.mockResolvedValue();
|
||||
|
||||
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.message).toBe("Failed to reinitialize MCP server 'test-server'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1026,19 +984,21 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
describe('GET /:serverName/auth-values', () => {
|
||||
const { loadCustomConfig } = require('~/server/services/Config');
|
||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||
|
||||
it('should return auth value flags for server', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
SECRET_TOKEN: 'another-env-var',
|
||||
loadCustomConfig.mockResolvedValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
SECRET_TOKEN: 'another-env-var',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
getUserPluginAuthValue.mockResolvedValueOnce('some-api-key-value').mockResolvedValueOnce('');
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
@@ -1057,11 +1017,11 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 404 when server is not found in configuration', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
loadCustomConfig.mockResolvedValue({
|
||||
mcpServers: {
|
||||
'other-server': {},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/mcp/non-existent-server/auth-values');
|
||||
|
||||
@@ -1072,15 +1032,16 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should handle errors when checking auth values', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
loadCustomConfig.mockResolvedValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
getUserPluginAuthValue.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
@@ -1096,13 +1057,7 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 500 when auth values check throws unexpected error', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockImplementation(() => {
|
||||
throw new Error('Config loading failed');
|
||||
}),
|
||||
};
|
||||
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
loadCustomConfig.mockRejectedValue(new Error('Config loading failed'));
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
|
||||
@@ -1111,13 +1066,14 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should handle customUserVars that is not an object', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: 'not-an-object',
|
||||
}),
|
||||
};
|
||||
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
const { loadCustomConfig } = require('~/server/services/Config');
|
||||
loadCustomConfig.mockResolvedValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
customUserVars: 'not-an-object',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
|
||||
@@ -1141,6 +1097,98 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:serverName/reinitialize - Tool Deletion Coverage', () => {
|
||||
it('should handle null cached tools during reinitialize (triggers || {} fallback)', async () => {
|
||||
const { loadCustomConfig, getCachedTools } = require('~/server/services/Config');
|
||||
|
||||
const mockUserConnection = {
|
||||
fetchTools: jest.fn().mockResolvedValue([{ name: 'new-tool', description: 'A new tool' }]),
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
disconnectServer: jest.fn(),
|
||||
initializeServer: jest.fn(),
|
||||
mcpConfigs: {},
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
loadCustomConfig.mockResolvedValue({
|
||||
mcpServers: {
|
||||
'test-server': { env: { API_KEY: 'test-key' } },
|
||||
},
|
||||
});
|
||||
|
||||
getCachedTools.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app).post('/api/mcp/test-server/reinitialize').expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: "MCP server 'test-server' reinitialized successfully",
|
||||
success: true,
|
||||
oauthRequired: false,
|
||||
oauthUrl: null,
|
||||
serverName: 'test-server',
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete existing cached tools during successful reinitialize', async () => {
|
||||
const {
|
||||
loadCustomConfig,
|
||||
getCachedTools,
|
||||
setCachedTools,
|
||||
} = require('~/server/services/Config');
|
||||
|
||||
const mockUserConnection = {
|
||||
fetchTools: jest.fn().mockResolvedValue([{ name: 'new-tool', description: 'A new tool' }]),
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
disconnectServer: jest.fn(),
|
||||
initializeServer: jest.fn(),
|
||||
mcpConfigs: {},
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
loadCustomConfig.mockResolvedValue({
|
||||
mcpServers: {
|
||||
'test-server': { env: { API_KEY: 'test-key' } },
|
||||
},
|
||||
});
|
||||
|
||||
const existingTools = {
|
||||
'old-tool_mcp_test-server': { type: 'function' },
|
||||
'other-tool_mcp_other-server': { type: 'function' },
|
||||
};
|
||||
getCachedTools.mockResolvedValue(existingTools);
|
||||
|
||||
const response = await request(app).post('/api/mcp/test-server/reinitialize').expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: "MCP server 'test-server' reinitialized successfully",
|
||||
success: true,
|
||||
oauthRequired: false,
|
||||
oauthUrl: null,
|
||||
serverName: 'test-server',
|
||||
});
|
||||
|
||||
expect(setCachedTools).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'new-tool_mcp_test-server': expect.any(Object),
|
||||
'other-tool_mcp_other-server': { type: 'function' },
|
||||
}),
|
||||
{ userId: 'test-user-id' },
|
||||
);
|
||||
expect(setCachedTools).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
'old-tool_mcp_test-server': expect.anything(),
|
||||
}),
|
||||
{ userId: 'test-user-id' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:serverName/oauth/callback - Edge Cases', () => {
|
||||
it('should handle OAuth callback without toolFlowId (falsy toolFlowId)', async () => {
|
||||
const { MCPOAuthHandler } = require('@librechat/api');
|
||||
|
||||
@@ -46,7 +46,7 @@ router.use('/tools', tools);
|
||||
|
||||
/**
|
||||
* Get all agent categories with counts
|
||||
* @route GET /agents/categories
|
||||
* @route GET /agents/marketplace/categories
|
||||
*/
|
||||
router.get('/categories', v1.getAgentCategories);
|
||||
/**
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
const express = require('express');
|
||||
const { createSetBalanceConfig } = require('@librechat/api');
|
||||
const {
|
||||
resetPasswordRequestController,
|
||||
resetPasswordController,
|
||||
registrationController,
|
||||
graphTokenController,
|
||||
refreshController,
|
||||
registrationController,
|
||||
resetPasswordController,
|
||||
resetPasswordRequestController,
|
||||
graphTokenController,
|
||||
} = require('~/server/controllers/AuthController');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { logoutController } = require('~/server/controllers/auth/LogoutController');
|
||||
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
|
||||
const {
|
||||
regenerateBackupCodes,
|
||||
disable2FA,
|
||||
confirm2FA,
|
||||
enable2FA,
|
||||
verify2FA,
|
||||
disable2FA,
|
||||
regenerateBackupCodes,
|
||||
confirm2FA,
|
||||
} = require('~/server/controllers/TwoFactorController');
|
||||
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
|
||||
const { logoutController } = require('~/server/controllers/auth/LogoutController');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { getBalanceConfig } = require('~/server/services/Config');
|
||||
const middleware = require('~/server/middleware');
|
||||
const { Balance } = require('~/db/models');
|
||||
|
||||
const setBalanceConfig = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
Balance,
|
||||
});
|
||||
const {
|
||||
checkBan,
|
||||
logHeaders,
|
||||
loginLimiter,
|
||||
requireJwtAuth,
|
||||
checkInviteUser,
|
||||
registerLimiter,
|
||||
requireLdapAuth,
|
||||
setBalanceConfig,
|
||||
requireLocalAuth,
|
||||
resetPasswordLimiter,
|
||||
validateRegistration,
|
||||
validatePasswordReset,
|
||||
} = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const ldapAuth = !!process.env.LDAP_URL && !!process.env.LDAP_USER_SEARCH_BASE;
|
||||
//Local
|
||||
router.post('/logout', middleware.requireJwtAuth, logoutController);
|
||||
router.post('/logout', requireJwtAuth, logoutController);
|
||||
router.post(
|
||||
'/login',
|
||||
middleware.logHeaders,
|
||||
middleware.loginLimiter,
|
||||
middleware.checkBan,
|
||||
ldapAuth ? middleware.requireLdapAuth : middleware.requireLocalAuth,
|
||||
logHeaders,
|
||||
loginLimiter,
|
||||
checkBan,
|
||||
ldapAuth ? requireLdapAuth : requireLocalAuth,
|
||||
setBalanceConfig,
|
||||
loginController,
|
||||
);
|
||||
router.post('/refresh', refreshController);
|
||||
router.post(
|
||||
'/register',
|
||||
middleware.registerLimiter,
|
||||
middleware.checkBan,
|
||||
middleware.checkInviteUser,
|
||||
middleware.validateRegistration,
|
||||
registerLimiter,
|
||||
checkBan,
|
||||
checkInviteUser,
|
||||
validateRegistration,
|
||||
registrationController,
|
||||
);
|
||||
router.post(
|
||||
'/requestPasswordReset',
|
||||
middleware.resetPasswordLimiter,
|
||||
middleware.checkBan,
|
||||
middleware.validatePasswordReset,
|
||||
resetPasswordLimiter,
|
||||
checkBan,
|
||||
validatePasswordReset,
|
||||
resetPasswordRequestController,
|
||||
);
|
||||
router.post(
|
||||
'/resetPassword',
|
||||
middleware.checkBan,
|
||||
middleware.validatePasswordReset,
|
||||
resetPasswordController,
|
||||
);
|
||||
router.post('/resetPassword', checkBan, validatePasswordReset, resetPasswordController);
|
||||
|
||||
router.get('/2fa/enable', middleware.requireJwtAuth, enable2FA);
|
||||
router.post('/2fa/verify', middleware.requireJwtAuth, verify2FA);
|
||||
router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken);
|
||||
router.post('/2fa/confirm', middleware.requireJwtAuth, confirm2FA);
|
||||
router.post('/2fa/disable', middleware.requireJwtAuth, disable2FA);
|
||||
router.post('/2fa/backup/regenerate', middleware.requireJwtAuth, regenerateBackupCodes);
|
||||
router.get('/2fa/enable', requireJwtAuth, enable2FA);
|
||||
router.post('/2fa/verify', requireJwtAuth, verify2FA);
|
||||
router.post('/2fa/verify-temp', checkBan, verify2FAWithTempToken);
|
||||
router.post('/2fa/confirm', requireJwtAuth, confirm2FA);
|
||||
router.post('/2fa/disable', requireJwtAuth, disable2FA);
|
||||
router.post('/2fa/backup/regenerate', requireJwtAuth, regenerateBackupCodes);
|
||||
|
||||
router.get('/graph-token', middleware.requireJwtAuth, graphTokenController);
|
||||
router.get('/graph-token', requireJwtAuth, graphTokenController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -111,20 +111,17 @@ router.get('/', async function (req, res) {
|
||||
payload.mcpServers = {};
|
||||
const config = await getCustomConfig();
|
||||
if (config?.mcpServers != null) {
|
||||
try {
|
||||
const mcpManager = getMCPManager();
|
||||
const oauthServers = mcpManager.getOAuthServers();
|
||||
for (const serverName in config.mcpServers) {
|
||||
const serverConfig = config.mcpServers[serverName];
|
||||
payload.mcpServers[serverName] = {
|
||||
startup: serverConfig?.startup,
|
||||
chatMenu: serverConfig?.chatMenu,
|
||||
isOAuth: oauthServers?.has(serverName),
|
||||
customUserVars: serverConfig?.customUserVars || {},
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error loading MCP servers', err);
|
||||
const mcpManager = getMCPManager();
|
||||
const oauthServers = mcpManager.getOAuthServers();
|
||||
|
||||
for (const serverName in config.mcpServers) {
|
||||
const serverConfig = config.mcpServers[serverName];
|
||||
payload.mcpServers[serverName] = {
|
||||
customUserVars: serverConfig?.customUserVars || {},
|
||||
chatMenu: serverConfig?.chatMenu,
|
||||
isOAuth: oauthServers.has(serverName),
|
||||
startup: serverConfig?.startup,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
const { Router } = require('express');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { MCPOAuthHandler, getUserMCPAuthMap } = require('@librechat/api');
|
||||
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
|
||||
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
|
||||
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
|
||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||
const { MCPOAuthHandler } = require('@librechat/api');
|
||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
|
||||
const { setCachedTools, getCachedTools, loadCustomConfig } = require('~/server/services/Config');
|
||||
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
|
||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { findPluginAuthsByKeys } = require('~/models');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
const router = Router();
|
||||
@@ -144,12 +142,33 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
|
||||
);
|
||||
|
||||
const userTools = (await getCachedTools({ userId: flowState.userId })) || {};
|
||||
|
||||
const mcpDelimiter = Constants.mcp_delimiter;
|
||||
for (const key of Object.keys(userTools)) {
|
||||
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
|
||||
delete userTools[key];
|
||||
}
|
||||
}
|
||||
|
||||
const tools = await userConnection.fetchTools();
|
||||
await updateMCPUserTools({
|
||||
userId: flowState.userId,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
for (const tool of tools) {
|
||||
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
|
||||
userTools[name] = {
|
||||
type: 'function',
|
||||
['function']: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await setCachedTools(userTools, { userId: flowState.userId });
|
||||
|
||||
logger.debug(
|
||||
`[MCP OAuth] Cached ${tools.length} tools for ${serverName} user ${flowState.userId}`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
|
||||
}
|
||||
@@ -296,47 +315,133 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
|
||||
|
||||
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
|
||||
|
||||
const mcpManager = getMCPManager();
|
||||
const serverConfig = mcpManager.getRawConfig(serverName);
|
||||
if (!serverConfig) {
|
||||
const printConfig = false;
|
||||
const config = await loadCustomConfig(printConfig);
|
||||
if (!config || !config.mcpServers || !config.mcpServers[serverName]) {
|
||||
return res.status(404).json({
|
||||
error: `MCP server '${serverName}' not found in configuration`,
|
||||
});
|
||||
}
|
||||
|
||||
await mcpManager.disconnectUserConnection(user.id, serverName);
|
||||
logger.info(
|
||||
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
const flowManager = getFlowStateManager(flowsCache);
|
||||
const mcpManager = getMCPManager();
|
||||
|
||||
await mcpManager.disconnectServer(serverName);
|
||||
logger.info(`[MCP Reinitialize] Disconnected existing server: ${serverName}`);
|
||||
|
||||
const serverConfig = config.mcpServers[serverName];
|
||||
mcpManager.mcpConfigs[serverName] = serverConfig;
|
||||
let customUserVars = {};
|
||||
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
|
||||
for (const varName of Object.keys(serverConfig.customUserVars)) {
|
||||
try {
|
||||
const value = await getUserPluginAuthValue(user.id, varName, false);
|
||||
customUserVars[varName] = value;
|
||||
} catch (err) {
|
||||
logger.error(`[MCP Reinitialize] Error fetching ${varName} for user ${user.id}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let userConnection = null;
|
||||
let oauthRequired = false;
|
||||
let oauthUrl = null;
|
||||
|
||||
try {
|
||||
userConnection = await mcpManager.getUserConnection({
|
||||
user,
|
||||
serverName,
|
||||
flowManager,
|
||||
customUserVars,
|
||||
tokenMethods: {
|
||||
findToken,
|
||||
updateToken,
|
||||
createToken,
|
||||
deleteTokens,
|
||||
},
|
||||
returnOnOAuth: true,
|
||||
oauthStart: async (authURL) => {
|
||||
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
|
||||
oauthUrl = authURL;
|
||||
oauthRequired = true;
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[MCP Reinitialize] Successfully established connection for ${serverName}`);
|
||||
} catch (err) {
|
||||
logger.info(`[MCP Reinitialize] getUserConnection threw error: ${err.message}`);
|
||||
logger.info(
|
||||
`[MCP Reinitialize] OAuth state - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
|
||||
);
|
||||
|
||||
const isOAuthError =
|
||||
err.message?.includes('OAuth') ||
|
||||
err.message?.includes('authentication') ||
|
||||
err.message?.includes('401');
|
||||
|
||||
const isOAuthFlowInitiated = err.message === 'OAuth flow initiated - return early';
|
||||
|
||||
if (isOAuthError || oauthRequired || isOAuthFlowInitiated) {
|
||||
logger.info(
|
||||
`[MCP Reinitialize] OAuth required for ${serverName} (isOAuthError: ${isOAuthError}, oauthRequired: ${oauthRequired}, isOAuthFlowInitiated: ${isOAuthFlowInitiated})`,
|
||||
);
|
||||
oauthRequired = true;
|
||||
} else {
|
||||
logger.error(
|
||||
`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`,
|
||||
err,
|
||||
);
|
||||
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
|
||||
}
|
||||
}
|
||||
|
||||
if (userConnection && !oauthRequired) {
|
||||
const userTools = (await getCachedTools({ userId: user.id })) || {};
|
||||
|
||||
const mcpDelimiter = Constants.mcp_delimiter;
|
||||
for (const key of Object.keys(userTools)) {
|
||||
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
|
||||
delete userTools[key];
|
||||
}
|
||||
}
|
||||
|
||||
const tools = await userConnection.fetchTools();
|
||||
for (const tool of tools) {
|
||||
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
|
||||
userTools[name] = {
|
||||
type: 'function',
|
||||
['function']: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await setCachedTools(userTools, { userId: user.id });
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[MCP Reinitialize] Sending response for ${serverName} - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
|
||||
);
|
||||
|
||||
/** @type {Record<string, Record<string, string>> | undefined} */
|
||||
let userMCPAuthMap;
|
||||
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
|
||||
userMCPAuthMap = await getUserMCPAuthMap({
|
||||
userId: user.id,
|
||||
servers: [serverName],
|
||||
findPluginAuthsByKeys,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await reinitMCPServer({
|
||||
req,
|
||||
serverName,
|
||||
userMCPAuthMap,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
|
||||
}
|
||||
|
||||
const { success, message, oauthRequired, oauthUrl } = result;
|
||||
const getResponseMessage = () => {
|
||||
if (oauthRequired) {
|
||||
return `MCP server '${serverName}' ready for OAuth authentication`;
|
||||
}
|
||||
if (userConnection) {
|
||||
return `MCP server '${serverName}' reinitialized successfully`;
|
||||
}
|
||||
return `Failed to reinitialize MCP server '${serverName}'`;
|
||||
};
|
||||
|
||||
res.json({
|
||||
success,
|
||||
message,
|
||||
oauthUrl,
|
||||
success: (userConnection && !oauthRequired) || (oauthRequired && oauthUrl),
|
||||
message: getResponseMessage(),
|
||||
serverName,
|
||||
oauthRequired,
|
||||
oauthUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[MCP Reinitialize] Unexpected error', error);
|
||||
@@ -446,14 +551,15 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
|
||||
return res.status(401).json({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const mcpManager = getMCPManager();
|
||||
const serverConfig = mcpManager.getRawConfig(serverName);
|
||||
if (!serverConfig) {
|
||||
const printConfig = false;
|
||||
const config = await loadCustomConfig(printConfig);
|
||||
if (!config || !config.mcpServers || !config.mcpServers[serverName]) {
|
||||
return res.status(404).json({
|
||||
error: `MCP server '${serverName}' not found in configuration`,
|
||||
});
|
||||
}
|
||||
|
||||
const serverConfig = config.mcpServers[serverName];
|
||||
const pluginKey = `${Constants.mcp_prefix}${serverName}`;
|
||||
const authValueFlags = {};
|
||||
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { randomState } = require('openid-client');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { isEnabled, createSetBalanceConfig } = require('@librechat/api');
|
||||
const { checkDomainAllowed, loginLimiter, logHeaders, checkBan } = require('~/server/middleware');
|
||||
const {
|
||||
checkBan,
|
||||
logHeaders,
|
||||
loginLimiter,
|
||||
setBalanceConfig,
|
||||
checkDomainAllowed,
|
||||
} = require('~/server/middleware');
|
||||
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const { getBalanceConfig } = require('~/server/services/Config');
|
||||
const { Balance } = require('~/db/models');
|
||||
|
||||
const setBalanceConfig = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
const {
|
||||
isEnabled,
|
||||
loadMemoryConfig,
|
||||
agentsConfigSetup,
|
||||
loadWebSearchConfig,
|
||||
} = require('@librechat/api');
|
||||
const { loadMemoryConfig, agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
|
||||
const {
|
||||
FileSources,
|
||||
loadOCRConfig,
|
||||
@@ -11,16 +6,16 @@ const {
|
||||
getConfigDefaults,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
checkWebSearchConfig,
|
||||
checkAzureVariables,
|
||||
checkVariables,
|
||||
checkHealth,
|
||||
checkConfig,
|
||||
checkVariables,
|
||||
checkAzureVariables,
|
||||
checkWebSearchConfig,
|
||||
} = require('./start/checks');
|
||||
const { ensureDefaultCategories, seedDefaultRoles, initializeRoles } = require('~/models');
|
||||
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
|
||||
const { initializeAzureBlobService } = require('./Files/Azure/initialize');
|
||||
const { initializeFirebase } = require('./Files/Firebase/initialize');
|
||||
const { seedDefaultRoles, initializeRoles, ensureDefaultCategories } = require('~/models');
|
||||
const loadCustomConfig = require('./Config/loadCustomConfig');
|
||||
const handleRateLimits = require('./Config/handleRateLimits');
|
||||
const { loadDefaultInterface } = require('./start/interface');
|
||||
@@ -29,6 +24,7 @@ const { azureConfigSetup } = require('./start/azureOpenAI');
|
||||
const { processModelSpecs } = require('./start/modelSpecs');
|
||||
const { initializeS3 } = require('./Files/S3/initialize');
|
||||
const { loadAndFormatTools } = require('./ToolService');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { setCachedTools } = require('./Config');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
|
||||
@@ -995,4 +995,23 @@ describe('AppService updating app.locals and issuing warnings', () => {
|
||||
roles: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default peoplePicker permissions when not specified', async () => {
|
||||
const mockConfig = {
|
||||
interface: {
|
||||
// No peoplePicker configuration
|
||||
},
|
||||
};
|
||||
|
||||
require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(mockConfig));
|
||||
|
||||
const app = { locals: {} };
|
||||
await AppService(app);
|
||||
|
||||
// Check that default permissions are applied
|
||||
expect(app.locals.interfaceConfig.peoplePicker).toBeDefined();
|
||||
expect(app.locals.interfaceConfig.peoplePicker.users).toBe(true);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.groups).toBe(true);
|
||||
expect(app.locals.interfaceConfig.peoplePicker.roles).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ const ToolCacheKeys = {
|
||||
* @param {string[]} [options.roleIds] - Role IDs for role-based tools
|
||||
* @param {string[]} [options.groupIds] - Group IDs for group-based tools
|
||||
* @param {boolean} [options.includeGlobal=true] - Whether to include global tools
|
||||
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
|
||||
* @returns {Promise<Object|null>} The available tools object or null if not cached
|
||||
*/
|
||||
async function getCachedTools(options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
@@ -41,13 +41,13 @@ async function getCachedTools(options = {}) {
|
||||
// Future implementation will merge tools from multiple sources
|
||||
// based on user permissions, roles, and groups
|
||||
if (userId) {
|
||||
/** @type {LCAvailableTools | null} Check if we have pre-computed effective tools for this user */
|
||||
// Check if we have pre-computed effective tools for this user
|
||||
const effectiveTools = await cache.get(ToolCacheKeys.EFFECTIVE(userId));
|
||||
if (effectiveTools) {
|
||||
return effectiveTools;
|
||||
}
|
||||
|
||||
/** @type {LCAvailableTools | null} Otherwise, compute from individual sources */
|
||||
// Otherwise, compute from individual sources
|
||||
const toolSources = [];
|
||||
|
||||
if (includeGlobal) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, getUserMCPAuthMap } = require('@librechat/api');
|
||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||
const { normalizeEndpointName } = require('~/server/utils');
|
||||
const loadCustomConfig = require('./loadCustomConfig');
|
||||
@@ -52,6 +53,31 @@ const getCustomEndpointConfig = async (endpoint) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {string} params.userId
|
||||
* @param {GenericTool[]} [params.tools]
|
||||
* @param {import('@librechat/data-schemas').PluginAuthMethods['findPluginAuthsByKeys']} params.findPluginAuthsByKeys
|
||||
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
|
||||
*/
|
||||
async function getMCPAuthMap({ userId, tools, findPluginAuthsByKeys }) {
|
||||
try {
|
||||
if (!tools || tools.length === 0) {
|
||||
return;
|
||||
}
|
||||
return await getUserMCPAuthMap({
|
||||
tools,
|
||||
userId,
|
||||
findPluginAuthsByKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
@@ -62,6 +88,7 @@ async function hasCustomUserVars() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMCPAuthMap,
|
||||
getCustomConfig,
|
||||
getBalanceConfig,
|
||||
hasCustomUserVars,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const { config } = require('./EndpointService');
|
||||
const getCachedTools = require('./getCachedTools');
|
||||
const getCustomConfig = require('./getCustomConfig');
|
||||
const mcpToolsCache = require('./mcpToolsCache');
|
||||
const loadCustomConfig = require('./loadCustomConfig');
|
||||
const loadConfigModels = require('./loadConfigModels');
|
||||
const loadDefaultModels = require('./loadDefaultModels');
|
||||
@@ -18,6 +17,5 @@ module.exports = {
|
||||
loadAsyncEndpoints,
|
||||
...getCachedTools,
|
||||
...getCustomConfig,
|
||||
...mcpToolsCache,
|
||||
...getEndpointsConfig,
|
||||
};
|
||||
|
||||
@@ -76,11 +76,10 @@ async function loadConfigModels(req) {
|
||||
fetchPromisesMap[uniqueKey] =
|
||||
fetchPromisesMap[uniqueKey] ||
|
||||
fetchModels({
|
||||
name,
|
||||
apiKey: API_KEY,
|
||||
baseURL: BASE_URL,
|
||||
user: req.user.id,
|
||||
direct: endpoint.directEndpoint,
|
||||
baseURL: BASE_URL,
|
||||
apiKey: API_KEY,
|
||||
name,
|
||||
userIdQuery: models.userIdQuery,
|
||||
});
|
||||
uniqueKeyToEndpointsMap[uniqueKey] = uniqueKeyToEndpointsMap[uniqueKey] || [];
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||
const { getCachedTools, setCachedTools } = require('./getCachedTools');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
* Updates MCP tools in the cache for a specific server and user
|
||||
* @param {Object} params - Parameters for updating MCP tools
|
||||
* @param {string} params.userId - User ID
|
||||
* @param {string} params.serverName - MCP server name
|
||||
* @param {Array} params.tools - Array of tool objects from MCP server
|
||||
* @returns {Promise<LCAvailableTools>}
|
||||
*/
|
||||
async function updateMCPUserTools({ userId, serverName, tools }) {
|
||||
try {
|
||||
const userTools = await getCachedTools({ userId });
|
||||
|
||||
const mcpDelimiter = Constants.mcp_delimiter;
|
||||
for (const key of Object.keys(userTools)) {
|
||||
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
|
||||
delete userTools[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
|
||||
userTools[name] = {
|
||||
type: 'function',
|
||||
['function']: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await setCachedTools(userTools, { userId });
|
||||
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
logger.debug(`[MCP Cache] Updated ${tools.length} tools for ${serverName} user ${userId}`);
|
||||
return userTools;
|
||||
} catch (error) {
|
||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges app-level tools with global tools
|
||||
* @param {import('@librechat/api').LCAvailableTools} appTools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function mergeAppTools(appTools) {
|
||||
try {
|
||||
const count = Object.keys(appTools).length;
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
const cachedTools = await getCachedTools({ includeGlobal: true });
|
||||
const mergedTools = { ...cachedTools, ...appTools };
|
||||
await setCachedTools(mergedTools, { isGlobal: true });
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
logger.debug(`Merged ${count} app-level tools`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to merge app-level tools:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges user-level tools with global tools
|
||||
* @param {object} params
|
||||
* @param {string} params.userId
|
||||
* @param {Record<string, FunctionTool>} params.cachedUserTools
|
||||
* @param {import('@librechat/api').LCAvailableTools} params.userTools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function mergeUserTools({ userId, cachedUserTools, userTools }) {
|
||||
try {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
const count = Object.keys(userTools).length;
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
const cachedTools = cachedUserTools ?? (await getCachedTools({ userId }));
|
||||
const mergedTools = { ...cachedTools, ...userTools };
|
||||
await setCachedTools(mergedTools, { userId });
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
logger.debug(`Merged ${count} user-level tools`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to merge user-level tools:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all MCP tools for a specific server
|
||||
* @param {Object} params - Parameters for clearing MCP tools
|
||||
* @param {string} [params.userId] - User ID (if clearing user-specific tools)
|
||||
* @param {string} params.serverName - MCP server name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function clearMCPServerTools({ userId, serverName }) {
|
||||
try {
|
||||
const tools = await getCachedTools({ userId, includeGlobal: !userId });
|
||||
|
||||
// Remove all tools for this server
|
||||
const mcpDelimiter = Constants.mcp_delimiter;
|
||||
let removedCount = 0;
|
||||
for (const key of Object.keys(tools)) {
|
||||
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
|
||||
delete tools[key];
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
await setCachedTools(tools, userId ? { userId } : { isGlobal: true });
|
||||
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
|
||||
logger.debug(
|
||||
`[MCP Cache] Removed ${removedCount} tools for ${serverName}${userId ? ` user ${userId}` : ' (global)'}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[MCP Cache] Failed to clear tools for ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mergeAppTools,
|
||||
mergeUserTools,
|
||||
updateMCPUserTools,
|
||||
clearMCPServerTools,
|
||||
};
|
||||
@@ -30,13 +30,7 @@ const { getModelMaxTokens } = require('~/utils');
|
||||
* @param {TEndpointOption} [params.endpointOption]
|
||||
* @param {Set<string>} [params.allowedProviders]
|
||||
* @param {boolean} [params.isInitialAgent]
|
||||
* @returns {Promise<Agent & {
|
||||
* tools: StructuredTool[],
|
||||
* attachments: Array<MongoFile>,
|
||||
* toolContextMap: Record<string, unknown>,
|
||||
* maxContextTokens: number,
|
||||
* userMCPAuthMap?: Record<string, Record<string, string>>
|
||||
* }>}
|
||||
* @returns {Promise<Agent & { tools: StructuredTool[], attachments: Array<MongoFile>, toolContextMap: Record<string, unknown>, maxContextTokens: number }>}
|
||||
*/
|
||||
const initializeAgent = async ({
|
||||
req,
|
||||
@@ -97,19 +91,16 @@ const initializeAgent = async ({
|
||||
});
|
||||
|
||||
const provider = agent.provider;
|
||||
const {
|
||||
tools: structuredTools,
|
||||
toolContextMap,
|
||||
userMCPAuthMap,
|
||||
} = (await loadTools?.({
|
||||
req,
|
||||
res,
|
||||
provider,
|
||||
agentId: agent.id,
|
||||
tools: agent.tools,
|
||||
model: agent.model,
|
||||
tool_resources,
|
||||
})) ?? {};
|
||||
const { tools: structuredTools, toolContextMap } =
|
||||
(await loadTools?.({
|
||||
req,
|
||||
res,
|
||||
provider,
|
||||
agentId: agent.id,
|
||||
tools: agent.tools,
|
||||
model: agent.model,
|
||||
tool_resources,
|
||||
})) ?? {};
|
||||
|
||||
agent.endpoint = provider;
|
||||
const { getOptions, overrideProvider } = await getProviderConfig(provider);
|
||||
@@ -198,7 +189,6 @@ const initializeAgent = async ({
|
||||
tools,
|
||||
attachments,
|
||||
resendFiles,
|
||||
userMCPAuthMap,
|
||||
toolContextMap,
|
||||
useLegacyContent: !!options.useLegacyContent,
|
||||
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9),
|
||||
|
||||
@@ -19,10 +19,7 @@ const AgentClient = require('~/server/controllers/agents/client');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
const { logViolation } = require('~/cache');
|
||||
|
||||
/**
|
||||
* @param {AbortSignal} signal
|
||||
*/
|
||||
function createToolLoader(signal) {
|
||||
function createToolLoader() {
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {ServerRequest} params.req
|
||||
@@ -32,11 +29,7 @@ function createToolLoader(signal) {
|
||||
* @param {string} params.provider
|
||||
* @param {string} params.model
|
||||
* @param {AgentToolResources} params.tool_resources
|
||||
* @returns {Promise<{
|
||||
* tools: StructuredTool[],
|
||||
* toolContextMap: Record<string, unknown>,
|
||||
* userMCPAuthMap?: Record<string, Record<string, string>>
|
||||
* } | undefined>}
|
||||
* @returns {Promise<{ tools: StructuredTool[], toolContextMap: Record<string, unknown> } | undefined>}
|
||||
*/
|
||||
return async function loadTools({ req, res, agentId, tools, provider, model, tool_resources }) {
|
||||
const agent = { id: agentId, tools, provider, model };
|
||||
@@ -45,7 +38,6 @@ function createToolLoader(signal) {
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
signal,
|
||||
tool_resources,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -54,7 +46,7 @@ function createToolLoader(signal) {
|
||||
};
|
||||
}
|
||||
|
||||
const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
||||
const initializeClient = async ({ req, res, endpointOption }) => {
|
||||
if (!endpointOption) {
|
||||
throw new Error('Endpoint option not provided');
|
||||
}
|
||||
@@ -100,7 +92,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
||||
/** @type {Set<string>} */
|
||||
const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders);
|
||||
|
||||
const loadTools = createToolLoader(signal);
|
||||
const loadTools = createToolLoader();
|
||||
/** @type {Array<MongoFile>} */
|
||||
const requestFiles = req.body.files ?? [];
|
||||
/** @type {string} */
|
||||
@@ -119,7 +111,6 @@ 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 });
|
||||
@@ -149,11 +140,6 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
||||
endpointOption,
|
||||
allowedProviders,
|
||||
});
|
||||
if (userMCPAuthMap != null) {
|
||||
Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {});
|
||||
} else {
|
||||
userMCPAuthMap = config.userMCPAuthMap;
|
||||
}
|
||||
agentConfigs.set(agentId, config);
|
||||
}
|
||||
}
|
||||
@@ -202,7 +188,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
||||
: EModelEndpoint.agents,
|
||||
});
|
||||
|
||||
return { client, userMCPAuthMap };
|
||||
return { client };
|
||||
};
|
||||
|
||||
module.exports = { initializeClient };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { saveConvo } = require('~/models');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Add title to conversation in a way that avoids memory retention
|
||||
|
||||
@@ -45,10 +45,6 @@ function getClaudeHeaders(model, supportsCacheControl) {
|
||||
'anthropic-beta':
|
||||
'token-efficient-tools-2025-02-19,output-128k-2025-02-19,prompt-caching-2024-07-31',
|
||||
};
|
||||
} else if (/claude-sonnet-4/.test(model)) {
|
||||
return {
|
||||
'anthropic-beta': 'prompt-caching-2024-07-31,context-1m-2025-08-07',
|
||||
};
|
||||
} else if (
|
||||
/claude-(?:sonnet|opus|haiku)-[4-9]/.test(model) ||
|
||||
/claude-[4-9]-(?:sonnet|opus|haiku)?/.test(model) ||
|
||||
|
||||
@@ -39,9 +39,8 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
|
||||
if (optionsOnly) {
|
||||
clientOptions = Object.assign(
|
||||
{
|
||||
proxy: PROXY ?? null,
|
||||
userId: req.user.id,
|
||||
reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? null,
|
||||
proxy: PROXY ?? null,
|
||||
modelOptions: endpointOption?.model_parameters ?? {},
|
||||
},
|
||||
clientOptions,
|
||||
|
||||
@@ -15,7 +15,6 @@ const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = requir
|
||||
* @param {number} [options.modelOptions.topK] - Controls the number of top tokens to consider.
|
||||
* @param {string[]} [options.modelOptions.stop] - Sequences where the API will stop generating further tokens.
|
||||
* @param {boolean} [options.modelOptions.stream] - Whether to stream the response.
|
||||
* @param {string} options.userId - The user ID for tracking and personalization.
|
||||
* @param {string} [options.proxy] - Proxy server URL.
|
||||
* @param {string} [options.reverseProxyUrl] - URL for a reverse proxy, if used.
|
||||
*
|
||||
@@ -48,11 +47,6 @@ function getLLMConfig(apiKey, options = {}) {
|
||||
maxTokens:
|
||||
mergedOptions.maxOutputTokens || anthropicSettings.maxOutputTokens.reset(mergedOptions.model),
|
||||
clientOptions: {},
|
||||
invocationKwargs: {
|
||||
metadata: {
|
||||
user_id: options.userId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
requestOptions = configureReasoning(requestOptions, systemOptions);
|
||||
|
||||
@@ -1,19 +1,50 @@
|
||||
const { anthropicSettings, removeNullishValues } = require('librechat-data-provider');
|
||||
const { getLLMConfig } = require('~/server/services/Endpoints/anthropic/llm');
|
||||
const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = require('./helpers');
|
||||
|
||||
jest.mock('https-proxy-agent', () => ({
|
||||
HttpsProxyAgent: jest.fn().mockImplementation((proxy) => ({ proxy })),
|
||||
}));
|
||||
|
||||
jest.mock('./helpers', () => ({
|
||||
checkPromptCacheSupport: jest.fn(),
|
||||
getClaudeHeaders: jest.fn(),
|
||||
configureReasoning: jest.fn((requestOptions) => requestOptions),
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
anthropicSettings: {
|
||||
model: { default: 'claude-3-opus-20240229' },
|
||||
maxOutputTokens: { default: 4096, reset: jest.fn(() => 4096) },
|
||||
thinking: { default: false },
|
||||
promptCache: { default: false },
|
||||
thinkingBudget: { default: null },
|
||||
},
|
||||
removeNullishValues: jest.fn((obj) => {
|
||||
const result = {};
|
||||
for (const key in obj) {
|
||||
if (obj[key] !== null && obj[key] !== undefined) {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('getLLMConfig', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
checkPromptCacheSupport.mockReturnValue(false);
|
||||
getClaudeHeaders.mockReturnValue(undefined);
|
||||
configureReasoning.mockImplementation((requestOptions) => requestOptions);
|
||||
anthropicSettings.maxOutputTokens.reset.mockReturnValue(4096);
|
||||
});
|
||||
|
||||
it('should create a basic configuration with default values', () => {
|
||||
const result = getLLMConfig('test-api-key', { modelOptions: {} });
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('apiKey', 'test-api-key');
|
||||
expect(result.llmConfig).toHaveProperty('model', 'claude-3-5-sonnet-latest');
|
||||
expect(result.llmConfig).toHaveProperty('model', anthropicSettings.model.default);
|
||||
expect(result.llmConfig).toHaveProperty('stream', true);
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens');
|
||||
});
|
||||
@@ -68,73 +99,40 @@ describe('getLLMConfig', () => {
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
it('should NOT include topK and topP for Claude-3-7 models with thinking enabled (hyphen notation)', () => {
|
||||
it('should NOT include topK and topP for Claude-3-7 models (hyphen notation)', () => {
|
||||
configureReasoning.mockImplementation((requestOptions) => {
|
||||
requestOptions.thinking = { type: 'enabled' };
|
||||
return requestOptions;
|
||||
});
|
||||
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
topK: 10,
|
||||
topP: 0.9,
|
||||
thinking: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).not.toHaveProperty('topK');
|
||||
expect(result.llmConfig).not.toHaveProperty('topP');
|
||||
expect(result.llmConfig).toHaveProperty('thinking');
|
||||
expect(result.llmConfig.thinking).toHaveProperty('type', 'enabled');
|
||||
// When thinking is enabled, it uses the default thinkingBudget of 2000
|
||||
expect(result.llmConfig.thinking).toHaveProperty('budget_tokens', 2000);
|
||||
});
|
||||
|
||||
it('should add "prompt-caching" and "context-1m" beta headers for claude-sonnet-4 model', () => {
|
||||
const modelOptions = {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
promptCache: true,
|
||||
};
|
||||
const result = getLLMConfig('test-key', { modelOptions });
|
||||
const clientOptions = result.llmConfig.clientOptions;
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
it('should add "prompt-caching" and "context-1m" beta headers for claude-sonnet-4 model formats', () => {
|
||||
const modelVariations = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4-latest',
|
||||
'anthropic/claude-sonnet-4-20250514',
|
||||
];
|
||||
|
||||
modelVariations.forEach((model) => {
|
||||
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');
|
||||
expect(clientOptions.defaultHeaders['anthropic-beta']).toBe(
|
||||
'prompt-caching-2024-07-31,context-1m-2025-08-07',
|
||||
);
|
||||
it('should NOT include topK and topP for Claude-3.7 models (decimal notation)', () => {
|
||||
configureReasoning.mockImplementation((requestOptions) => {
|
||||
requestOptions.thinking = { type: 'enabled' };
|
||||
return requestOptions;
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT include topK and topP for Claude-3.7 models with thinking enabled (decimal notation)', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3.7-sonnet',
|
||||
topK: 10,
|
||||
topP: 0.9,
|
||||
thinking: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).not.toHaveProperty('topK');
|
||||
expect(result.llmConfig).not.toHaveProperty('topP');
|
||||
expect(result.llmConfig).toHaveProperty('thinking');
|
||||
expect(result.llmConfig.thinking).toHaveProperty('type', 'enabled');
|
||||
// When thinking is enabled, it uses the default thinkingBudget of 2000
|
||||
expect(result.llmConfig.thinking).toHaveProperty('budget_tokens', 2000);
|
||||
});
|
||||
|
||||
it('should handle custom maxOutputTokens', () => {
|
||||
@@ -235,6 +233,7 @@ describe('getLLMConfig', () => {
|
||||
});
|
||||
|
||||
it('should handle maxOutputTokens when explicitly set to falsy value', () => {
|
||||
anthropicSettings.maxOutputTokens.reset.mockReturnValue(8192);
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-opus',
|
||||
@@ -242,8 +241,8 @@ describe('getLLMConfig', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// The actual anthropicSettings.maxOutputTokens.reset('claude-3-opus') returns 4096
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
|
||||
expect(anthropicSettings.maxOutputTokens.reset).toHaveBeenCalledWith('claude-3-opus');
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 8192);
|
||||
});
|
||||
|
||||
it('should handle both proxy and reverseProxyUrl', () => {
|
||||
@@ -264,6 +263,9 @@ describe('getLLMConfig', () => {
|
||||
});
|
||||
|
||||
it('should handle prompt cache with supported model', () => {
|
||||
checkPromptCacheSupport.mockReturnValue(true);
|
||||
getClaudeHeaders.mockReturnValue({ 'anthropic-beta': 'prompt-caching-2024-07-31' });
|
||||
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-5-sonnet',
|
||||
@@ -271,38 +273,43 @@ describe('getLLMConfig', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// claude-3-5-sonnet supports prompt caching and should get the appropriate headers
|
||||
expect(checkPromptCacheSupport).toHaveBeenCalledWith('claude-3-5-sonnet');
|
||||
expect(getClaudeHeaders).toHaveBeenCalledWith('claude-3-5-sonnet', true);
|
||||
expect(result.llmConfig.clientOptions.defaultHeaders).toEqual({
|
||||
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31',
|
||||
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle thinking and thinkingBudget options', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
configureReasoning.mockImplementation((requestOptions, systemOptions) => {
|
||||
if (systemOptions.thinking) {
|
||||
requestOptions.thinking = { type: 'enabled' };
|
||||
}
|
||||
if (systemOptions.thinkingBudget) {
|
||||
requestOptions.thinking = {
|
||||
...requestOptions.thinking,
|
||||
budget_tokens: systemOptions.thinkingBudget,
|
||||
};
|
||||
}
|
||||
return requestOptions;
|
||||
});
|
||||
|
||||
getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
thinking: true,
|
||||
thinkingBudget: 10000, // This exceeds the default max_tokens of 8192
|
||||
thinkingBudget: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
// The function should add thinking configuration for claude-3-7 models
|
||||
expect(result.llmConfig).toHaveProperty('thinking');
|
||||
expect(result.llmConfig.thinking).toHaveProperty('type', 'enabled');
|
||||
// With claude-3-7-sonnet, the max_tokens default is 8192
|
||||
// Budget tokens gets adjusted to 90% of max_tokens (8192 * 0.9 = 7372) when it exceeds max_tokens
|
||||
expect(result.llmConfig.thinking).toHaveProperty('budget_tokens', 7372);
|
||||
|
||||
// Test with budget_tokens within max_tokens limit
|
||||
const result2 = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
expect(configureReasoning).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
thinking: true,
|
||||
thinkingBudget: 2000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result2.llmConfig.thinking).toHaveProperty('budget_tokens', 2000);
|
||||
promptCache: false,
|
||||
thinkingBudget: 5000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove system options from modelOptions', () => {
|
||||
@@ -323,6 +330,16 @@ describe('getLLMConfig', () => {
|
||||
});
|
||||
|
||||
it('should handle all nullish values removal', () => {
|
||||
removeNullishValues.mockImplementation((obj) => {
|
||||
const cleaned = {};
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
});
|
||||
return cleaned;
|
||||
});
|
||||
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
temperature: null,
|
||||
|
||||
@@ -109,14 +109,14 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
|
||||
|
||||
apiKey = azureOptions.azureOpenAIApiKey;
|
||||
opts.defaultQuery = { 'api-version': azureOptions.azureOpenAIApiVersion };
|
||||
opts.defaultHeaders = resolveHeaders({
|
||||
headers: {
|
||||
opts.defaultHeaders = resolveHeaders(
|
||||
{
|
||||
...headers,
|
||||
'api-key': apiKey,
|
||||
'OpenAI-Beta': `assistants=${version}`,
|
||||
},
|
||||
user: req.user,
|
||||
});
|
||||
req.user,
|
||||
);
|
||||
opts.model = azureOptions.azureOpenAIApiDeploymentName;
|
||||
|
||||
if (initAppClient) {
|
||||
|
||||
@@ -28,11 +28,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
|
||||
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
|
||||
|
||||
let resolvedHeaders = resolveHeaders({
|
||||
headers: endpointConfig.headers,
|
||||
user: req.user,
|
||||
body: req.body,
|
||||
});
|
||||
let resolvedHeaders = resolveHeaders(endpointConfig.headers, req.user);
|
||||
|
||||
if (CUSTOM_API_KEY.match(envVarRegex)) {
|
||||
throw new Error(`Missing API Key for ${endpoint}.`);
|
||||
|
||||
@@ -64,14 +64,13 @@ describe('custom/initializeClient', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => {
|
||||
it('calls resolveHeaders with headers and user', async () => {
|
||||
const { resolveHeaders } = require('@librechat/api');
|
||||
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
|
||||
expect(resolveHeaders).toHaveBeenCalledWith({
|
||||
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
|
||||
user: { id: 'user-123', email: 'test@example.com' },
|
||||
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
|
||||
});
|
||||
expect(resolveHeaders).toHaveBeenCalledWith(
|
||||
{ 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
|
||||
{ id: 'user-123', email: 'test@example.com' },
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if endpoint config is missing', async () => {
|
||||
|
||||
@@ -81,10 +81,10 @@ const initializeClient = async ({
|
||||
serverless = _serverless;
|
||||
|
||||
clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
|
||||
clientOptions.headers = resolveHeaders({
|
||||
headers: { ...headers, ...(clientOptions.headers ?? {}) },
|
||||
user: req.user,
|
||||
});
|
||||
clientOptions.headers = resolveHeaders(
|
||||
{ ...headers, ...(clientOptions.headers ?? {}) },
|
||||
req.user,
|
||||
);
|
||||
|
||||
clientOptions.titleConvo = azureConfig.titleConvo;
|
||||
clientOptions.titleModel = azureConfig.titleModel;
|
||||
|
||||
@@ -2,10 +2,10 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const axios = require('axios');
|
||||
const fetch = require('node-fetch');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ref, uploadBytes, getDownloadURL, deleteObject } = require('firebase/storage');
|
||||
const { getBufferMetadata } = require('~/server/utils');
|
||||
const { getFirebaseStorage } = require('./initialize');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Deletes a file from Firebase Storage.
|
||||
@@ -145,10 +145,7 @@ function extractFirebaseFilePath(urlString) {
|
||||
}
|
||||
|
||||
return '';
|
||||
} catch {
|
||||
logger.debug(
|
||||
'[extractFirebaseFilePath] Failed to extract Firebase file path from URL, returning empty string',
|
||||
);
|
||||
} catch (error) {
|
||||
// If URL parsing fails, return an empty string
|
||||
return '';
|
||||
}
|
||||
@@ -214,24 +211,14 @@ async function uploadFileToFirebase({ req, file, file_id }) {
|
||||
const inputBuffer = await fs.promises.readFile(inputFilePath);
|
||||
const bytes = Buffer.byteLength(inputBuffer);
|
||||
const userId = req.user.id;
|
||||
|
||||
const fileName = `${file_id}__${path.basename(inputFilePath)}`;
|
||||
try {
|
||||
const downloadURL = await saveBufferToFirebase({ userId, buffer: inputBuffer, fileName });
|
||||
return { filepath: downloadURL, bytes };
|
||||
} catch (err) {
|
||||
logger.error('[uploadFileToFirebase] Error saving file buffer to Firebase:', err);
|
||||
try {
|
||||
if (file && file.path) {
|
||||
await fs.promises.unlink(file.path);
|
||||
}
|
||||
} catch (unlinkError) {
|
||||
logger.error(
|
||||
'[uploadFileToFirebase] Error deleting temporary file, likely already deleted:',
|
||||
unlinkError.message,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const downloadURL = await saveBufferToFirebase({ userId, buffer: inputBuffer, fileName });
|
||||
|
||||
await fs.promises.unlink(inputFilePath);
|
||||
|
||||
return { filepath: downloadURL, bytes };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
const { z } = require('zod');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
Providers,
|
||||
StepTypes,
|
||||
GraphEvents,
|
||||
Constants: AgentConstants,
|
||||
} = require('@librechat/agents');
|
||||
const { Time, CacheKeys, StepTypes } = require('librechat-data-provider');
|
||||
const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents');
|
||||
const { Constants, ContentTypes, isAssistantsEndpoint } = require('librechat-data-provider');
|
||||
const {
|
||||
sendEvent,
|
||||
MCPOAuthHandler,
|
||||
normalizeServerName,
|
||||
convertWithResolvedRefs,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
CacheKeys,
|
||||
Constants,
|
||||
ContentTypes,
|
||||
isAssistantsEndpoint,
|
||||
} = require('librechat-data-provider');
|
||||
const { getCachedTools, loadCustomConfig } = require('./Config');
|
||||
const { findToken, createToken, updateToken } = require('~/models');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { reinitMCPServer } = require('./Tools/mcp');
|
||||
const { getCachedTools, loadCustomConfig } = require('./Config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
@@ -31,13 +20,16 @@ const { getLogStores } = require('~/cache');
|
||||
* @param {ServerResponse} params.res - The Express response object for sending events.
|
||||
* @param {string} params.stepId - The ID of the step in the flow.
|
||||
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
|
||||
* @param {string} params.loginFlowId - The ID of the login flow.
|
||||
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
|
||||
*/
|
||||
function createRunStepDeltaEmitter({ res, stepId, toolCall }) {
|
||||
function createOAuthStart({ res, stepId, toolCall, loginFlowId, flowManager, signal }) {
|
||||
/**
|
||||
* Creates a function to handle OAuth login requests.
|
||||
* @param {string} authURL - The URL to redirect the user for OAuth authentication.
|
||||
* @returns {void}
|
||||
* @returns {Promise<boolean>} Returns true to indicate the event was sent successfully.
|
||||
*/
|
||||
return function (authURL) {
|
||||
return async function (authURL) {
|
||||
/** @type {{ id: string; delta: AgentToolCallDelta }} */
|
||||
const data = {
|
||||
id: stepId,
|
||||
@@ -48,54 +40,17 @@ function createRunStepDeltaEmitter({ res, stepId, toolCall }) {
|
||||
expires_at: Date.now() + Time.TWO_MINUTES,
|
||||
},
|
||||
};
|
||||
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {ServerResponse} params.res - The Express response object for sending events.
|
||||
* @param {string} params.runId - The Run ID, i.e. message ID
|
||||
* @param {string} params.stepId - The ID of the step in the flow.
|
||||
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
|
||||
* @param {number} [params.index]
|
||||
*/
|
||||
function createRunStepEmitter({ res, runId, stepId, toolCall, index }) {
|
||||
return function () {
|
||||
/** @type {import('@librechat/agents').RunStep} */
|
||||
const data = {
|
||||
runId: runId ?? Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
|
||||
id: stepId,
|
||||
type: StepTypes.TOOL_CALLS,
|
||||
index: index ?? 0,
|
||||
stepDetails: {
|
||||
type: StepTypes.TOOL_CALLS,
|
||||
tool_calls: [toolCall],
|
||||
/** Used to ensure the handler (use of `sendEvent`) is only invoked once */
|
||||
await flowManager.createFlowWithHandler(
|
||||
loginFlowId,
|
||||
'oauth_login',
|
||||
async () => {
|
||||
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
|
||||
logger.debug('Sent OAuth login request to client');
|
||||
return true;
|
||||
},
|
||||
};
|
||||
sendEvent(res, { event: GraphEvents.ON_RUN_STEP, data });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a function used to ensure the flow handler is only invoked once
|
||||
* @param {object} params
|
||||
* @param {string} params.flowId - The ID of the login flow.
|
||||
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
|
||||
* @param {(authURL: string) => void} [params.callback]
|
||||
*/
|
||||
function createOAuthStart({ flowId, flowManager, callback }) {
|
||||
/**
|
||||
* Creates a function to handle OAuth login requests.
|
||||
* @param {string} authURL - The URL to redirect the user for OAuth authentication.
|
||||
* @returns {Promise<boolean>} Returns true to indicate the event was sent successfully.
|
||||
*/
|
||||
return async function (authURL) {
|
||||
await flowManager.createFlowWithHandler(flowId, 'oauth_login', async () => {
|
||||
callback?.(authURL);
|
||||
logger.debug('Sent OAuth login request to client');
|
||||
return true;
|
||||
});
|
||||
signal,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,166 +93,23 @@ function createAbortHandler({ userId, serverName, toolName, flowManager }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {() => void} params.runStepEmitter
|
||||
* @param {(authURL: string) => void} params.runStepDeltaEmitter
|
||||
* @returns {(authURL: string) => void}
|
||||
*/
|
||||
function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) {
|
||||
return function (authURL) {
|
||||
runStepEmitter();
|
||||
runStepDeltaEmitter(authURL);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
|
||||
* @param {ServerResponse} params.res - The Express response object for sending events.
|
||||
* @param {string} params.serverName
|
||||
* @param {AbortSignal} params.signal
|
||||
* @param {string} params.model
|
||||
* @param {number} [params.index]
|
||||
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
|
||||
* @returns { Promise<Array<typeof tool | { _call: (toolInput: Object | string) => unknown}>> } An object with `_call` method to execute the tool input.
|
||||
*/
|
||||
async function reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap }) {
|
||||
const runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID;
|
||||
const flowId = `${req.user?.id}:${serverName}:${Date.now()}`;
|
||||
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
|
||||
const stepId = 'step_oauth_login_' + serverName;
|
||||
const toolCall = {
|
||||
id: flowId,
|
||||
name: serverName,
|
||||
type: 'tool_call_chunk',
|
||||
};
|
||||
|
||||
const runStepEmitter = createRunStepEmitter({
|
||||
res,
|
||||
index,
|
||||
runId,
|
||||
stepId,
|
||||
toolCall,
|
||||
});
|
||||
const runStepDeltaEmitter = createRunStepDeltaEmitter({
|
||||
res,
|
||||
stepId,
|
||||
toolCall,
|
||||
});
|
||||
const callback = createOAuthCallback({ runStepEmitter, runStepDeltaEmitter });
|
||||
const oauthStart = createOAuthStart({
|
||||
res,
|
||||
flowId,
|
||||
callback,
|
||||
flowManager,
|
||||
});
|
||||
return await reinitMCPServer({
|
||||
req,
|
||||
signal,
|
||||
serverName,
|
||||
oauthStart,
|
||||
flowManager,
|
||||
userMCPAuthMap,
|
||||
forceNew: true,
|
||||
returnOnOAuth: false,
|
||||
connectionTimeout: Time.TWO_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all tools from the specified MCP Server via `toolKey`.
|
||||
* Creates a general tool for an entire action set.
|
||||
*
|
||||
* This function assumes tools could not be aggregated from the cache of tool definitions,
|
||||
* i.e. `availableTools`, and will reinitialize the MCP server to ensure all tools are generated.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
|
||||
* @param {ServerResponse} params.res - The Express response object for sending events.
|
||||
* @param {string} params.serverName
|
||||
* @param {string} params.model
|
||||
* @param {Providers | EModelEndpoint} params.provider - The provider for the tool.
|
||||
* @param {number} [params.index]
|
||||
* @param {AbortSignal} [params.signal]
|
||||
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
|
||||
* @returns { Promise<Array<typeof tool | { _call: (toolInput: Object | string) => unknown}>> } An object with `_call` method to execute the tool input.
|
||||
*/
|
||||
async function createMCPTools({ req, res, index, signal, serverName, provider, userMCPAuthMap }) {
|
||||
const result = await reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap });
|
||||
if (!result || !result.tools) {
|
||||
logger.warn(`[MCP][${serverName}] Failed to reinitialize MCP server.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const serverTools = [];
|
||||
for (const tool of result.tools) {
|
||||
const toolInstance = await createMCPTool({
|
||||
req,
|
||||
res,
|
||||
provider,
|
||||
userMCPAuthMap,
|
||||
availableTools: result.availableTools,
|
||||
toolKey: `${tool.name}${Constants.mcp_delimiter}${serverName}`,
|
||||
});
|
||||
if (toolInstance) {
|
||||
serverTools.push(toolInstance);
|
||||
}
|
||||
}
|
||||
|
||||
return serverTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single tool from the specified MCP Server via `toolKey`.
|
||||
* @param {Object} params
|
||||
* @param {Object} params - The parameters for loading action sets.
|
||||
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
|
||||
* @param {ServerResponse} params.res - The Express response object for sending events.
|
||||
* @param {string} params.toolKey - The toolKey for the tool.
|
||||
* @param {import('@librechat/agents').Providers | EModelEndpoint} params.provider - The provider for the tool.
|
||||
* @param {string} params.model - The model for the tool.
|
||||
* @param {number} [params.index]
|
||||
* @param {AbortSignal} [params.signal]
|
||||
* @param {Providers | EModelEndpoint} params.provider - The provider for the tool.
|
||||
* @param {LCAvailableTools} [params.availableTools]
|
||||
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
|
||||
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
|
||||
*/
|
||||
async function createMCPTool({
|
||||
req,
|
||||
res,
|
||||
index,
|
||||
signal,
|
||||
toolKey,
|
||||
provider,
|
||||
userMCPAuthMap,
|
||||
availableTools: tools,
|
||||
}) {
|
||||
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
|
||||
const availableTools =
|
||||
tools ?? (await getCachedTools({ userId: req.user?.id, includeGlobal: true }));
|
||||
/** @type {LCTool | undefined} */
|
||||
let toolDefinition = availableTools?.[toolKey]?.function;
|
||||
async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
||||
const availableTools = await getCachedTools({ userId: req.user?.id, includeGlobal: true });
|
||||
const toolDefinition = availableTools?.[toolKey]?.function;
|
||||
if (!toolDefinition) {
|
||||
logger.warn(
|
||||
`[MCP][${serverName}][${toolName}] Requested tool not found in available tools, re-initializing MCP server.`,
|
||||
);
|
||||
const result = await reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap });
|
||||
toolDefinition = result?.availableTools?.[toolKey]?.function;
|
||||
logger.error(`Tool ${toolKey} not found in available tools`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!toolDefinition) {
|
||||
logger.warn(`[MCP][${serverName}][${toolName}] Tool definition not found, cannot create tool.`);
|
||||
return;
|
||||
}
|
||||
|
||||
return createToolInstance({
|
||||
res,
|
||||
provider,
|
||||
toolName,
|
||||
serverName,
|
||||
toolDefinition,
|
||||
});
|
||||
}
|
||||
|
||||
function createToolInstance({ res, toolName, serverName, toolDefinition, provider: _provider }) {
|
||||
/** @type {LCTool} */
|
||||
const { description, parameters } = toolDefinition;
|
||||
const isGoogle = _provider === Providers.VERTEXAI || _provider === Providers.GOOGLE;
|
||||
@@ -310,8 +122,16 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
|
||||
schema = z.object({ input: z.string().optional() });
|
||||
}
|
||||
|
||||
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
|
||||
const normalizedToolKey = `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`;
|
||||
|
||||
if (!req.user?.id) {
|
||||
logger.error(
|
||||
`[MCP][${serverName}][${toolName}] User ID not found on request. Cannot create tool.`,
|
||||
);
|
||||
throw new Error(`User ID not found on request. Cannot create tool for ${toolKey}.`);
|
||||
}
|
||||
|
||||
/** @type {(toolArguments: Object | string, config?: GraphRunnableConfig) => Promise<unknown>} */
|
||||
const _call = async (toolArguments, config) => {
|
||||
const userId = config?.configurable?.user?.id || config?.configurable?.user_id;
|
||||
@@ -328,16 +148,14 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
|
||||
const provider = (config?.metadata?.provider || _provider)?.toLowerCase();
|
||||
|
||||
const { args: _args, stepId, ...toolCall } = config.toolCall ?? {};
|
||||
const flowId = `${serverName}:oauth_login:${config.metadata.thread_id}:${config.metadata.run_id}`;
|
||||
const runStepDeltaEmitter = createRunStepDeltaEmitter({
|
||||
const loginFlowId = `${serverName}:oauth_login:${config.metadata.thread_id}:${config.metadata.run_id}`;
|
||||
const oauthStart = createOAuthStart({
|
||||
res,
|
||||
stepId,
|
||||
toolCall,
|
||||
});
|
||||
const oauthStart = createOAuthStart({
|
||||
flowId,
|
||||
loginFlowId,
|
||||
flowManager,
|
||||
callback: runStepDeltaEmitter,
|
||||
signal: derivedSignal,
|
||||
});
|
||||
const oauthEnd = createOAuthEnd({
|
||||
res,
|
||||
@@ -362,7 +180,6 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
|
||||
signal: derivedSignal,
|
||||
},
|
||||
user: config?.configurable?.user,
|
||||
requestBody: config?.configurable?.requestBody,
|
||||
customUserVars,
|
||||
flowManager,
|
||||
tokenMethods: {
|
||||
@@ -383,7 +200,7 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[MCP][${serverName}][${toolName}][User: ${userId}] Error calling MCP tool:`,
|
||||
`[MCP][User: ${userId}][${serverName}] Error calling "${toolName}" MCP tool:`,
|
||||
error,
|
||||
);
|
||||
|
||||
@@ -396,12 +213,12 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
|
||||
|
||||
if (isOAuthError) {
|
||||
throw new Error(
|
||||
`[MCP][${serverName}][${toolName}] OAuth authentication required. Please check the server logs for the authentication URL.`,
|
||||
`OAuth authentication required for ${serverName}. Please check the server logs for the authentication URL.`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[MCP][${serverName}][${toolName}] tool call failed${error?.message ? `: ${error?.message}` : '.'}`,
|
||||
`"${toolKey}" tool call failed${error?.message ? `: ${error?.message}` : '.'}`,
|
||||
);
|
||||
} finally {
|
||||
// Clean up abort handler to prevent memory leaks
|
||||
@@ -437,21 +254,15 @@ async function getMCPSetupData(userId) {
|
||||
}
|
||||
|
||||
const mcpManager = getMCPManager(userId);
|
||||
/** @type {ReturnType<MCPManager['getAllConnections']>} */
|
||||
let appConnections = new Map();
|
||||
try {
|
||||
appConnections = (await mcpManager.getAllConnections()) || new Map();
|
||||
} catch (error) {
|
||||
logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error);
|
||||
}
|
||||
const appConnections = mcpManager.getAllConnections() || new Map();
|
||||
const userConnections = mcpManager.getUserConnections(userId) || new Map();
|
||||
const oauthServers = mcpManager.getOAuthServers() || new Set();
|
||||
|
||||
return {
|
||||
mcpConfig,
|
||||
oauthServers,
|
||||
appConnections,
|
||||
userConnections,
|
||||
oauthServers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -556,7 +367,6 @@ async function getServerConnectionStatus(
|
||||
|
||||
module.exports = {
|
||||
createMCPTool,
|
||||
createMCPTools,
|
||||
getMCPSetupData,
|
||||
checkOAuthFlowStatus,
|
||||
getServerConnectionStatus,
|
||||
|
||||
@@ -34,7 +34,6 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
|
||||
* @param {string} params.apiKey - The API key for authentication with the API.
|
||||
* @param {string} params.baseURL - The base path URL for the API.
|
||||
* @param {string} [params.name='OpenAI'] - The name of the API; defaults to 'OpenAI'.
|
||||
* @param {boolean} [params.direct=false] - Whether `directEndpoint` was configured
|
||||
* @param {boolean} [params.azure=false] - Whether to fetch models from Azure.
|
||||
* @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter.
|
||||
* @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response.
|
||||
@@ -45,16 +44,14 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
|
||||
const fetchModels = async ({
|
||||
user,
|
||||
apiKey,
|
||||
baseURL: _baseURL,
|
||||
baseURL,
|
||||
name = EModelEndpoint.openAI,
|
||||
direct,
|
||||
azure = false,
|
||||
userIdQuery = false,
|
||||
createTokenConfig = true,
|
||||
tokenKey,
|
||||
}) => {
|
||||
let models = [];
|
||||
const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL;
|
||||
|
||||
if (!baseURL && !azure) {
|
||||
return models;
|
||||
|
||||
@@ -33,7 +33,7 @@ async function withTimeout(promise, timeoutMs, timeoutMessage) {
|
||||
* @param {string} [params.body.model] - Optional. The ID of the model to be used for this run.
|
||||
* @param {string} [params.body.instructions] - Optional. Override the default system message of the assistant.
|
||||
* @param {string} [params.body.additional_instructions] - Optional. Appends additional instructions
|
||||
* at the end of the instructions for the run. This is useful for modifying
|
||||
* at theend of the instructions for the run. This is useful for modifying
|
||||
* the behavior on a per-run basis without overriding other instructions.
|
||||
* @param {Object[]} [params.body.tools] - Optional. Override the tools the assistant can use for this run.
|
||||
* @param {string[]} [params.body.file_ids] - Optional.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { getToolkitKey } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { zodToJsonSchema } = require('zod-to-json-schema');
|
||||
const { getToolkitKey, getUserMCPAuthMap } = require('@librechat/api');
|
||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||
const { tool: toolFn, Tool, DynamicStructuredTool } = require('@langchain/core/tools');
|
||||
const {
|
||||
@@ -33,17 +33,12 @@ const {
|
||||
toolkits,
|
||||
} = require('~/app/clients/tools');
|
||||
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
|
||||
const {
|
||||
getEndpointsConfig,
|
||||
hasCustomUserVars,
|
||||
getCachedTools,
|
||||
} = require('~/server/services/Config');
|
||||
const { getEndpointsConfig, getCachedTools } = require('~/server/services/Config');
|
||||
const { createOnSearchResults } = require('~/server/services/Tools/search');
|
||||
const { isActionDomainAllowed } = require('~/server/services/domains');
|
||||
const { recordUsage } = require('~/server/services/Threads');
|
||||
const { loadTools } = require('~/app/clients/tools/util');
|
||||
const { redactMessage } = require('~/config/parsers');
|
||||
const { findPluginAuthsByKeys } = require('~/models');
|
||||
|
||||
/**
|
||||
* Loads and formats tools from the specified tool directory.
|
||||
@@ -474,12 +469,11 @@ async function processRequiredActions(client, requiredActions) {
|
||||
* @param {Object} params - Run params containing user and request information.
|
||||
* @param {ServerRequest} params.req - The request object.
|
||||
* @param {ServerResponse} params.res - The request object.
|
||||
* @param {AbortSignal} params.signal
|
||||
* @param {Pick<Agent, 'id' | 'provider' | 'model' | 'tools'} params.agent - The agent to load tools for.
|
||||
* @param {string | undefined} [params.openAIApiKey] - The OpenAI API key.
|
||||
* @returns {Promise<{ tools?: StructuredTool[]; userMCPAuthMap?: Record<string, Record<string, string>> }>} The agent tools.
|
||||
* @returns {Promise<{ tools?: StructuredTool[] }>} The agent tools.
|
||||
*/
|
||||
async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIApiKey }) {
|
||||
async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey }) {
|
||||
if (!agent.tools || agent.tools.length === 0) {
|
||||
return {};
|
||||
} else if (agent.tools && agent.tools.length === 1 && agent.tools[0] === AgentCapabilities.ocr) {
|
||||
@@ -529,20 +523,8 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
|
||||
webSearchCallbacks = createOnSearchResults(res);
|
||||
}
|
||||
|
||||
/** @type {Record<string, Record<string, string>>} */
|
||||
let userMCPAuthMap;
|
||||
if (await hasCustomUserVars()) {
|
||||
userMCPAuthMap = await getUserMCPAuthMap({
|
||||
tools: agent.tools,
|
||||
userId: req.user.id,
|
||||
findPluginAuthsByKeys,
|
||||
});
|
||||
}
|
||||
|
||||
const { loadedTools, toolContextMap } = await loadTools({
|
||||
agent,
|
||||
signal,
|
||||
userMCPAuthMap,
|
||||
functions: true,
|
||||
user: req.user.id,
|
||||
tools: _agentTools,
|
||||
@@ -606,7 +588,6 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
|
||||
if (!checkCapability(AgentCapabilities.actions)) {
|
||||
return {
|
||||
tools: agentTools,
|
||||
userMCPAuthMap,
|
||||
toolContextMap,
|
||||
};
|
||||
}
|
||||
@@ -618,7 +599,6 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
|
||||
}
|
||||
return {
|
||||
tools: agentTools,
|
||||
userMCPAuthMap,
|
||||
toolContextMap,
|
||||
};
|
||||
}
|
||||
@@ -727,7 +707,6 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
|
||||
return {
|
||||
tools: agentTools,
|
||||
toolContextMap,
|
||||
userMCPAuthMap,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { updateMCPUserTools } = require('~/server/services/Config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {ServerRequest} params.req
|
||||
* @param {string} params.serverName - The name of the MCP server
|
||||
* @param {boolean} params.returnOnOAuth - Whether to initiate OAuth and return, or wait for OAuth flow to finish
|
||||
* @param {AbortSignal} [params.signal] - The abort signal to handle cancellation.
|
||||
* @param {boolean} [params.forceNew]
|
||||
* @param {number} [params.connectionTimeout]
|
||||
* @param {FlowStateManager<any>} [params.flowManager]
|
||||
* @param {(authURL: string) => Promise<boolean>} [params.oauthStart]
|
||||
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
|
||||
*/
|
||||
async function reinitMCPServer({
|
||||
req,
|
||||
signal,
|
||||
forceNew,
|
||||
serverName,
|
||||
userMCPAuthMap,
|
||||
connectionTimeout,
|
||||
returnOnOAuth = true,
|
||||
oauthStart: _oauthStart,
|
||||
flowManager: _flowManager,
|
||||
}) {
|
||||
/** @type {MCPConnection | null} */
|
||||
let userConnection = null;
|
||||
/** @type {LCAvailableTools | null} */
|
||||
let availableTools = null;
|
||||
/** @type {ReturnType<MCPConnection['fetchTools']> | null} */
|
||||
let tools = null;
|
||||
let oauthRequired = false;
|
||||
let oauthUrl = null;
|
||||
try {
|
||||
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
|
||||
const flowManager = _flowManager ?? getFlowStateManager(getLogStores(CacheKeys.FLOWS));
|
||||
const mcpManager = getMCPManager();
|
||||
|
||||
const oauthStart =
|
||||
_oauthStart ??
|
||||
(async (authURL) => {
|
||||
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
|
||||
oauthUrl = authURL;
|
||||
oauthRequired = true;
|
||||
});
|
||||
|
||||
try {
|
||||
userConnection = await mcpManager.getUserConnection({
|
||||
user: req.user,
|
||||
signal,
|
||||
forceNew,
|
||||
oauthStart,
|
||||
serverName,
|
||||
flowManager,
|
||||
returnOnOAuth,
|
||||
customUserVars,
|
||||
connectionTimeout,
|
||||
tokenMethods: {
|
||||
findToken,
|
||||
updateToken,
|
||||
createToken,
|
||||
deleteTokens,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[MCP Reinitialize] Successfully established connection for ${serverName}`);
|
||||
} catch (err) {
|
||||
logger.info(`[MCP Reinitialize] getUserConnection threw error: ${err.message}`);
|
||||
logger.info(
|
||||
`[MCP Reinitialize] OAuth state - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
|
||||
);
|
||||
|
||||
const isOAuthError =
|
||||
err.message?.includes('OAuth') ||
|
||||
err.message?.includes('authentication') ||
|
||||
err.message?.includes('401');
|
||||
|
||||
const isOAuthFlowInitiated = err.message === 'OAuth flow initiated - return early';
|
||||
|
||||
if (isOAuthError || oauthRequired || isOAuthFlowInitiated) {
|
||||
logger.info(
|
||||
`[MCP Reinitialize] OAuth required for ${serverName} (isOAuthError: ${isOAuthError}, oauthRequired: ${oauthRequired}, isOAuthFlowInitiated: ${isOAuthFlowInitiated})`,
|
||||
);
|
||||
oauthRequired = true;
|
||||
} else {
|
||||
logger.error(
|
||||
`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (userConnection && !oauthRequired) {
|
||||
tools = await userConnection.fetchTools();
|
||||
availableTools = await updateMCPUserTools({
|
||||
userId: req.user.id,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[MCP Reinitialize] Sending response for ${serverName} - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
|
||||
);
|
||||
|
||||
const getResponseMessage = () => {
|
||||
if (oauthRequired) {
|
||||
return `MCP server '${serverName}' ready for OAuth authentication`;
|
||||
}
|
||||
if (userConnection) {
|
||||
return `MCP server '${serverName}' reinitialized successfully`;
|
||||
}
|
||||
return `Failed to reinitialize MCP server '${serverName}'`;
|
||||
};
|
||||
|
||||
const result = {
|
||||
availableTools,
|
||||
success: Boolean((userConnection && !oauthRequired) || (oauthRequired && oauthUrl)),
|
||||
message: getResponseMessage(),
|
||||
oauthRequired,
|
||||
serverName,
|
||||
oauthUrl,
|
||||
tools,
|
||||
};
|
||||
logger.debug(`[MCP Reinitialize] Response for ${serverName}:`, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'[MCP Reinitialize] Error loading MCP Tools, servers may still be initializing:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
reinitMCPServer,
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { createMCPManager } = require('~/config');
|
||||
const { mergeAppTools } = require('./Config');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { getCachedTools, setCachedTools } = require('./Config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
* Initialize MCP servers
|
||||
@@ -12,16 +15,55 @@ async function initializeMCPs(app) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mcpManager = await createMCPManager(mcpServers);
|
||||
// Filter out servers with startup: false
|
||||
const filteredServers = {};
|
||||
for (const [name, config] of Object.entries(mcpServers)) {
|
||||
if (config.startup === false) {
|
||||
logger.info(`Skipping MCP server '${name}' due to startup: false`);
|
||||
continue;
|
||||
}
|
||||
filteredServers[name] = config;
|
||||
}
|
||||
|
||||
if (Object.keys(filteredServers).length === 0) {
|
||||
logger.info('[MCP] No MCP servers to initialize (all skipped or none configured)');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Initializing MCP servers...');
|
||||
const mcpManager = getMCPManager();
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
|
||||
|
||||
try {
|
||||
delete app.locals.mcpConfig;
|
||||
const mcpTools = mcpManager.getAppToolFunctions() || {};
|
||||
await mergeAppTools(mcpTools);
|
||||
await mcpManager.initializeMCPs({
|
||||
mcpServers: filteredServers,
|
||||
flowManager,
|
||||
tokenMethods: {
|
||||
findToken,
|
||||
updateToken,
|
||||
createToken,
|
||||
deleteTokens,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`MCP servers initialized successfully. Added ${Object.keys(mcpTools).length} MCP tools.`,
|
||||
);
|
||||
delete app.locals.mcpConfig;
|
||||
const availableTools = await getCachedTools();
|
||||
|
||||
if (!availableTools) {
|
||||
logger.warn('No available tools found in cache during MCP initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
const toolsCopy = { ...availableTools };
|
||||
await mcpManager.mapAvailableTools(toolsCopy, flowManager);
|
||||
await setCachedTools(toolsCopy, { isGlobal: true });
|
||||
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
logger.debug('Cleared tools array cache after MCP initialization');
|
||||
|
||||
logger.info('MCP servers initialized successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize MCP servers:', error);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
roleDefaults,
|
||||
PermissionTypes,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
@@ -9,6 +8,43 @@ const { logger } = require('@librechat/data-schemas');
|
||||
const { isMemoryEnabled } = require('@librechat/api');
|
||||
const { updateAccessPermissions, getRoleByName } = require('~/models/Role');
|
||||
|
||||
/**
|
||||
* Updates role permissions intelligently - only updates permission types that:
|
||||
* 1. Don't exist in the database (first time setup)
|
||||
* 2. Are explicitly configured in the config file
|
||||
* @param {object} params - The role name to update
|
||||
* @param {string} params.roleName - The role name to update
|
||||
* @param {object} params.allPermissions - All permissions to potentially update
|
||||
* @param {object} params.interfaceConfig - The interface config from librechat.yaml
|
||||
*/
|
||||
async function updateRolePermissions({ roleName, allPermissions, interfaceConfig }) {
|
||||
const existingRole = await getRoleByName(roleName);
|
||||
const existingPermissions = existingRole?.permissions || {};
|
||||
const permissionsToUpdate = {};
|
||||
|
||||
for (const [permType, perms] of Object.entries(allPermissions)) {
|
||||
const permTypeExists = existingPermissions[permType];
|
||||
|
||||
const isExplicitlyConfigured = interfaceConfig && hasExplicitConfig(interfaceConfig, permType);
|
||||
|
||||
// Only update if: doesn't exist OR explicitly configured
|
||||
if (!permTypeExists || isExplicitlyConfigured) {
|
||||
permissionsToUpdate[permType] = perms;
|
||||
if (!permTypeExists) {
|
||||
logger.debug(`Role '${roleName}': Setting up default permissions for '${permType}'`);
|
||||
} else if (isExplicitlyConfigured) {
|
||||
logger.debug(`Role '${roleName}': Applying explicit config for '${permType}'`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Role '${roleName}': Preserving existing permissions for '${permType}'`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(permissionsToUpdate).length > 0) {
|
||||
await updateAccessPermissions(roleName, permissionsToUpdate, existingRole);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a permission type has explicit configuration
|
||||
*/
|
||||
@@ -65,7 +101,6 @@ async function loadDefaultInterface(config, configDefaults) {
|
||||
|
||||
/** @type {TCustomConfig['interface']} */
|
||||
const loadedInterface = removeNullishValues({
|
||||
// UI elements - use schema defaults
|
||||
endpointsMenu:
|
||||
interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu),
|
||||
modelSelect:
|
||||
@@ -77,167 +112,55 @@ async function loadDefaultInterface(config, configDefaults) {
|
||||
privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy,
|
||||
termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService,
|
||||
mcpServers: interfaceConfig?.mcpServers ?? defaults.mcpServers,
|
||||
bookmarks: interfaceConfig?.bookmarks ?? defaults.bookmarks,
|
||||
memories: shouldDisableMemories ? false : (interfaceConfig?.memories ?? defaults.memories),
|
||||
prompts: interfaceConfig?.prompts ?? defaults.prompts,
|
||||
multiConvo: interfaceConfig?.multiConvo ?? defaults.multiConvo,
|
||||
agents: interfaceConfig?.agents ?? defaults.agents,
|
||||
temporaryChat: interfaceConfig?.temporaryChat ?? defaults.temporaryChat,
|
||||
runCode: interfaceConfig?.runCode ?? defaults.runCode,
|
||||
webSearch: interfaceConfig?.webSearch ?? defaults.webSearch,
|
||||
fileSearch: interfaceConfig?.fileSearch ?? defaults.fileSearch,
|
||||
fileCitations: interfaceConfig?.fileCitations ?? defaults.fileCitations,
|
||||
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
|
||||
|
||||
// Permissions - only include if explicitly configured
|
||||
bookmarks: interfaceConfig?.bookmarks,
|
||||
memories: shouldDisableMemories ? false : interfaceConfig?.memories,
|
||||
prompts: interfaceConfig?.prompts,
|
||||
multiConvo: interfaceConfig?.multiConvo,
|
||||
agents: interfaceConfig?.agents,
|
||||
temporaryChat: interfaceConfig?.temporaryChat,
|
||||
runCode: interfaceConfig?.runCode,
|
||||
webSearch: interfaceConfig?.webSearch,
|
||||
fileSearch: interfaceConfig?.fileSearch,
|
||||
fileCitations: interfaceConfig?.fileCitations,
|
||||
peoplePicker: interfaceConfig?.peoplePicker,
|
||||
marketplace: interfaceConfig?.marketplace,
|
||||
peoplePicker: {
|
||||
users: interfaceConfig?.peoplePicker?.users ?? defaults.peoplePicker?.users,
|
||||
groups: interfaceConfig?.peoplePicker?.groups ?? defaults.peoplePicker?.groups,
|
||||
roles: interfaceConfig?.peoplePicker?.roles ?? defaults.peoplePicker?.roles,
|
||||
},
|
||||
marketplace: {
|
||||
use: interfaceConfig?.marketplace?.use ?? defaults.marketplace?.use,
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to get permission value with proper precedence
|
||||
const getPermissionValue = (configValue, roleDefault, schemaDefault) => {
|
||||
if (configValue !== undefined) return configValue;
|
||||
if (roleDefault !== undefined) return roleDefault;
|
||||
return schemaDefault;
|
||||
};
|
||||
|
||||
// Permission precedence order:
|
||||
// 1. Explicit user configuration (from librechat.yaml)
|
||||
// 2. Role-specific defaults (from roleDefaults)
|
||||
// 3. Interface schema defaults (from interfaceSchema.default())
|
||||
for (const roleName of [SystemRoles.USER, SystemRoles.ADMIN]) {
|
||||
const defaultPerms = roleDefaults[roleName].permissions;
|
||||
const existingRole = await getRoleByName(roleName);
|
||||
const existingPermissions = existingRole?.permissions || {};
|
||||
const permissionsToUpdate = {};
|
||||
|
||||
// Helper to add permission if it should be updated
|
||||
const addPermissionIfNeeded = (permType, permissions) => {
|
||||
const permTypeExists = existingPermissions[permType];
|
||||
const isExplicitlyConfigured =
|
||||
interfaceConfig && hasExplicitConfig(interfaceConfig, permType);
|
||||
|
||||
// Only update if: doesn't exist OR explicitly configured
|
||||
if (!permTypeExists || isExplicitlyConfigured) {
|
||||
permissionsToUpdate[permType] = permissions;
|
||||
if (!permTypeExists) {
|
||||
logger.debug(`Role '${roleName}': Setting up default permissions for '${permType}'`);
|
||||
} else if (isExplicitlyConfigured) {
|
||||
logger.debug(`Role '${roleName}': Applying explicit config for '${permType}'`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Role '${roleName}': Preserving existing permissions for '${permType}'`);
|
||||
}
|
||||
};
|
||||
|
||||
// Build permissions for each type
|
||||
const allPermissions = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.prompts,
|
||||
defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.USE],
|
||||
defaults.prompts,
|
||||
),
|
||||
await updateRolePermissions({
|
||||
roleName,
|
||||
allPermissions: {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: loadedInterface.bookmarks },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: loadedInterface.memories,
|
||||
[Permissions.OPT_OUT]: isPersonalizationEnabled,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: loadedInterface.multiConvo },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: loadedInterface.agents },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker?.users,
|
||||
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker?.groups,
|
||||
[Permissions.VIEW_ROLES]: loadedInterface.peoplePicker?.roles,
|
||||
},
|
||||
[PermissionTypes.MARKETPLACE]: {
|
||||
[Permissions.USE]: loadedInterface.marketplace?.use,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.bookmarks,
|
||||
defaultPerms[PermissionTypes.BOOKMARKS]?.[Permissions.USE],
|
||||
defaults.bookmarks,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.memories,
|
||||
defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.USE],
|
||||
defaults.memories,
|
||||
),
|
||||
[Permissions.OPT_OUT]: isPersonalizationEnabled,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.multiConvo,
|
||||
defaultPerms[PermissionTypes.MULTI_CONVO]?.[Permissions.USE],
|
||||
defaults.multiConvo,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.agents,
|
||||
defaultPerms[PermissionTypes.AGENTS]?.[Permissions.USE],
|
||||
defaults.agents,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.temporaryChat,
|
||||
defaultPerms[PermissionTypes.TEMPORARY_CHAT]?.[Permissions.USE],
|
||||
defaults.temporaryChat,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.RUN_CODE]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.runCode,
|
||||
defaultPerms[PermissionTypes.RUN_CODE]?.[Permissions.USE],
|
||||
defaults.runCode,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.WEB_SEARCH]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.webSearch,
|
||||
defaultPerms[PermissionTypes.WEB_SEARCH]?.[Permissions.USE],
|
||||
defaults.webSearch,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: getPermissionValue(
|
||||
loadedInterface.peoplePicker?.users,
|
||||
defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_USERS],
|
||||
defaults.peoplePicker?.users,
|
||||
),
|
||||
[Permissions.VIEW_GROUPS]: getPermissionValue(
|
||||
loadedInterface.peoplePicker?.groups,
|
||||
defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_GROUPS],
|
||||
defaults.peoplePicker?.groups,
|
||||
),
|
||||
[Permissions.VIEW_ROLES]: getPermissionValue(
|
||||
loadedInterface.peoplePicker?.roles,
|
||||
defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_ROLES],
|
||||
defaults.peoplePicker?.roles,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.MARKETPLACE]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.marketplace?.use,
|
||||
defaultPerms[PermissionTypes.MARKETPLACE]?.[Permissions.USE],
|
||||
defaults.marketplace?.use,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.fileSearch,
|
||||
defaultPerms[PermissionTypes.FILE_SEARCH]?.[Permissions.USE],
|
||||
defaults.fileSearch,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.FILE_CITATIONS]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.fileCitations,
|
||||
defaultPerms[PermissionTypes.FILE_CITATIONS]?.[Permissions.USE],
|
||||
defaults.fileCitations,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
// Check and add each permission type if needed
|
||||
for (const [permType, permissions] of Object.entries(allPermissions)) {
|
||||
addPermissionIfNeeded(permType, permissions);
|
||||
}
|
||||
|
||||
// Update permissions if any need updating
|
||||
if (Object.keys(permissionsToUpdate).length > 0) {
|
||||
await updateAccessPermissions(roleName, permissionsToUpdate, existingRole);
|
||||
}
|
||||
interfaceConfig,
|
||||
});
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
const {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
roleDefaults,
|
||||
} = require('librechat-data-provider');
|
||||
const { SystemRoles, Permissions, PermissionTypes } = require('librechat-data-provider');
|
||||
const { updateAccessPermissions, getRoleByName } = require('~/models/Role');
|
||||
const { loadDefaultInterface } = require('./interface');
|
||||
|
||||
@@ -12,11 +7,6 @@ jest.mock('~/models/Role', () => ({
|
||||
getRoleByName: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
isMemoryEnabled: jest.fn((config) => config?.enable === true),
|
||||
}));
|
||||
|
||||
describe('loadDefaultInterface', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -51,45 +41,15 @@ describe('loadDefaultInterface', () => {
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
const expectedPermissionsForUser = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: true,
|
||||
[Permissions.VIEW_GROUPS]: true,
|
||||
[Permissions.VIEW_ROLES]: true,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
};
|
||||
|
||||
const expectedPermissionsForAdmin = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
@@ -108,14 +68,14 @@ describe('loadDefaultInterface', () => {
|
||||
// Check USER role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissionsForUser,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
|
||||
// Check ADMIN role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissionsForAdmin,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -147,45 +107,12 @@ describe('loadDefaultInterface', () => {
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
const expectedPermissionsForUser = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: false,
|
||||
},
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: false,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: false, [Permissions.OPT_OUT]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: false,
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: false,
|
||||
[Permissions.VIEW_GROUPS]: false,
|
||||
[Permissions.VIEW_ROLES]: false,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false },
|
||||
};
|
||||
|
||||
const expectedPermissionsForAdmin = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: false,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: false,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: false,
|
||||
},
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false },
|
||||
@@ -204,95 +131,44 @@ describe('loadDefaultInterface', () => {
|
||||
// Check USER role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissionsForUser,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
|
||||
// Check ADMIN role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissionsForAdmin,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with role-specific defaults when permission types are not specified in config', async () => {
|
||||
it('should call updateAccessPermissions with undefined when permission types are not specified in config', async () => {
|
||||
const config = {};
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
memories: true,
|
||||
multiConvo: true,
|
||||
agents: true,
|
||||
temporaryChat: true,
|
||||
runCode: true,
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
fileCitations: true,
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
marketplace: {
|
||||
use: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
const expectedPermissionsForUser = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.USE]: undefined,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: false,
|
||||
[Permissions.VIEW_GROUPS]: false,
|
||||
[Permissions.VIEW_ROLES]: false,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_ROLES]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
};
|
||||
|
||||
const expectedPermissionsForAdmin = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: true,
|
||||
[Permissions.VIEW_GROUPS]: true,
|
||||
[Permissions.VIEW_ROLES]: true,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
};
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
|
||||
@@ -300,14 +176,14 @@ describe('loadDefaultInterface', () => {
|
||||
// Check USER role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissionsForUser,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
|
||||
// Check ADMIN role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissionsForAdmin,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -327,78 +203,24 @@ describe('loadDefaultInterface', () => {
|
||||
fileCitations: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
memories: true,
|
||||
multiConvo: true,
|
||||
agents: true,
|
||||
temporaryChat: true,
|
||||
runCode: true,
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
fileCitations: true,
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
marketplace: {
|
||||
use: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
const expectedPermissionsForUser = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: false,
|
||||
[Permissions.VIEW_GROUPS]: false,
|
||||
[Permissions.VIEW_ROLES]: false,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
};
|
||||
|
||||
const expectedPermissionsForAdmin = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: true,
|
||||
[Permissions.VIEW_GROUPS]: true,
|
||||
[Permissions.VIEW_ROLES]: true,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_ROLES]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
@@ -409,14 +231,14 @@ describe('loadDefaultInterface', () => {
|
||||
// Check USER role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissionsForUser,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
|
||||
// Check ADMIN role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissionsForAdmin,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -448,49 +270,16 @@ describe('loadDefaultInterface', () => {
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
const expectedPermissionsForUser = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: false,
|
||||
[Permissions.VIEW_GROUPS]: false,
|
||||
[Permissions.VIEW_ROLES]: false,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
};
|
||||
|
||||
const expectedPermissionsForAdmin = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: true,
|
||||
[Permissions.VIEW_GROUPS]: true,
|
||||
@@ -505,14 +294,14 @@ describe('loadDefaultInterface', () => {
|
||||
// Check USER role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissionsForUser,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
|
||||
// Check ADMIN role call
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissionsForAdmin,
|
||||
expectedPermissions,
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -539,55 +328,24 @@ describe('loadDefaultInterface', () => {
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
fileCitations: true,
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
marketplace: {
|
||||
use: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
// Should be called with all permissions EXCEPT prompts and agents (which already exist)
|
||||
const expectedPermissionsForUser = {
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: false,
|
||||
[Permissions.VIEW_GROUPS]: false,
|
||||
[Permissions.VIEW_ROLES]: false,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
};
|
||||
|
||||
const expectedPermissionsForAdmin = {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: true,
|
||||
[Permissions.VIEW_GROUPS]: true,
|
||||
[Permissions.VIEW_ROLES]: true,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_ROLES]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
@@ -596,7 +354,7 @@ describe('loadDefaultInterface', () => {
|
||||
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissionsForUser,
|
||||
expectedPermissions,
|
||||
expect.objectContaining({
|
||||
permissions: {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||
@@ -606,7 +364,7 @@ describe('loadDefaultInterface', () => {
|
||||
);
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissionsForAdmin,
|
||||
expectedPermissions,
|
||||
expect.objectContaining({
|
||||
permissions: {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||
@@ -638,442 +396,47 @@ describe('loadDefaultInterface', () => {
|
||||
prompts: false,
|
||||
agents: true,
|
||||
bookmarks: true,
|
||||
memories: true,
|
||||
multiConvo: true,
|
||||
temporaryChat: true,
|
||||
runCode: true,
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
fileCitations: true,
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
marketplace: {
|
||||
use: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
// Should update prompts (explicitly configured) and all other permissions that don't exist
|
||||
const expectedPermissionsForUser = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: true,
|
||||
}, // Explicitly configured
|
||||
const expectedPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, // Explicitly configured
|
||||
// All other permissions that don't exist in the database
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.USE]: undefined,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: false,
|
||||
[Permissions.VIEW_GROUPS]: false,
|
||||
[Permissions.VIEW_ROLES]: false,
|
||||
[Permissions.VIEW_USERS]: undefined,
|
||||
[Permissions.VIEW_GROUPS]: undefined,
|
||||
[Permissions.VIEW_ROLES]: undefined,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
};
|
||||
|
||||
const expectedPermissionsForAdmin = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: true,
|
||||
}, // Explicitly configured
|
||||
// All other permissions that don't exist in the database
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: true,
|
||||
[Permissions.VIEW_GROUPS]: true,
|
||||
[Permissions.VIEW_ROLES]: true,
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
|
||||
};
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.USER,
|
||||
expectedPermissionsForUser,
|
||||
expectedPermissions,
|
||||
expect.objectContaining({
|
||||
permissions: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(
|
||||
SystemRoles.ADMIN,
|
||||
expectedPermissionsForAdmin,
|
||||
expectedPermissions,
|
||||
expect.objectContaining({
|
||||
permissions: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use role-specific defaults for PEOPLE_PICKER when peoplePicker config is undefined', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
// peoplePicker is not defined at all
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Get the calls to updateAccessPermissions
|
||||
const userCall = updateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.USER,
|
||||
);
|
||||
const adminCall = updateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.ADMIN,
|
||||
);
|
||||
|
||||
// For USER role, PEOPLE_PICKER should use USER defaults (false)
|
||||
expect(userCall[1][PermissionTypes.PEOPLE_PICKER]).toEqual({
|
||||
[Permissions.VIEW_USERS]: false,
|
||||
[Permissions.VIEW_GROUPS]: false,
|
||||
[Permissions.VIEW_ROLES]: false,
|
||||
});
|
||||
|
||||
// For ADMIN role, PEOPLE_PICKER should use ADMIN defaults (true)
|
||||
expect(adminCall[1][PermissionTypes.PEOPLE_PICKER]).toEqual({
|
||||
[Permissions.VIEW_USERS]: true,
|
||||
[Permissions.VIEW_GROUPS]: true,
|
||||
[Permissions.VIEW_ROLES]: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use role-specific defaults for complex permissions when not configured', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
// Only configure some permissions, leave others undefined
|
||||
bookmarks: true,
|
||||
multiConvo: false,
|
||||
},
|
||||
};
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
memories: true,
|
||||
multiConvo: true,
|
||||
agents: true,
|
||||
temporaryChat: true,
|
||||
runCode: true,
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
fileCitations: true,
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
marketplace: {
|
||||
use: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
const userCall = updateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.USER,
|
||||
);
|
||||
const adminCall = updateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.ADMIN,
|
||||
);
|
||||
|
||||
// Check PROMPTS permissions use role defaults
|
||||
expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({
|
||||
[Permissions.USE]: true,
|
||||
});
|
||||
|
||||
expect(adminCall[1][PermissionTypes.PROMPTS]).toEqual({
|
||||
[Permissions.USE]: true,
|
||||
});
|
||||
|
||||
// Check AGENTS permissions use role defaults
|
||||
expect(userCall[1][PermissionTypes.AGENTS]).toEqual({
|
||||
[Permissions.USE]: true,
|
||||
});
|
||||
|
||||
expect(adminCall[1][PermissionTypes.AGENTS]).toEqual({
|
||||
[Permissions.USE]: true,
|
||||
});
|
||||
|
||||
// Check MEMORIES permissions use role defaults
|
||||
expect(userCall[1][PermissionTypes.MEMORIES]).toEqual({
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
});
|
||||
|
||||
expect(adminCall[1][PermissionTypes.MEMORIES]).toEqual({
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle memories OPT_OUT based on personalization when memories are enabled', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
memories: true,
|
||||
},
|
||||
memory: {
|
||||
// Memory enabled with personalization
|
||||
enable: true,
|
||||
personalize: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
const userCall = updateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.USER,
|
||||
);
|
||||
const adminCall = updateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.ADMIN,
|
||||
);
|
||||
|
||||
// Both roles should have OPT_OUT set to true when personalization is enabled
|
||||
expect(userCall[1][PermissionTypes.MEMORIES][Permissions.OPT_OUT]).toBe(true);
|
||||
expect(adminCall[1][PermissionTypes.MEMORIES][Permissions.OPT_OUT]).toBe(true);
|
||||
});
|
||||
|
||||
it('should populate missing PEOPLE_PICKER and MARKETPLACE permissions with role-specific defaults', async () => {
|
||||
// Mock that PEOPLE_PICKER and MARKETPLACE permissions don't exist yet
|
||||
getRoleByName.mockResolvedValue({
|
||||
permissions: {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
// PEOPLE_PICKER and MARKETPLACE are missing
|
||||
},
|
||||
});
|
||||
|
||||
const config = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
marketplace: {
|
||||
use: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
const userCall = updateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.USER,
|
||||
);
|
||||
const adminCall = updateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.ADMIN,
|
||||
);
|
||||
|
||||
// Check that PEOPLE_PICKER uses role-specific defaults from roleDefaults
|
||||
expect(userCall[1][PermissionTypes.PEOPLE_PICKER]).toEqual({
|
||||
[Permissions.VIEW_USERS]:
|
||||
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PEOPLE_PICKER][
|
||||
Permissions.VIEW_USERS
|
||||
],
|
||||
[Permissions.VIEW_GROUPS]:
|
||||
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PEOPLE_PICKER][
|
||||
Permissions.VIEW_GROUPS
|
||||
],
|
||||
[Permissions.VIEW_ROLES]:
|
||||
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PEOPLE_PICKER][
|
||||
Permissions.VIEW_ROLES
|
||||
],
|
||||
});
|
||||
|
||||
expect(adminCall[1][PermissionTypes.PEOPLE_PICKER]).toEqual({
|
||||
[Permissions.VIEW_USERS]:
|
||||
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.PEOPLE_PICKER][
|
||||
Permissions.VIEW_USERS
|
||||
],
|
||||
[Permissions.VIEW_GROUPS]:
|
||||
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.PEOPLE_PICKER][
|
||||
Permissions.VIEW_GROUPS
|
||||
],
|
||||
[Permissions.VIEW_ROLES]:
|
||||
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.PEOPLE_PICKER][
|
||||
Permissions.VIEW_ROLES
|
||||
],
|
||||
});
|
||||
|
||||
// Check that MARKETPLACE uses role-specific defaults from roleDefaults
|
||||
expect(userCall[1][PermissionTypes.MARKETPLACE]).toEqual({
|
||||
[Permissions.USE]:
|
||||
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.MARKETPLACE][Permissions.USE],
|
||||
});
|
||||
|
||||
expect(adminCall[1][PermissionTypes.MARKETPLACE]).toEqual({
|
||||
[Permissions.USE]:
|
||||
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.MARKETPLACE][Permissions.USE],
|
||||
});
|
||||
});
|
||||
|
||||
it('should leave all existing permissions unchanged when nothing is configured', async () => {
|
||||
// Mock existing permissions with values that differ from defaults
|
||||
const existingUserPermissions = {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: true,
|
||||
[Permissions.VIEW_GROUPS]: false,
|
||||
[Permissions.VIEW_ROLES]: true,
|
||||
},
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: true },
|
||||
};
|
||||
|
||||
getRoleByName.mockResolvedValue({
|
||||
permissions: existingUserPermissions,
|
||||
});
|
||||
|
||||
// No config provided
|
||||
const config = undefined;
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
memories: true,
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
marketplace: {
|
||||
use: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
// Should only update permissions that don't exist
|
||||
const userCall = updateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.USER,
|
||||
);
|
||||
|
||||
// Should only have permissions for things that don't exist in the role
|
||||
expect(userCall[1]).not.toHaveProperty(PermissionTypes.PROMPTS);
|
||||
expect(userCall[1]).not.toHaveProperty(PermissionTypes.BOOKMARKS);
|
||||
expect(userCall[1]).not.toHaveProperty(PermissionTypes.MEMORIES);
|
||||
expect(userCall[1]).not.toHaveProperty(PermissionTypes.PEOPLE_PICKER);
|
||||
expect(userCall[1]).not.toHaveProperty(PermissionTypes.MARKETPLACE);
|
||||
|
||||
// Should have other permissions that weren't in existingUserPermissions
|
||||
expect(userCall[1]).toHaveProperty(PermissionTypes.MULTI_CONVO);
|
||||
expect(userCall[1]).toHaveProperty(PermissionTypes.AGENTS);
|
||||
});
|
||||
|
||||
it('should only call getRoleByName once per role for efficiency', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
// Should call getRoleByName exactly twice (once for USER, once for ADMIN)
|
||||
expect(getRoleByName).toHaveBeenCalledTimes(2);
|
||||
expect(getRoleByName).toHaveBeenCalledWith(SystemRoles.USER);
|
||||
expect(getRoleByName).toHaveBeenCalledWith(SystemRoles.ADMIN);
|
||||
});
|
||||
|
||||
it('should only update explicitly configured permissions and leave others unchanged', async () => {
|
||||
// Mock existing permissions
|
||||
getRoleByName.mockResolvedValue({
|
||||
permissions: {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: false,
|
||||
[Permissions.VIEW_GROUPS]: false,
|
||||
[Permissions.VIEW_ROLES]: false,
|
||||
},
|
||||
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
|
||||
},
|
||||
});
|
||||
|
||||
// Only configure some permissions
|
||||
const config = {
|
||||
interface: {
|
||||
prompts: true, // Explicitly set to true
|
||||
bookmarks: true, // Explicitly set to true
|
||||
// memories not configured - should remain unchanged
|
||||
// peoplePicker not configured - should remain unchanged
|
||||
marketplace: {
|
||||
use: true, // Explicitly set to true
|
||||
},
|
||||
},
|
||||
};
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
prompts: false,
|
||||
bookmarks: false,
|
||||
memories: true,
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
marketplace: {
|
||||
use: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
const userCall = updateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.USER,
|
||||
);
|
||||
|
||||
// Explicitly configured permissions should be updated
|
||||
expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({
|
||||
[Permissions.USE]: true,
|
||||
});
|
||||
expect(userCall[1][PermissionTypes.BOOKMARKS]).toEqual({ [Permissions.USE]: true });
|
||||
expect(userCall[1][PermissionTypes.MARKETPLACE]).toEqual({ [Permissions.USE]: true });
|
||||
|
||||
// Unconfigured permissions should not be present (left unchanged)
|
||||
expect(userCall[1]).not.toHaveProperty(PermissionTypes.MEMORIES);
|
||||
expect(userCall[1]).not.toHaveProperty(PermissionTypes.PEOPLE_PICKER);
|
||||
|
||||
// New permissions that didn't exist should still be added
|
||||
expect(userCall[1]).toHaveProperty(PermissionTypes.AGENTS);
|
||||
expect(userCall[1]).toHaveProperty(PermissionTypes.MULTI_CONVO);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
logAgentMigrationWarning,
|
||||
logPromptMigrationWarning,
|
||||
checkAgentPermissionsMigration,
|
||||
checkPromptPermissionsMigration,
|
||||
} = require('@librechat/api');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { Agent, PromptGroup } = require('~/db/models');
|
||||
const { findRoleByIdentifier } = require('~/models');
|
||||
|
||||
/**
|
||||
* Check if permissions migrations are needed for shared resources
|
||||
* This runs at the end to ensure all systems are initialized
|
||||
*/
|
||||
async function checkMigrations() {
|
||||
try {
|
||||
const agentMigrationResult = await checkAgentPermissionsMigration({
|
||||
mongoose,
|
||||
methods: {
|
||||
findRoleByIdentifier,
|
||||
getProjectByName,
|
||||
},
|
||||
AgentModel: Agent,
|
||||
});
|
||||
logAgentMigrationWarning(agentMigrationResult);
|
||||
} catch (error) {
|
||||
logger.error('Failed to check agent permissions migration:', error);
|
||||
}
|
||||
try {
|
||||
const promptMigrationResult = await checkPromptPermissionsMigration({
|
||||
mongoose,
|
||||
methods: {
|
||||
findRoleByIdentifier,
|
||||
getProjectByName,
|
||||
},
|
||||
PromptGroupModel: PromptGroup,
|
||||
});
|
||||
logPromptMigrationWarning(promptMigrationResult);
|
||||
} catch (error) {
|
||||
logger.error('Failed to check prompt permissions migration:', error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkMigrations,
|
||||
};
|
||||
@@ -1,48 +1,19 @@
|
||||
const passport = require('passport');
|
||||
const session = require('express-session');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const {
|
||||
openIdJwtLogin,
|
||||
facebookLogin,
|
||||
discordLogin,
|
||||
setupOpenId,
|
||||
googleLogin,
|
||||
githubLogin,
|
||||
discordLogin,
|
||||
facebookLogin,
|
||||
appleLogin,
|
||||
setupSaml,
|
||||
openIdJwtLogin,
|
||||
} = require('~/strategies');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
* Configures OpenID Connect for the application.
|
||||
* @param {Express.Application} app - The Express application instance.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function configureOpenId(app) {
|
||||
logger.info('Configuring OpenID Connect...');
|
||||
const sessionOptions = {
|
||||
secret: process.env.OPENID_SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: getLogStores(CacheKeys.OPENID_SESSION),
|
||||
};
|
||||
app.use(session(sessionOptions));
|
||||
app.use(passport.session());
|
||||
|
||||
const config = await setupOpenId();
|
||||
if (!config) {
|
||||
logger.error('OpenID Connect configuration failed - strategy not registered.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
logger.info('OpenID token reuse is enabled.');
|
||||
passport.use('openidJwt', openIdJwtLogin(config));
|
||||
}
|
||||
logger.info('OpenID Connect configured successfully.');
|
||||
}
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -73,7 +44,21 @@ const configureSocialLogins = async (app) => {
|
||||
process.env.OPENID_SCOPE &&
|
||||
process.env.OPENID_SESSION_SECRET
|
||||
) {
|
||||
await configureOpenId(app);
|
||||
logger.info('Configuring OpenID Connect...');
|
||||
const sessionOptions = {
|
||||
secret: process.env.OPENID_SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: getLogStores(CacheKeys.OPENID_SESSION),
|
||||
};
|
||||
app.use(session(sessionOptions));
|
||||
app.use(passport.session());
|
||||
const config = await setupOpenId();
|
||||
if (isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
logger.info('OpenID token reuse is enabled.');
|
||||
passport.use('openidJwt', openIdJwtLogin(config));
|
||||
}
|
||||
logger.info('OpenID Connect configured.');
|
||||
}
|
||||
if (
|
||||
process.env.SAML_ENTRY_POINT &&
|
||||
|
||||
@@ -12,7 +12,7 @@ const jwtLogin = () =>
|
||||
},
|
||||
async (payload, done) => {
|
||||
try {
|
||||
const user = await getUserById(payload?.id, '-password -__v -totpSecret -backupCodes');
|
||||
const user = await getUserById(payload?.id, '-password -__v -totpSecret');
|
||||
if (user) {
|
||||
user.id = user._id.toString();
|
||||
if (!user.role) {
|
||||
|
||||
@@ -22,7 +22,7 @@ async function passportLogin(req, email, password, done) {
|
||||
return done(null, false, { message: validationError });
|
||||
}
|
||||
|
||||
const user = await findUser({ email: email.trim() }, '+password');
|
||||
const user = await findUser({ email: email.trim() });
|
||||
if (!user) {
|
||||
logError('Passport Local Strategy - User Not Found', { email });
|
||||
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
|
||||
|
||||
@@ -99,7 +99,6 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
|
||||
const hostAndProtocol = process.env.DOMAIN_SERVER;
|
||||
return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`);
|
||||
}
|
||||
|
||||
authorizationRequestParams(req, options) {
|
||||
const params = super.authorizationRequestParams(req, options);
|
||||
if (options?.state && !params.has('state')) {
|
||||
@@ -113,15 +112,6 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
|
||||
);
|
||||
}
|
||||
|
||||
/** Generate nonce for federated providers that require it */
|
||||
const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
|
||||
if (shouldGenerateNonce && !params.has('nonce') && this._sessionKey) {
|
||||
const crypto = require('crypto');
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
params.set('nonce', nonce);
|
||||
logger.debug('[openidStrategy] Generated nonce for federated provider:', nonce);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
}
|
||||
@@ -286,20 +276,12 @@ function convertToUsername(input, defaultValue = '') {
|
||||
*/
|
||||
async function setupOpenId() {
|
||||
try {
|
||||
const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
|
||||
|
||||
/** @type {ClientMetadata} */
|
||||
const clientMetadata = {
|
||||
client_id: process.env.OPENID_CLIENT_ID,
|
||||
client_secret: process.env.OPENID_CLIENT_SECRET,
|
||||
};
|
||||
|
||||
if (shouldGenerateNonce) {
|
||||
clientMetadata.response_types = ['code'];
|
||||
clientMetadata.grant_types = ['authorization_code'];
|
||||
clientMetadata.token_endpoint_auth_method = 'client_secret_post';
|
||||
}
|
||||
|
||||
/** @type {Configuration} */
|
||||
openidConfig = await client.discovery(
|
||||
new URL(process.env.OPENID_ISSUER),
|
||||
@@ -315,19 +297,11 @@ async function setupOpenId() {
|
||||
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
|
||||
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
|
||||
const usePKCE = isEnabled(process.env.OPENID_USE_PKCE);
|
||||
logger.info(`[openidStrategy] OpenID authentication configuration`, {
|
||||
generateNonce: shouldGenerateNonce,
|
||||
reason: shouldGenerateNonce
|
||||
? 'OPENID_GENERATE_NONCE=true - Will generate nonce and use explicit metadata for federated providers'
|
||||
: 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata',
|
||||
});
|
||||
|
||||
const openidLogin = new CustomOpenIDStrategy(
|
||||
{
|
||||
config: openidConfig,
|
||||
scope: process.env.OPENID_SCOPE,
|
||||
callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL,
|
||||
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
|
||||
usePKCE,
|
||||
},
|
||||
async (tokenset, done) => {
|
||||
|
||||
@@ -1115,18 +1115,6 @@
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports MCPConnection
|
||||
* @typedef {import('@librechat/api').MCPConnection} MCPConnection
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports LCFunctionTool
|
||||
* @typedef {import('@librechat/api').LCFunctionTool} LCFunctionTool
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports FlowStateManager
|
||||
* @typedef {import('@librechat/api').FlowStateManager} FlowStateManager
|
||||
@@ -1401,7 +1389,7 @@
|
||||
* @property {string} [model] - The model that the assistant used for this run.
|
||||
* @property {string} [instructions] - The instructions that the assistant used for this run.
|
||||
* @property {string} [additional_instructions] - Optional. Appends additional instructions
|
||||
* at the end of the instructions for the run. This is useful for modifying
|
||||
* at theend of the instructions for the run. This is useful for modifying
|
||||
* @property {Tool[]} [tools] - The list of tools used for this run.
|
||||
* @property {string[]} [file_ids] - The list of File IDs used for this run.
|
||||
* @property {Object} [metadata] - Metadata associated with this run.
|
||||
@@ -1837,7 +1825,6 @@
|
||||
* @param {object} opts - Options for the completion
|
||||
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
|
||||
* @param {AbortController} opts.abortController - AbortController instance
|
||||
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
|
||||
* @returns {Promise<string>}
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
@@ -108,7 +108,7 @@ const anthropicModels = {
|
||||
'claude-3.7-sonnet': 200000,
|
||||
'claude-3-5-sonnet-latest': 200000,
|
||||
'claude-3.5-sonnet-latest': 200000,
|
||||
'claude-sonnet-4': 1000000,
|
||||
'claude-sonnet-4': 200000,
|
||||
'claude-opus-4': 200000,
|
||||
'claude-4': 200000,
|
||||
};
|
||||
@@ -487,7 +487,5 @@ module.exports = {
|
||||
matchModelName,
|
||||
processModelData,
|
||||
getModelMaxTokens,
|
||||
getModelTokenValue,
|
||||
findMatchingPattern,
|
||||
getModelMaxOutputTokens,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const {
|
||||
maxOutputTokensMap,
|
||||
findMatchingPattern,
|
||||
getModelMaxTokens,
|
||||
processModelData,
|
||||
matchModelName,
|
||||
@@ -750,12 +749,8 @@ describe('Grok Model Tests - Tokens', () => {
|
||||
|
||||
describe('Claude Model Tests', () => {
|
||||
it('should return correct context length for Claude 4 models', () => {
|
||||
expect(getModelMaxTokens('claude-sonnet-4')).toBe(
|
||||
maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4'],
|
||||
);
|
||||
expect(getModelMaxTokens('claude-opus-4')).toBe(
|
||||
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4'],
|
||||
);
|
||||
expect(getModelMaxTokens('claude-sonnet-4')).toBe(200000);
|
||||
expect(getModelMaxTokens('claude-opus-4')).toBe(200000);
|
||||
});
|
||||
|
||||
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
|
||||
@@ -777,8 +772,7 @@ describe('Claude Model Tests', () => {
|
||||
];
|
||||
|
||||
modelVariations.forEach((model) => {
|
||||
const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]);
|
||||
expect(getModelMaxTokens(model)).toBe(maxTokensMap[EModelEndpoint.anthropic][modelKey]);
|
||||
expect(getModelMaxTokens(model)).toBe(200000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="LibreChat - An open source chat application with support for multiple AI models" />
|
||||
<title>LibreChat</title>
|
||||
<link rel="shortcut icon" href="#" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/apple-touch-icon-180x180.png" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.8.0-rc3",
|
||||
"version": "v0.8.0-rc2",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,18 +2,27 @@ import React, { createContext, useContext, useEffect, useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { useSearchApiKeyForm, useGetAgentsConfig, useCodeApiKeyForm, useToolToggle } from '~/hooks';
|
||||
import {
|
||||
useSearchApiKeyForm,
|
||||
useGetAgentsConfig,
|
||||
useCodeApiKeyForm,
|
||||
useToolToggle,
|
||||
useMCPSelect,
|
||||
} from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
interface BadgeRowContextType {
|
||||
conversationId?: string | null;
|
||||
agentsConfig?: TAgentsEndpoint | null;
|
||||
mcpSelect: ReturnType<typeof useMCPSelect>;
|
||||
webSearch: ReturnType<typeof useToolToggle>;
|
||||
artifacts: ReturnType<typeof useToolToggle>;
|
||||
fileSearch: ReturnType<typeof useToolToggle>;
|
||||
codeInterpreter: ReturnType<typeof useToolToggle>;
|
||||
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
|
||||
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
|
||||
startupConfig: ReturnType<typeof useGetStartupConfig>['data'];
|
||||
}
|
||||
|
||||
const BadgeRowContext = createContext<BadgeRowContextType | undefined>(undefined);
|
||||
@@ -110,6 +119,12 @@ export default function BadgeRowProvider({
|
||||
}
|
||||
}, [key, isSubmitting, setEphemeralAgent]);
|
||||
|
||||
/** Startup config */
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
/** MCPSelect hook */
|
||||
const mcpSelect = useMCPSelect({ conversationId });
|
||||
|
||||
/** CodeInterpreter hooks */
|
||||
const codeApiKeyForm = useCodeApiKeyForm({});
|
||||
const { setIsDialogOpen: setCodeDialogOpen } = codeApiKeyForm;
|
||||
@@ -157,10 +172,12 @@ export default function BadgeRowProvider({
|
||||
});
|
||||
|
||||
const value: BadgeRowContextType = {
|
||||
mcpSelect,
|
||||
webSearch,
|
||||
artifacts,
|
||||
fileSearch,
|
||||
agentsConfig,
|
||||
startupConfig,
|
||||
conversationId,
|
||||
codeApiKeyForm,
|
||||
codeInterpreter,
|
||||
|
||||
@@ -225,8 +225,6 @@ export type AgentPanelContextType = {
|
||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
agent_id?: string;
|
||||
agentsConfig?: t.TAgentsEndpoint | null;
|
||||
endpointsConfig?: t.TEndpointsConfig | null;
|
||||
};
|
||||
|
||||
export type AgentModelPanelProps = {
|
||||
@@ -337,7 +335,6 @@ export type TAskProps = {
|
||||
|
||||
export type TOptions = {
|
||||
editedMessageId?: string | null;
|
||||
editedContent?: t.TEditedContent;
|
||||
editedText?: string | null;
|
||||
isRegenerate?: boolean;
|
||||
isContinued?: boolean;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { Label } from '@librechat/client';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks';
|
||||
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: t.Agent; // The agent data to display
|
||||
@@ -15,21 +15,6 @@ interface AgentCardProps {
|
||||
*/
|
||||
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
|
||||
const localize = useLocalize();
|
||||
const { categories } = useAgentCategories();
|
||||
|
||||
const categoryLabel = useMemo(() => {
|
||||
if (!agent.category) return '';
|
||||
|
||||
const category = categories.find((cat) => cat.value === agent.category);
|
||||
if (category) {
|
||||
if (category.label && category.label.startsWith('com_')) {
|
||||
return localize(category.label as TranslationKeys);
|
||||
}
|
||||
return category.label;
|
||||
}
|
||||
|
||||
return agent.category.charAt(0).toUpperCase() + agent.category.slice(1);
|
||||
}, [agent.category, categories, localize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -43,7 +28,7 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
||||
onClick={onClick}
|
||||
aria-label={localize('com_agents_agent_card_label', {
|
||||
name: agent.name,
|
||||
description: agent.description ?? '',
|
||||
description: agent.description || localize('com_agents_no_description'),
|
||||
})}
|
||||
aria-describedby={`agent-${agent.id}-description`}
|
||||
tabIndex={0}
|
||||
@@ -62,45 +47,50 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
||||
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
|
||||
|
||||
{/* Category tag */}
|
||||
{agent.category && (
|
||||
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
|
||||
<Label className="line-clamp-1 font-normal">{categoryLabel}</Label>
|
||||
</div>
|
||||
)}
|
||||
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
|
||||
{agent.category && (
|
||||
<Label className="line-clamp-1 font-normal">
|
||||
{agent.category.charAt(0).toUpperCase() + agent.category.slice(1)}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: Name, description, and other content */}
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col justify-between space-y-1">
|
||||
<div className="space-y-1">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Agent name */}
|
||||
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
|
||||
{agent.name}
|
||||
</Label>
|
||||
|
||||
{/* Agent description */}
|
||||
<p
|
||||
id={`agent-${agent.id}-description`}
|
||||
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
||||
{...(agent.description ? { 'aria-label': `Description: ${agent.description}` } : {})}
|
||||
>
|
||||
{agent.description ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Owner info - moved to bottom right */}
|
||||
{(() => {
|
||||
const displayName = getContactDisplayName(agent);
|
||||
if (displayName) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
{/* Owner info */}
|
||||
{(() => {
|
||||
const displayName = getContactDisplayName(agent);
|
||||
if (displayName) {
|
||||
return (
|
||||
<div className="flex items-center text-sm text-text-secondary">
|
||||
<Label className="mr-1">🔹</Label>
|
||||
<Label>{displayName}</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Agent description */}
|
||||
<p
|
||||
id={`agent-${agent.id}-description`}
|
||||
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
||||
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
|
||||
>
|
||||
{agent.description || (
|
||||
<Label className="font-normal italic text-text-primary">
|
||||
{localize('com_agents_no_description')}
|
||||
</Label>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
62
client/src/components/Agents/AgentCategoryDisplay.tsx
Normal file
62
client/src/components/Agents/AgentCategoryDisplay.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { useAgentCategories } from '~/hooks/Agents';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface AgentCategoryDisplayProps {
|
||||
category?: string;
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
iconClassName?: string;
|
||||
showEmptyFallback?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display an agent category with proper translation
|
||||
*
|
||||
* @param category - The category value (e.g., "general", "hr", etc.)
|
||||
* @param className - Optional className for the container
|
||||
* @param showIcon - Whether to show the category icon
|
||||
* @param iconClassName - Optional className for the icon
|
||||
* @param showEmptyFallback - Whether to show a fallback for empty categories
|
||||
*/
|
||||
const AgentCategoryDisplay: React.FC<AgentCategoryDisplayProps> = ({
|
||||
category,
|
||||
className = '',
|
||||
showIcon = true,
|
||||
iconClassName = 'h-4 w-4 mr-2',
|
||||
showEmptyFallback = false,
|
||||
}) => {
|
||||
const { categories, emptyCategory } = useAgentCategories();
|
||||
|
||||
// Find the category in our processed categories list
|
||||
const categoryItem = categories.find((c) => c.value === category);
|
||||
|
||||
// Handle empty string case differently than undefined/null
|
||||
if (category === '') {
|
||||
if (!showEmptyFallback) {
|
||||
return null;
|
||||
}
|
||||
// Show the empty category placeholder
|
||||
return (
|
||||
<div className={cn('flex items-center text-gray-400', className)}>
|
||||
<span>{emptyCategory.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No category or unknown category
|
||||
if (!category || !categoryItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center', className)}>
|
||||
{showIcon && categoryItem.icon && (
|
||||
<span className={cn('flex-shrink-0', iconClassName)}>{categoryItem.icon}</span>
|
||||
)}
|
||||
<span>{categoryItem.label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentCategoryDisplay;
|
||||
@@ -126,7 +126,10 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<OGDialogContent ref={dialogRef} className="max-h-[90vh] w-11/12 max-w-lg overflow-y-auto">
|
||||
<OGDialogContent
|
||||
ref={dialogRef}
|
||||
className="max-h-[90vh] overflow-y-auto py-8 sm:max-w-[450px]"
|
||||
>
|
||||
{/* Copy link button - positioned next to close button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -158,7 +161,11 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
|
||||
|
||||
{/* Agent description - below contact */}
|
||||
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">
|
||||
{agent?.description}
|
||||
{agent?.description || (
|
||||
<span className="italic text-text-tertiary">
|
||||
{localize('com_agents_no_description')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useMediaQuery } from '@librechat/client';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { SmartLoader } from './SmartLoader';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
@@ -34,19 +33,15 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||
onChange,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
/** Helper function to get category display name from database data */
|
||||
// Helper function to get category display name from database data
|
||||
const getCategoryDisplayName = (category: t.TCategory) => {
|
||||
// Special cases for system categories
|
||||
if (category.value === 'promoted') {
|
||||
return localize('com_agents_top_picks');
|
||||
}
|
||||
if (category.value === 'all') {
|
||||
return localize('com_agents_all_category');
|
||||
}
|
||||
if (category.label && category.label.startsWith('com_')) {
|
||||
return localize(category.label as TranslationKeys);
|
||||
return 'All';
|
||||
}
|
||||
// Use database label or fallback to capitalized value
|
||||
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
|
||||
@@ -125,24 +120,10 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||
const tabsContent = (
|
||||
<div className="w-full pb-2">
|
||||
<div
|
||||
className={cn(
|
||||
'px-4',
|
||||
isSmallScreen
|
||||
? 'scrollbar-hide flex gap-2 overflow-x-auto scroll-smooth'
|
||||
: 'flex flex-wrap justify-center gap-1.5',
|
||||
)}
|
||||
className="flex flex-wrap justify-center gap-1.5 px-4"
|
||||
role="tablist"
|
||||
aria-label={localize('com_agents_category_tabs_label')}
|
||||
aria-orientation="horizontal"
|
||||
style={
|
||||
isSmallScreen
|
||||
? {
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
<button
|
||||
@@ -151,21 +132,16 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||
onClick={() => onChange(category.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, category.value)}
|
||||
className={cn(
|
||||
'relative cursor-pointer select-none whitespace-nowrap px-3 py-2 transition-all duration-200',
|
||||
isSmallScreen ? 'min-w-fit flex-shrink-0' : '',
|
||||
'relative cursor-pointer select-none whitespace-nowrap px-3 py-2 transition-colors',
|
||||
activeTab === category.value
|
||||
? 'rounded-t-lg bg-surface-hover text-text-primary'
|
||||
: 'rounded-lg bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary active:scale-95',
|
||||
: 'rounded-lg bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary',
|
||||
)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === category.value}
|
||||
aria-controls={`tabpanel-${category.value}`}
|
||||
tabIndex={activeTab === category.value ? 0 : -1}
|
||||
aria-label={localize('com_agents_category_tab_label', {
|
||||
category: getCategoryDisplayName(category),
|
||||
position: index + 1,
|
||||
total: categories.length,
|
||||
})}
|
||||
aria-label={`${getCategoryDisplayName(category)} tab (${index + 1} of ${categories.length})`}
|
||||
>
|
||||
{getCategoryDisplayName(category)}
|
||||
{/* Underline for active tab */}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/cl
|
||||
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import type t from 'librechat-data-provider';
|
||||
import type { ContextType } from '~/common';
|
||||
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
|
||||
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
|
||||
import { useDocumentTitle, useHasAccess, useLocalize } from '~/hooks';
|
||||
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
|
||||
import { SidePanelProvider, useChatContext } from '~/Providers';
|
||||
import { MarketplaceProvider } from './MarketplaceContext';
|
||||
@@ -44,18 +44,21 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||||
const [hideSidePanel, setHideSidePanel] = useRecoilState(store.hideSidePanel);
|
||||
|
||||
// Get URL parameters
|
||||
// Get URL parameters (default to 'all' to ensure users see agents)
|
||||
const activeTab = category || 'all';
|
||||
const searchQuery = searchParams.get('q') || '';
|
||||
const selectedAgentId = searchParams.get('agent_id') || '';
|
||||
|
||||
// Animation state
|
||||
type Direction = 'left' | 'right';
|
||||
// Initialize with a default value to prevent rendering issues
|
||||
const [displayCategory, setDisplayCategory] = useState<string>(category || 'all');
|
||||
const [displayCategory, setDisplayCategory] = useState<string>(activeTab);
|
||||
const [nextCategory, setNextCategory] = useState<string | null>(null);
|
||||
const [isTransitioning, setIsTransitioning] = useState<boolean>(false);
|
||||
const [animationDirection, setAnimationDirection] = useState<Direction>('right');
|
||||
|
||||
// Keep a ref of initial mount to avoid animating first sync
|
||||
const didInitRef = useRef(false);
|
||||
|
||||
// Ref for the scrollable container to enable infinite scroll
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -75,6 +78,15 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
localStorage.setItem('fullPanelCollapse', 'false');
|
||||
}, [setHideSidePanel, hideSidePanel]);
|
||||
|
||||
// Redirect base /agents route to /agents/all for consistency
|
||||
useEffect(() => {
|
||||
if (!category && window.location.pathname === '/agents') {
|
||||
const currentSearchParams = searchParams.toString();
|
||||
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
||||
navigate(`/agents/all${searchParamsStr}`, { replace: true });
|
||||
}
|
||||
}, [category, navigate, searchParams]);
|
||||
|
||||
// Ensure endpoints config is loaded first (required for agent queries)
|
||||
useGetEndpointsQuery();
|
||||
|
||||
@@ -86,22 +98,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
// Handle initial category when on /agents without a category
|
||||
useEffect(() => {
|
||||
if (
|
||||
!category &&
|
||||
window.location.pathname === '/agents' &&
|
||||
categoriesQuery.data &&
|
||||
displayCategory === 'all'
|
||||
) {
|
||||
const hasPromoted = categoriesQuery.data.some((cat) => cat.value === 'promoted');
|
||||
if (hasPromoted) {
|
||||
// If promoted exists, update display to show it
|
||||
setDisplayCategory('promoted');
|
||||
}
|
||||
}
|
||||
}, [category, categoriesQuery.data, displayCategory]);
|
||||
|
||||
/**
|
||||
* Handle agent card selection
|
||||
*
|
||||
@@ -132,8 +128,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
*/
|
||||
const orderedTabs = useMemo<string[]>(() => {
|
||||
const dynamic = (categoriesQuery.data || []).map((c) => c.value);
|
||||
// Only include values that actually exist in the categories
|
||||
const set = new Set<string>(dynamic);
|
||||
// Ensure unique and stable order - 'all' should be last to match server response
|
||||
const set = new Set<string>(['promoted', ...dynamic, 'all']);
|
||||
return Array.from(set);
|
||||
}, [categoriesQuery.data]);
|
||||
|
||||
@@ -149,7 +145,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
* Handle category tab selection changes with directional animation
|
||||
*/
|
||||
const handleTabChange = (tabValue: string) => {
|
||||
if (tabValue === displayCategory || isTransitioning) {
|
||||
if (tabValue === activeTab || isTransitioning) {
|
||||
// Ignore redundant or rapid clicks during transition
|
||||
return;
|
||||
}
|
||||
@@ -180,14 +176,37 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync display when URL changes externally (back/forward)
|
||||
* Sync animation when URL changes externally (back/forward or deep links)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (category && category !== displayCategory && !isTransitioning) {
|
||||
// URL changed externally, update display without animation
|
||||
setDisplayCategory(category);
|
||||
if (!didInitRef.current) {
|
||||
// First render: do not animate; just set display to current active tab
|
||||
didInitRef.current = true;
|
||||
setDisplayCategory(activeTab);
|
||||
return;
|
||||
}
|
||||
}, [category, displayCategory, isTransitioning]);
|
||||
if (isTransitioning || activeTab === displayCategory) {
|
||||
return;
|
||||
}
|
||||
// Compute direction vs current displayCategory and animate
|
||||
const currentIndex = getTabIndex(displayCategory);
|
||||
const newIndex = getTabIndex(activeTab);
|
||||
const direction: Direction = newIndex > currentIndex ? 'right' : 'left';
|
||||
|
||||
setAnimationDirection(direction);
|
||||
setNextCategory(activeTab);
|
||||
setIsTransitioning(true);
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDisplayCategory(activeTab);
|
||||
setNextCategory(null);
|
||||
setIsTransitioning(false);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [activeTab, displayCategory, isTransitioning, getTabIndex]);
|
||||
|
||||
// No longer needed with keyframes
|
||||
|
||||
@@ -205,7 +224,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
} else {
|
||||
newParams.delete('q');
|
||||
// Preserve current category when clearing search
|
||||
const currentCategory = displayCategory;
|
||||
const currentCategory = activeTab;
|
||||
if (currentCategory === 'promoted') {
|
||||
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
|
||||
} else {
|
||||
@@ -285,6 +304,10 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
ref={scrollContainerRef}
|
||||
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
{/* Admin Settings */}
|
||||
<div className="absolute right-4 top-4 z-30">
|
||||
<MarketplaceAdminSettings />
|
||||
</div>
|
||||
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||
{!isSmallScreen && (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
||||
@@ -315,19 +338,19 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero Section - scrolls away */}
|
||||
{!isSmallScreen && (
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<div className={cn('mb-8 text-center', 'mt-12')}>
|
||||
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
|
||||
{localize('com_agents_marketplace')}
|
||||
</h1>
|
||||
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
|
||||
{localize('com_agents_marketplace_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<div className={cn('mb-8 text-center', isSmallScreen ? 'mt-6' : 'mt-12')}>
|
||||
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
|
||||
{localize('com_agents_marketplace')}
|
||||
</h1>
|
||||
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
|
||||
{localize('com_agents_marketplace_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky wrapper for search bar and categories */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -337,22 +360,20 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
>
|
||||
<div className="container mx-auto max-w-4xl px-4">
|
||||
{/* Search bar */}
|
||||
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
|
||||
<div className="mx-auto max-w-2xl pb-6">
|
||||
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
||||
{/* TODO: Remove this once we have a better way to handle admin settings */}
|
||||
{/* Admin Settings */}
|
||||
<MarketplaceAdminSettings />
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<CategoryTabs
|
||||
categories={categoriesQuery.data || []}
|
||||
activeTab={displayCategory}
|
||||
activeTab={activeTab}
|
||||
isLoading={categoriesQuery.isLoading}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
||||
{/* Two-pane animated container wrapping category header + grid */}
|
||||
@@ -381,8 +402,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
}
|
||||
if (displayCategory === 'all') {
|
||||
return {
|
||||
name: localize('com_agents_all'),
|
||||
description: localize('com_agents_all_description'),
|
||||
name: 'All Agents',
|
||||
description: 'Browse all shared agents across all categories',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -392,12 +413,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label?.startsWith('com_')
|
||||
? localize(categoryData.label as TranslationKeys)
|
||||
: categoryData.label,
|
||||
description: categoryData.description?.startsWith('com_')
|
||||
? localize(categoryData.description as TranslationKeys)
|
||||
: categoryData.description || '',
|
||||
name: categoryData.label,
|
||||
description: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -459,8 +476,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
}
|
||||
if (nextCategory === 'all') {
|
||||
return {
|
||||
name: localize('com_agents_all'),
|
||||
description: localize('com_agents_all_description'),
|
||||
name: 'All Agents',
|
||||
description: 'Browse all shared agents across all categories',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -470,16 +487,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label?.startsWith('com_')
|
||||
? localize(categoryData.label as TranslationKeys)
|
||||
: categoryData.label,
|
||||
description: categoryData.description?.startsWith('com_')
|
||||
? localize(
|
||||
categoryData.description as Parameters<
|
||||
typeof localize
|
||||
>[0],
|
||||
)
|
||||
: categoryData.description || '',
|
||||
name: categoryData.label,
|
||||
description: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -520,6 +529,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
{/* Note: Using Tailwind keyframes for slide in/out animations */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent detail dialog */}
|
||||
{isDetailOpen && selectedAgent && (
|
||||
<AgentDetail
|
||||
|
||||
@@ -146,13 +146,14 @@ const MarketplaceAdminSettings = () => {
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative h-12 rounded-xl border-border-medium font-medium"
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative gap-1 rounded-lg font-medium"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary">
|
||||
<OGDialogContent className="w-full border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_marketplace',
|
||||
)}`}</OGDialogTitle>
|
||||
|
||||
@@ -73,7 +73,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }
|
||||
value={searchTerm}
|
||||
onChange={handleChange}
|
||||
placeholder={localize('com_agents_search_placeholder')}
|
||||
className="h-12 rounded-xl border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-md transition-[border-color,box-shadow] duration-200 placeholder:text-text-secondary focus:border-border-heavy focus:shadow-lg focus:ring-0"
|
||||
className="h-14 rounded-2xl border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-md transition-[border-color,box-shadow] duration-200 placeholder:text-text-secondary focus:border-border-heavy focus:shadow-lg focus:ring-0"
|
||||
aria-label={localize('com_agents_search_aria')}
|
||||
aria-describedby="search-instructions search-results-count"
|
||||
autoComplete="off"
|
||||
|
||||
@@ -53,6 +53,7 @@ const mockLocalize = jest.fn((key: string, options?: any) => {
|
||||
com_agents_search_placeholder: 'Search agents...',
|
||||
com_agents_clear_search: 'Clear search',
|
||||
com_agents_agent_card_label: `${options?.name} agent. ${options?.description}`,
|
||||
com_agents_no_description: 'No description available',
|
||||
com_agents_grid_announcement: `Showing ${options?.count} agents in ${options?.category} category`,
|
||||
com_agents_load_more_label: `Load more agents from ${options?.category} category`,
|
||||
com_agents_error_retry: 'Try Again',
|
||||
@@ -61,7 +62,6 @@ const mockLocalize = jest.fn((key: string, options?: any) => {
|
||||
com_agents_search_empty_heading: 'No search results',
|
||||
com_agents_created_by: 'by',
|
||||
com_agents_top_picks: 'Top Picks',
|
||||
com_agents_all_category: 'All',
|
||||
// ErrorDisplay translations
|
||||
com_agents_error_suggestion_generic: 'Try refreshing the page or check your network connection',
|
||||
com_agents_error_network_title: 'Network Error',
|
||||
@@ -200,7 +200,7 @@ describe('Accessibility Improvements', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const promotedTab = screen.getByRole('tab', { name: /Top Picks category/ });
|
||||
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
|
||||
|
||||
// Test arrow key navigation
|
||||
fireEvent.keyDown(promotedTab, { key: 'ArrowRight' });
|
||||
@@ -227,8 +227,8 @@ describe('Accessibility Improvements', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const promotedTab = screen.getByRole('tab', { name: /Top Picks category/ });
|
||||
const allTab = screen.getByRole('tab', { name: /All category/ });
|
||||
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
|
||||
const allTab = screen.getByRole('tab', { name: /All tab/ });
|
||||
|
||||
// Active tab should be focusable
|
||||
expect(promotedTab).toHaveAttribute('tabIndex', '0');
|
||||
@@ -307,6 +307,13 @@ describe('Accessibility Improvements', () => {
|
||||
expect(card).toHaveAttribute('role', 'button');
|
||||
});
|
||||
|
||||
it('handles agents without descriptions', () => {
|
||||
const agentWithoutDesc = { ...mockAgent, description: undefined };
|
||||
render(<AgentCard agent={agentWithoutDesc as any as t.Agent} onClick={jest.fn()} />);
|
||||
|
||||
expect(screen.getByText('No description available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports keyboard interaction', () => {
|
||||
const onClick = jest.fn();
|
||||
render(<AgentCard agent={mockAgent as t.Agent} onClick={onClick} />);
|
||||
|
||||
@@ -8,42 +8,10 @@ import type t from 'librechat-data-provider';
|
||||
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
|
||||
const mockTranslations: Record<string, string> = {
|
||||
com_agents_created_by: 'Created by',
|
||||
com_agents_agent_card_label: '{{name}} agent. {{description}}',
|
||||
com_agents_category_general: 'General',
|
||||
com_agents_category_hr: 'Human Resources',
|
||||
};
|
||||
return mockTranslations[key] || key;
|
||||
});
|
||||
|
||||
// Mock useAgentCategories hook
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string, values?: Record<string, string>) => {
|
||||
const mockTranslations: Record<string, string> = {
|
||||
com_agents_created_by: 'Created by',
|
||||
com_agents_agent_card_label: '{{name}} agent. {{description}}',
|
||||
com_agents_category_general: 'General',
|
||||
com_agents_category_hr: 'Human Resources',
|
||||
};
|
||||
let translation = mockTranslations[key] || key;
|
||||
|
||||
// Replace placeholders with actual values
|
||||
if (values) {
|
||||
Object.entries(values).forEach(([placeholder, value]) => {
|
||||
translation = translation.replace(new RegExp(`{{${placeholder}}}`, 'g'), value);
|
||||
});
|
||||
}
|
||||
|
||||
return translation;
|
||||
},
|
||||
useAgentCategories: () => ({
|
||||
categories: [
|
||||
{ value: 'general', label: 'com_agents_category_general' },
|
||||
{ value: 'hr', label: 'com_agents_category_hr' },
|
||||
{ value: 'custom', label: 'Custom Category' }, // Non-localized custom category
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AgentCard', () => {
|
||||
const mockAgent: t.Agent = {
|
||||
id: '1',
|
||||
@@ -80,6 +48,7 @@ describe('AgentCard', () => {
|
||||
|
||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -183,6 +152,7 @@ describe('AgentCard', () => {
|
||||
|
||||
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -195,6 +165,7 @@ describe('AgentCard', () => {
|
||||
|
||||
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -207,6 +178,7 @@ describe('AgentCard', () => {
|
||||
|
||||
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -223,6 +195,7 @@ describe('AgentCard', () => {
|
||||
|
||||
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -232,49 +205,6 @@ describe('AgentCard', () => {
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
expect(card).toHaveAttribute('tabIndex', '0');
|
||||
expect(card).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Test Agent agent. A test agent for testing purposes',
|
||||
);
|
||||
});
|
||||
|
||||
it('displays localized category label', () => {
|
||||
const agentWithCategory = {
|
||||
...mockAgent,
|
||||
category: 'general',
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithCategory} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('General')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays custom category label', () => {
|
||||
const agentWithCustomCategory = {
|
||||
...mockAgent,
|
||||
category: 'custom',
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithCustomCategory} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Custom Category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays capitalized fallback for unknown category', () => {
|
||||
const agentWithUnknownCategory = {
|
||||
...mockAgent,
|
||||
category: 'unknown',
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithUnknownCategory} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display category tag when category is not provided', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.queryByText('General')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Unknown')).not.toBeInTheDocument();
|
||||
expect(card).toHaveAttribute('aria-label', 'com_agents_agent_card_label');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import AgentCategoryDisplay from '../AgentCategoryDisplay';
|
||||
|
||||
// Mock the useAgentCategories hook
|
||||
jest.mock('~/hooks/Agents', () => ({
|
||||
useAgentCategories: () => ({
|
||||
categories: [
|
||||
{
|
||||
value: 'general',
|
||||
label: 'General',
|
||||
icon: <span data-testid="icon-general">{''}</span>,
|
||||
className: 'w-full',
|
||||
},
|
||||
{
|
||||
value: 'hr',
|
||||
label: 'HR',
|
||||
icon: <span data-testid="icon-hr">{''}</span>,
|
||||
className: 'w-full',
|
||||
},
|
||||
{
|
||||
value: 'rd',
|
||||
label: 'R&D',
|
||||
icon: <span data-testid="icon-rd">{''}</span>,
|
||||
className: 'w-full',
|
||||
},
|
||||
{
|
||||
value: 'finance',
|
||||
label: 'Finance',
|
||||
icon: <span data-testid="icon-finance">{''}</span>,
|
||||
className: 'w-full',
|
||||
},
|
||||
],
|
||||
emptyCategory: {
|
||||
value: '',
|
||||
label: 'General',
|
||||
className: 'w-full',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AgentCategoryDisplay', () => {
|
||||
it('should display the proper label for a category', () => {
|
||||
render(<AgentCategoryDisplay category="rd" />);
|
||||
expect(screen.getByText('R&D')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the icon when showIcon is true', () => {
|
||||
render(<AgentCategoryDisplay category="finance" showIcon={true} />);
|
||||
expect(screen.getByTestId('icon-finance')).toBeInTheDocument();
|
||||
expect(screen.getByText('Finance')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display the icon when showIcon is false', () => {
|
||||
render(<AgentCategoryDisplay category="hr" showIcon={false} />);
|
||||
expect(screen.queryByTestId('icon-hr')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('HR')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom classnames', () => {
|
||||
render(<AgentCategoryDisplay category="general" className="test-class" />);
|
||||
expect(screen.getByText('General').parentElement).toHaveClass('test-class');
|
||||
});
|
||||
|
||||
it('should not render anything for unknown categories', () => {
|
||||
const { container } = render(<AgentCategoryDisplay category="unknown" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render anything when no category is provided', () => {
|
||||
const { container } = render(<AgentCategoryDisplay />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render anything for empty category when showEmptyFallback is false', () => {
|
||||
const { container } = render(<AgentCategoryDisplay category="" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should render empty category placeholder when showEmptyFallback is true', () => {
|
||||
render(<AgentCategoryDisplay category="" showEmptyFallback={true} />);
|
||||
expect(screen.getByText('General')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom iconClassName to the icon', () => {
|
||||
render(<AgentCategoryDisplay category="general" iconClassName="custom-icon-class" />);
|
||||
const iconElement = screen.getByTestId('icon-general').parentElement;
|
||||
expect(iconElement).toHaveClass('custom-icon-class');
|
||||
});
|
||||
});
|
||||
@@ -179,6 +179,7 @@ describe('AgentDetail', () => {
|
||||
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
|
||||
|
||||
expect(screen.getByText('com_agents_loading')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_agents_no_description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render copy link button', () => {
|
||||
|
||||
@@ -9,8 +9,7 @@ import type t from 'librechat-data-provider';
|
||||
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
|
||||
const mockTranslations: Record<string, string> = {
|
||||
com_agents_top_picks: 'Top Picks',
|
||||
com_agents_all: 'All Agents',
|
||||
com_agents_all_category: 'All',
|
||||
com_agents_all: 'All',
|
||||
com_ui_no_categories: 'No categories available',
|
||||
com_agents_category_tabs_label: 'Agent Categories',
|
||||
com_ui_agent_category_general: 'General',
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('Virtual Scrolling Performance', () => {
|
||||
|
||||
// Performance check: rendering should be fast
|
||||
const renderTime = endTime - startTime;
|
||||
expect(renderTime).toBeLessThan(650);
|
||||
expect(renderTime).toBeLessThan(600); // Should render in less than 600ms
|
||||
|
||||
console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`);
|
||||
console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`);
|
||||
|
||||
@@ -89,7 +89,7 @@ const BookmarkEditDialog = ({
|
||||
<OGDialog open={open} onOpenChange={setOpen} triggerRef={triggerRef}>
|
||||
{children}
|
||||
<OGDialogTemplate
|
||||
title={bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')}
|
||||
title="Bookmark"
|
||||
showCloseButton={false}
|
||||
className="w-11/12 md:max-w-2xl"
|
||||
main={
|
||||
|
||||
@@ -38,8 +38,6 @@ const BookmarkForm = ({
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<TConversationTagRequest>({
|
||||
mode: 'onBlur',
|
||||
reValidateMode: 'onChange',
|
||||
defaultValues: {
|
||||
tag: bookmark?.tag ?? '',
|
||||
description: bookmark?.description ?? '',
|
||||
@@ -100,30 +98,23 @@ const BookmarkForm = ({
|
||||
<Input
|
||||
type="text"
|
||||
id="bookmark-tag"
|
||||
aria-label={
|
||||
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
|
||||
}
|
||||
aria-label="Bookmark"
|
||||
{...register('tag', {
|
||||
required: localize('com_ui_field_required'),
|
||||
required: 'tag is required',
|
||||
maxLength: {
|
||||
value: 128,
|
||||
message: localize('com_ui_field_max_length', {
|
||||
field: localize('com_ui_bookmarks_title'),
|
||||
length: 128,
|
||||
}),
|
||||
message: localize('com_auth_password_max_length'),
|
||||
},
|
||||
validate: (value) => {
|
||||
return (
|
||||
value === bookmark?.tag ||
|
||||
bookmarks.every((bookmark) => bookmark.tag !== value) ||
|
||||
localize('com_ui_bookmarks_tag_exists')
|
||||
'tag must be unique'
|
||||
);
|
||||
},
|
||||
})}
|
||||
aria-invalid={!!errors.tag}
|
||||
placeholder={
|
||||
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
|
||||
}
|
||||
placeholder="Bookmark"
|
||||
/>
|
||||
{errors.tag && <span className="text-sm text-red-500">{errors.tag.message}</span>}
|
||||
</div>
|
||||
@@ -136,10 +127,7 @@ const BookmarkForm = ({
|
||||
{...register('description', {
|
||||
maxLength: {
|
||||
value: 1048,
|
||||
message: localize('com_ui_field_max_length', {
|
||||
field: localize('com_ui_bookmarks_description'),
|
||||
length: 1048,
|
||||
}),
|
||||
message: 'Maximum 1048 characters',
|
||||
},
|
||||
})}
|
||||
id="bookmark-description"
|
||||
|
||||
@@ -10,12 +10,11 @@ import {
|
||||
PermissionTypes,
|
||||
defaultAgentCapabilities,
|
||||
} from 'librechat-data-provider';
|
||||
import { useLocalize, useHasAccess, useAgentCapabilities, useMCPSelect } from '~/hooks';
|
||||
import { useLocalize, useHasAccess, useAgentCapabilities } from '~/hooks';
|
||||
import ArtifactsSubMenu from '~/components/Chat/Input/ArtifactsSubMenu';
|
||||
import MCPSubMenu from '~/components/Chat/Input/MCPSubMenu';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
|
||||
interface ToolsDropdownProps {
|
||||
disabled?: boolean;
|
||||
@@ -27,16 +26,15 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const {
|
||||
webSearch,
|
||||
mcpSelect,
|
||||
artifacts,
|
||||
fileSearch,
|
||||
agentsConfig,
|
||||
startupConfig,
|
||||
codeApiKeyForm,
|
||||
codeInterpreter,
|
||||
searchApiKeyForm,
|
||||
} = useBadgeRowContext();
|
||||
const mcpSelect = useMCPSelect();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled } =
|
||||
useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { ModelSelectorProps } from '~/common';
|
||||
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
|
||||
import { ModelSelectorChatProvider } from './ModelSelectorChatContext';
|
||||
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components';
|
||||
import { getSelectedIcon, getDisplayValue } from './utils';
|
||||
import { CustomMenu as Menu } from './CustomMenu';
|
||||
@@ -13,7 +12,6 @@ function ModelSelectorContent() {
|
||||
|
||||
const {
|
||||
// LibreChat
|
||||
agentsMap,
|
||||
modelSpecs,
|
||||
mappedEndpoints,
|
||||
endpointsConfig,
|
||||
@@ -45,12 +43,11 @@ function ModelSelectorContent() {
|
||||
() =>
|
||||
getDisplayValue({
|
||||
localize,
|
||||
agentsMap,
|
||||
modelSpecs,
|
||||
selectedValues,
|
||||
mappedEndpoints,
|
||||
}),
|
||||
[localize, agentsMap, modelSpecs, selectedValues, mappedEndpoints],
|
||||
[localize, modelSpecs, selectedValues, mappedEndpoints],
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
@@ -103,10 +100,8 @@ function ModelSelectorContent() {
|
||||
|
||||
export default function ModelSelector({ startupConfig }: ModelSelectorProps) {
|
||||
return (
|
||||
<ModelSelectorChatProvider>
|
||||
<ModelSelectorProvider startupConfig={startupConfig}>
|
||||
<ModelSelectorContent />
|
||||
</ModelSelectorProvider>
|
||||
</ModelSelectorChatProvider>
|
||||
<ModelSelectorProvider startupConfig={startupConfig}>
|
||||
<ModelSelectorContent />
|
||||
</ModelSelectorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
|
||||
interface ModelSelectorChatContextValue {
|
||||
endpoint?: EModelEndpoint | null;
|
||||
model?: string | null;
|
||||
spec?: string | null;
|
||||
agent_id?: string | null;
|
||||
assistant_id?: string | null;
|
||||
newConversation: ReturnType<typeof useChatContext>['newConversation'];
|
||||
}
|
||||
|
||||
const ModelSelectorChatContext = createContext<ModelSelectorChatContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export function ModelSelectorChatProvider({ children }: { children: React.ReactNode }) {
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
|
||||
/** Context value only created when relevant conversation properties change */
|
||||
const contextValue = useMemo<ModelSelectorChatContextValue>(
|
||||
() => ({
|
||||
endpoint: conversation?.endpoint,
|
||||
model: conversation?.model,
|
||||
spec: conversation?.spec,
|
||||
agent_id: conversation?.agent_id,
|
||||
assistant_id: conversation?.assistant_id,
|
||||
newConversation,
|
||||
}),
|
||||
[
|
||||
conversation?.endpoint,
|
||||
conversation?.model,
|
||||
conversation?.spec,
|
||||
conversation?.agent_id,
|
||||
conversation?.assistant_id,
|
||||
newConversation,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ModelSelectorChatContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ModelSelectorChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useModelSelectorChatContext() {
|
||||
const context = useContext(ModelSelectorChatContext);
|
||||
if (!context) {
|
||||
throw new Error('useModelSelectorChatContext must be used within ModelSelectorChatProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -3,16 +3,10 @@ import React, { createContext, useContext, useState, useMemo } from 'react';
|
||||
import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import type { Endpoint, SelectedValues } from '~/common';
|
||||
import {
|
||||
useAgentDefaultPermissionLevel,
|
||||
useSelectorEffects,
|
||||
useKeyDialog,
|
||||
useEndpoints,
|
||||
} from '~/hooks';
|
||||
import { useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
|
||||
import { useGetEndpointsQuery, useListAgentsQuery } from '~/data-provider';
|
||||
import { useModelSelectorChatContext } from './ModelSelectorChatContext';
|
||||
import { useAgentsMapContext, useAssistantsMapContext, useChatContext } from '~/Providers';
|
||||
import { useEndpoints, useSelectorEffects, useKeyDialog } from '~/hooks';
|
||||
import useSelectMention from '~/hooks/Input/useSelectMention';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { filterItems } from './utils';
|
||||
|
||||
type ModelSelectorContextType = {
|
||||
@@ -57,24 +51,14 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const assistantsMap = useAssistantsMapContext();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { endpoint, model, spec, agent_id, assistant_id, newConversation } =
|
||||
useModelSelectorChatContext();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
const modelSpecs = useMemo(() => startupConfig?.modelSpecs?.list ?? [], [startupConfig]);
|
||||
const permissionLevel = useAgentDefaultPermissionLevel();
|
||||
const { data: agents = null } = useListAgentsQuery(
|
||||
{ requiredPermission: permissionLevel },
|
||||
{
|
||||
select: (data) => data?.data,
|
||||
},
|
||||
);
|
||||
|
||||
const { mappedEndpoints, endpointRequiresUserKey } = useEndpoints({
|
||||
agents,
|
||||
agentsMap,
|
||||
assistantsMap,
|
||||
startupConfig,
|
||||
endpointsConfig,
|
||||
});
|
||||
|
||||
const { onSelectEndpoint, onSelectSpec } = useSelectMention({
|
||||
// presets,
|
||||
modelSpecs,
|
||||
@@ -86,21 +70,13 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||
|
||||
// State
|
||||
const [selectedValues, setSelectedValues] = useState<SelectedValues>({
|
||||
endpoint: endpoint || '',
|
||||
model: model || '',
|
||||
modelSpec: spec || '',
|
||||
endpoint: conversation?.endpoint || '',
|
||||
model: conversation?.model || '',
|
||||
modelSpec: conversation?.spec || '',
|
||||
});
|
||||
useSelectorEffects({
|
||||
agentsMap,
|
||||
conversation: endpoint
|
||||
? ({
|
||||
endpoint: endpoint ?? null,
|
||||
model: model ?? null,
|
||||
spec: spec ?? null,
|
||||
agent_id: agent_id ?? null,
|
||||
assistant_id: assistant_id ?? null,
|
||||
} as any)
|
||||
: null,
|
||||
conversation,
|
||||
assistantsMap,
|
||||
setSelectedValues,
|
||||
});
|
||||
@@ -110,7 +86,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||
|
||||
const keyProps = useKeyDialog();
|
||||
|
||||
/** Memoized search results */
|
||||
// Memoized search results
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchValue) {
|
||||
return null;
|
||||
@@ -119,6 +95,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||
return filterItems(allItems, searchValue, agentsMap, assistantsMap || {});
|
||||
}, [searchValue, modelSpecs, mappedEndpoints, agentsMap, assistantsMap]);
|
||||
|
||||
// Functions
|
||||
const setDebouncedSearchValue = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
|
||||
@@ -167,13 +167,11 @@ export const getDisplayValue = ({
|
||||
mappedEndpoints,
|
||||
selectedValues,
|
||||
modelSpecs,
|
||||
agentsMap,
|
||||
}: {
|
||||
localize: ReturnType<typeof useLocalize>;
|
||||
selectedValues: SelectedValues;
|
||||
mappedEndpoints: Endpoint[];
|
||||
modelSpecs: TModelSpec[];
|
||||
agentsMap?: TAgentsMap;
|
||||
}) => {
|
||||
if (selectedValues.modelSpec) {
|
||||
const spec = modelSpecs.find((s) => s.name === selectedValues.modelSpec);
|
||||
@@ -192,9 +190,6 @@ export const getDisplayValue = ({
|
||||
endpoint.agentNames[selectedValues.model]
|
||||
) {
|
||||
return endpoint.agentNames[selectedValues.model];
|
||||
} else if (isAgentsEndpoint(endpoint.value) && agentsMap) {
|
||||
const agent = agentsMap[selectedValues.model];
|
||||
return agent?.name || selectedValues.model;
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -94,12 +94,6 @@ const ContentParts = memo(
|
||||
return null;
|
||||
}
|
||||
|
||||
const isToolCall =
|
||||
part.type === ContentTypes.TOOL_CALL || part['tool_call_ids'] != null;
|
||||
if (isToolCall) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EditTextPart
|
||||
index={idx}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user