Compare commits
14 Commits
feat/add-b
...
feat/anthr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2584ded9a0 | ||
|
|
621695b5a2 | ||
|
|
ae3907d176 | ||
|
|
6605b6c800 | ||
|
|
007570b5c6 | ||
|
|
5943d5346c | ||
|
|
052e61b735 | ||
|
|
1ccac58403 | ||
|
|
04d74a7e07 | ||
|
|
0fdca8ddbd | ||
|
|
c5ca621efd | ||
|
|
8cefa566da | ||
|
|
7e4c8a5d0d | ||
|
|
edf33bedcb |
53
.github/workflows/helmcharts.yml
vendored
53
.github/workflows/helmcharts.yml
vendored
@@ -4,12 +4,13 @@ name: Build Helm Charts on Tag
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
- "chart-*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -26,15 +27,49 @@ jobs:
|
||||
uses: azure/setup-helm@v4
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Build Subchart Deps
|
||||
run: |
|
||||
cd helm/librechat-rag-api
|
||||
helm dependency build
|
||||
cd helm/librechat
|
||||
helm dependency build
|
||||
cd ../librechat-rag-api
|
||||
helm dependency build
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.6.0
|
||||
- name: Get Chart Version
|
||||
id: chart-version
|
||||
run: |
|
||||
CHART_VERSION=$(echo "${{ github.ref_name }}" | cut -d'-' -f2)
|
||||
echo "CHART_VERSION=${CHART_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Log in to GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
charts_dir: helm
|
||||
skip_existing: true
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Run Helm OCI Charts Releaser
|
||||
# This is for the librechat chart
|
||||
- name: Release Helm OCI Charts for librechat
|
||||
uses: appany/helm-oci-chart-releaser@v0.4.2
|
||||
with:
|
||||
name: librechat
|
||||
repository: ${{ github.actor }}/librechat-chart
|
||||
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
|
||||
path: helm/librechat
|
||||
registry: ghcr.io
|
||||
registry_username: ${{ github.actor }}
|
||||
registry_password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# this is for the librechat-rag-api chart
|
||||
- name: Release Helm OCI Charts for librechat-rag-api
|
||||
uses: appany/helm-oci-chart-releaser@v0.4.2
|
||||
with:
|
||||
name: librechat-rag-api
|
||||
repository: ${{ github.actor }}/librechat-chart
|
||||
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
|
||||
path: helm/librechat-rag-api
|
||||
registry: ghcr.io
|
||||
registry_username: ${{ github.actor }}
|
||||
registry_password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.8.0-rc1
|
||||
# v0.8.0-rc2
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.8.0-rc1
|
||||
# v0.8.0-rc2
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
||||
@@ -27,6 +27,7 @@ const {
|
||||
const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils');
|
||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { encodeAndFormatDocuments } = require('~/server/services/Files/documents');
|
||||
const { sleep } = require('~/server/utils');
|
||||
const BaseClient = require('./BaseClient');
|
||||
const { logger } = require('~/config');
|
||||
@@ -312,6 +313,33 @@ class AnthropicClient extends BaseClient {
|
||||
return files;
|
||||
}
|
||||
|
||||
async addDocuments(message, attachments) {
|
||||
// Only process documents
|
||||
const documentResult = await encodeAndFormatDocuments(
|
||||
this.options.req,
|
||||
attachments,
|
||||
EModelEndpoint.anthropic,
|
||||
);
|
||||
|
||||
message.documents =
|
||||
documentResult.documents && documentResult.documents.length
|
||||
? documentResult.documents
|
||||
: undefined;
|
||||
|
||||
return documentResult.files;
|
||||
}
|
||||
|
||||
async processAttachments(message, attachments) {
|
||||
// Process both images and documents
|
||||
const [imageFiles, documentFiles] = await Promise.all([
|
||||
this.addImageURLs(message, attachments),
|
||||
this.addDocuments(message, attachments),
|
||||
]);
|
||||
|
||||
// Combine files from both processors
|
||||
return [...imageFiles, ...documentFiles];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {number} params.promptTokens
|
||||
@@ -382,7 +410,7 @@ class AnthropicClient extends BaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
const files = await this.addImageURLs(latestMessage, attachments);
|
||||
const files = await this.processAttachments(latestMessage, attachments);
|
||||
|
||||
this.options.attachments = files;
|
||||
}
|
||||
@@ -941,7 +969,7 @@ class AnthropicClient extends BaseClient {
|
||||
const content = `<conversation_context>
|
||||
${convo}
|
||||
</conversation_context>
|
||||
|
||||
|
||||
Please generate a title for this conversation.`;
|
||||
|
||||
const titleMessage = { role: 'user', content };
|
||||
|
||||
@@ -1233,7 +1233,7 @@ class BaseClient {
|
||||
{},
|
||||
);
|
||||
|
||||
await this.addImageURLs(message, files, this.visionMode);
|
||||
await this.processAttachments(message, files, this.visionMode);
|
||||
|
||||
this.message_file_map[message.messageId] = files;
|
||||
return message;
|
||||
|
||||
@@ -268,7 +268,7 @@ class GoogleClient extends BaseClient {
|
||||
const formattedMessages = [];
|
||||
const attachments = await this.options.attachments;
|
||||
const latestMessage = { ...messages[messages.length - 1] };
|
||||
const files = await this.addImageURLs(latestMessage, attachments, VisionModes.generative);
|
||||
const files = await this.processAttachments(latestMessage, attachments, VisionModes.generative);
|
||||
this.options.attachments = files;
|
||||
messages[messages.length - 1] = latestMessage;
|
||||
|
||||
@@ -312,6 +312,20 @@ class GoogleClient extends BaseClient {
|
||||
return files;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async addDocuments(message, attachments) {
|
||||
// GoogleClient doesn't support document processing yet
|
||||
// Return empty results for consistency
|
||||
return [];
|
||||
}
|
||||
|
||||
async processAttachments(message, attachments, mode = '') {
|
||||
// For GoogleClient, only process images
|
||||
const imageFiles = await this.addImageURLs(message, attachments, mode);
|
||||
const documentFiles = await this.addDocuments(message, attachments);
|
||||
return [...imageFiles, ...documentFiles];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the augmented prompt for attachments
|
||||
* TODO: Add File API Support
|
||||
@@ -345,7 +359,7 @@ class GoogleClient extends BaseClient {
|
||||
|
||||
const { prompt } = await this.buildMessagesPrompt(messages, parentMessageId);
|
||||
|
||||
const files = await this.addImageURLs(latestMessage, attachments);
|
||||
const files = await this.processAttachments(latestMessage, attachments);
|
||||
|
||||
this.options.attachments = files;
|
||||
|
||||
|
||||
@@ -372,6 +372,19 @@ class OpenAIClient extends BaseClient {
|
||||
return files;
|
||||
}
|
||||
|
||||
async addDocuments(message, attachments) {
|
||||
// OpenAI doesn't support native document processing yet
|
||||
// Return empty results for consistency
|
||||
return [];
|
||||
}
|
||||
|
||||
async processAttachments(message, attachments) {
|
||||
// For OpenAI, only process images
|
||||
const imageFiles = await this.addImageURLs(message, attachments);
|
||||
const documentFiles = await this.addDocuments(message, attachments);
|
||||
return [...imageFiles, ...documentFiles];
|
||||
}
|
||||
|
||||
async buildMessages(messages, parentMessageId, { promptPrefix = null }, opts) {
|
||||
let orderedMessages = this.constructor.getMessagesForConversation({
|
||||
messages,
|
||||
@@ -400,7 +413,7 @@ class OpenAIClient extends BaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
const files = await this.addImageURLs(
|
||||
const files = await this.processAttachments(
|
||||
orderedMessages[orderedMessages.length - 1],
|
||||
attachments,
|
||||
);
|
||||
|
||||
@@ -3,24 +3,61 @@ const { EModelEndpoint, ContentTypes } = require('librechat-data-provider');
|
||||
const { HumanMessage, AIMessage, SystemMessage } = require('@langchain/core/messages');
|
||||
|
||||
/**
|
||||
* Formats a message to OpenAI Vision API payload format.
|
||||
* Formats a message with document attachments for specific endpoints.
|
||||
*
|
||||
* @param {Object} params - The parameters for formatting.
|
||||
* @param {Object} params.message - The message object to format.
|
||||
* @param {string} [params.message.role] - The role of the message sender (must be 'user').
|
||||
* @param {string} [params.message.content] - The text content of the message.
|
||||
* @param {Array<Object>} [params.documents] - The document attachments for the message.
|
||||
* @param {EModelEndpoint} [params.endpoint] - Identifier for specific endpoint handling
|
||||
* @returns {(Object)} - The formatted message.
|
||||
*/
|
||||
const formatDocumentMessage = ({ message, documents, endpoint }) => {
|
||||
const contentParts = [];
|
||||
|
||||
// Add documents first (for Anthropic PDFs)
|
||||
if (documents && documents.length > 0) {
|
||||
contentParts.push(...documents);
|
||||
}
|
||||
|
||||
// Add text content
|
||||
contentParts.push({ type: ContentTypes.TEXT, text: message.content });
|
||||
|
||||
if (endpoint === EModelEndpoint.anthropic) {
|
||||
message.content = contentParts;
|
||||
return message;
|
||||
}
|
||||
|
||||
// For other endpoints, might need different handling
|
||||
message.content = contentParts;
|
||||
return message;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a message with vision capabilities (image_urls) for specific endpoints.
|
||||
*
|
||||
* @param {Object} params - The parameters for formatting.
|
||||
* @param {Object} params.message - The message object to format.
|
||||
* @param {Array<string>} [params.image_urls] - The image_urls to attach to the message.
|
||||
* @param {EModelEndpoint} [params.endpoint] - Identifier for specific endpoint handling
|
||||
* @returns {(Object)} - The formatted message.
|
||||
*/
|
||||
const formatVisionMessage = ({ message, image_urls, endpoint }) => {
|
||||
const contentParts = [];
|
||||
|
||||
// Add images
|
||||
if (image_urls && image_urls.length > 0) {
|
||||
contentParts.push(...image_urls);
|
||||
}
|
||||
|
||||
// Add text content
|
||||
contentParts.push({ type: ContentTypes.TEXT, text: message.content });
|
||||
|
||||
if (endpoint === EModelEndpoint.anthropic) {
|
||||
message.content = [...image_urls, { type: ContentTypes.TEXT, text: message.content }];
|
||||
message.content = contentParts;
|
||||
return message;
|
||||
}
|
||||
|
||||
message.content = [{ type: ContentTypes.TEXT, text: message.content }, ...image_urls];
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
@@ -58,7 +95,18 @@ const formatMessage = ({ message, userName, assistantName, endpoint, langChain =
|
||||
content,
|
||||
};
|
||||
|
||||
const { image_urls } = message;
|
||||
const { image_urls, documents } = message;
|
||||
|
||||
// Handle documents
|
||||
if (Array.isArray(documents) && documents.length > 0 && role === 'user') {
|
||||
return formatDocumentMessage({
|
||||
message: formattedMessage,
|
||||
documents: message.documents,
|
||||
endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle images
|
||||
if (Array.isArray(image_urls) && image_urls.length > 0 && role === 'user') {
|
||||
return formatVisionMessage({
|
||||
message: formattedMessage,
|
||||
@@ -146,7 +194,21 @@ const formatAgentMessages = (payload) => {
|
||||
message.content = [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: message.content }];
|
||||
}
|
||||
if (message.role !== 'assistant') {
|
||||
messages.push(formatMessage({ message, langChain: true }));
|
||||
// Check if message has documents and preserve array structure
|
||||
const hasDocuments =
|
||||
Array.isArray(message.content) &&
|
||||
message.content.some((part) => part && part.type === 'document');
|
||||
|
||||
if (hasDocuments && message.role === 'user') {
|
||||
// For user messages with documents, create HumanMessage directly with array content
|
||||
messages.push(new HumanMessage({ content: message.content }));
|
||||
} else if (hasDocuments && message.role === 'system') {
|
||||
// For system messages with documents, create SystemMessage directly with array content
|
||||
messages.push(new SystemMessage({ content: message.content }));
|
||||
} else {
|
||||
// Use regular formatting for messages without documents
|
||||
messages.push(formatMessage({ message, langChain: true }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -239,6 +301,8 @@ const formatAgentMessages = (payload) => {
|
||||
|
||||
module.exports = {
|
||||
formatMessage,
|
||||
formatDocumentMessage,
|
||||
formatVisionMessage,
|
||||
formatFromLangChain,
|
||||
formatAgentMessages,
|
||||
formatLangChainMessages,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
const { logger } = require('~/config');
|
||||
|
||||
//handle duplicates
|
||||
const handleDuplicateKeyError = (err, res) => {
|
||||
logger.error('Duplicate key error:', err.keyValue);
|
||||
const field = `${JSON.stringify(Object.keys(err.keyValue))}`;
|
||||
const code = 409;
|
||||
res
|
||||
.status(code)
|
||||
.send({ messages: `An document with that ${field} already exists.`, fields: field });
|
||||
};
|
||||
|
||||
//handle validation errors
|
||||
const handleValidationError = (err, res) => {
|
||||
logger.error('Validation error:', err.errors);
|
||||
let errors = Object.values(err.errors).map((el) => el.message);
|
||||
let fields = `${JSON.stringify(Object.values(err.errors).map((el) => el.path))}`;
|
||||
let code = 400;
|
||||
if (errors.length > 1) {
|
||||
errors = errors.join(' ');
|
||||
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
|
||||
} else {
|
||||
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = (err, _req, res, _next) => {
|
||||
try {
|
||||
if (err.name === 'ValidationError') {
|
||||
return handleValidationError(err, res);
|
||||
}
|
||||
if (err.code && err.code == 11000) {
|
||||
return handleDuplicateKeyError(err, res);
|
||||
}
|
||||
// Special handling for errors like SyntaxError
|
||||
if (err.statusCode && err.body) {
|
||||
return res.status(err.statusCode).send(err.body);
|
||||
}
|
||||
|
||||
logger.error('ErrorController => error', err);
|
||||
return res.status(500).send('An unknown error occurred.');
|
||||
} catch (err) {
|
||||
logger.error('ErrorController => processing error', err);
|
||||
return res.status(500).send('Processing error in ErrorController.');
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* @param {ServerRequest} req
|
||||
* @returns {Promise<TModelsConfig>} The models config.
|
||||
*/
|
||||
const getModelsConfig = async (req) => {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
|
||||
@@ -99,10 +99,36 @@ const confirm2FA = async (req, res) => {
|
||||
|
||||
/**
|
||||
* Disable 2FA by clearing the stored secret and backup codes.
|
||||
* Requires verification with either TOTP token or backup code if 2FA is fully enabled.
|
||||
*/
|
||||
const disable2FA = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token, backupCode } = req.body;
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA is not setup for this user' });
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
const secret = await getTOTPSecret(user.totpSecret);
|
||||
let isVerified = false;
|
||||
|
||||
if (token) {
|
||||
isVerified = await verifyTOTP(secret, token);
|
||||
} else if (backupCode) {
|
||||
isVerified = await verifyBackupCode({ user, backupCode });
|
||||
} else {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: 'Either token or backup code is required to disable 2FA' });
|
||||
}
|
||||
|
||||
if (!isVerified) {
|
||||
return res.status(401).json({ message: 'Invalid token or backup code' });
|
||||
}
|
||||
}
|
||||
await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false });
|
||||
return res.status(200).json();
|
||||
} catch (err) {
|
||||
|
||||
@@ -226,6 +226,42 @@ class AgentClient extends BaseClient {
|
||||
return files;
|
||||
}
|
||||
|
||||
async addDocuments(message, attachments) {
|
||||
const documentResult =
|
||||
await require('~/server/services/Files/documents').encodeAndFormatDocuments(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.agent.provider,
|
||||
);
|
||||
message.documents =
|
||||
documentResult.documents && documentResult.documents.length
|
||||
? documentResult.documents
|
||||
: undefined;
|
||||
return documentResult.files;
|
||||
}
|
||||
|
||||
async processAttachments(message, attachments) {
|
||||
const [imageFiles, documentFiles] = await Promise.all([
|
||||
this.addImageURLs(message, attachments),
|
||||
this.addDocuments(message, attachments),
|
||||
]);
|
||||
|
||||
const allFiles = [...imageFiles, ...documentFiles];
|
||||
const seenFileIds = new Set();
|
||||
const uniqueFiles = [];
|
||||
|
||||
for (const file of allFiles) {
|
||||
if (file.file_id && !seenFileIds.has(file.file_id)) {
|
||||
seenFileIds.add(file.file_id);
|
||||
uniqueFiles.push(file);
|
||||
} else if (!file.file_id) {
|
||||
uniqueFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueFiles;
|
||||
}
|
||||
|
||||
async buildMessages(
|
||||
messages,
|
||||
parentMessageId,
|
||||
@@ -259,7 +295,7 @@ class AgentClient extends BaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
const files = await this.addImageURLs(
|
||||
const files = await this.processAttachments(
|
||||
orderedMessages[orderedMessages.length - 1],
|
||||
attachments,
|
||||
);
|
||||
@@ -282,6 +318,23 @@ class AgentClient extends BaseClient {
|
||||
assistantName: this.options?.modelLabel,
|
||||
});
|
||||
|
||||
if (
|
||||
message.documents &&
|
||||
message.documents.length > 0 &&
|
||||
message.role === 'user' &&
|
||||
this.options.agent.provider === EModelEndpoint.anthropic
|
||||
) {
|
||||
const contentParts = [];
|
||||
contentParts.push(...message.documents);
|
||||
if (message.image_urls && message.image_urls.length > 0) {
|
||||
contentParts.push(...message.image_urls);
|
||||
}
|
||||
const textContent =
|
||||
typeof formattedMessage.content === 'string' ? formattedMessage.content : '';
|
||||
contentParts.push({ type: 'text', text: textContent });
|
||||
formattedMessage.content = contentParts;
|
||||
}
|
||||
|
||||
if (message.ocr && i !== orderedMessages.length - 1) {
|
||||
if (typeof formattedMessage.content === 'string') {
|
||||
formattedMessage.content = message.ocr + '\n' + formattedMessage.content;
|
||||
@@ -777,6 +830,51 @@ class AgentClient extends BaseClient {
|
||||
};
|
||||
|
||||
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
|
||||
|
||||
if (
|
||||
this.options.agent.provider === EModelEndpoint.anthropic &&
|
||||
payload &&
|
||||
Array.isArray(payload)
|
||||
) {
|
||||
let userMessageWithDocs = null;
|
||||
|
||||
if (this.userMessage?.documents) {
|
||||
userMessageWithDocs = this.userMessage;
|
||||
} else if (this.currentMessages?.length > 0) {
|
||||
const lastMessage = this.currentMessages[this.currentMessages.length - 1];
|
||||
if (lastMessage.documents?.length > 0) {
|
||||
userMessageWithDocs = lastMessage;
|
||||
}
|
||||
} else if (this.messages?.length > 0) {
|
||||
const lastMessage = this.messages[this.messages.length - 1];
|
||||
if (lastMessage.documents?.length > 0) {
|
||||
userMessageWithDocs = lastMessage;
|
||||
}
|
||||
}
|
||||
|
||||
if (userMessageWithDocs) {
|
||||
for (const payloadMessage of payload) {
|
||||
if (
|
||||
payloadMessage.role === 'user' &&
|
||||
userMessageWithDocs.text === payloadMessage.content
|
||||
) {
|
||||
if (typeof payloadMessage.content === 'string') {
|
||||
payloadMessage.content = [
|
||||
...userMessageWithDocs.documents,
|
||||
{ type: 'text', text: payloadMessage.content },
|
||||
];
|
||||
} else if (Array.isArray(payloadMessage.content)) {
|
||||
payloadMessage.content = [
|
||||
...userMessageWithDocs.documents,
|
||||
...payloadMessage.content,
|
||||
];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
|
||||
payload,
|
||||
this.indexTokenCountMap,
|
||||
|
||||
@@ -8,14 +8,12 @@ const express = require('express');
|
||||
const passport = require('passport');
|
||||
const compression = require('compression');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const { isEnabled, ErrorController } = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
|
||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
||||
const errorController = require('./controllers/ErrorController');
|
||||
const initializeMCPs = require('./services/initializeMCPs');
|
||||
const configureSocialLogins = require('./socialLogins');
|
||||
const AppService = require('./services/AppService');
|
||||
@@ -120,8 +118,7 @@ const startServer = async () => {
|
||||
app.use('/api/tags', routes.tags);
|
||||
app.use('/api/mcp', routes.mcp);
|
||||
|
||||
// Add the error controller one more time after all routes
|
||||
app.use(errorController);
|
||||
app.use(ErrorController);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.set({
|
||||
|
||||
@@ -92,7 +92,7 @@ async function healthCheckPoll(app, retries = 0) {
|
||||
if (response.status === 200) {
|
||||
return; // App is healthy
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore connection errors during polling
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const validateModel = async (req, res, next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
const { ILLEGAL_MODEL_REQ_SCORE: score = 5 } = process.env ?? {};
|
||||
const { ILLEGAL_MODEL_REQ_SCORE: score = 1 } = process.env ?? {};
|
||||
|
||||
const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
|
||||
const errorMessage = {
|
||||
|
||||
@@ -13,6 +13,8 @@ const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const memoryPayloadLimit = express.json({ limit: '100kb' });
|
||||
|
||||
const checkMemoryRead = generateCheckAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permissions: [Permissions.USE, Permissions.READ],
|
||||
@@ -60,6 +62,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
|
||||
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const tokenLimit = memoryConfig?.tokenLimit;
|
||||
const charLimit = memoryConfig?.charLimit || 10000;
|
||||
|
||||
let usagePercentage = null;
|
||||
if (tokenLimit && tokenLimit > 0) {
|
||||
@@ -70,6 +73,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
|
||||
memories: sortedMemories,
|
||||
totalTokens,
|
||||
tokenLimit: tokenLimit || null,
|
||||
charLimit,
|
||||
usagePercentage,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -83,7 +87,7 @@ router.get('/', checkMemoryRead, async (req, res) => {
|
||||
* Body: { key: string, value: string }
|
||||
* Returns 201 and { created: true, memory: <createdDoc> } when successful.
|
||||
*/
|
||||
router.post('/', checkMemoryCreate, async (req, res) => {
|
||||
router.post('/', memoryPayloadLimit, checkMemoryCreate, async (req, res) => {
|
||||
const { key, value } = req.body;
|
||||
|
||||
if (typeof key !== 'string' || key.trim() === '') {
|
||||
@@ -94,13 +98,25 @@ router.post('/', checkMemoryCreate, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
|
||||
}
|
||||
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const charLimit = memoryConfig?.charLimit || 10000;
|
||||
|
||||
if (key.length > 1000) {
|
||||
return res.status(400).json({
|
||||
error: `Key exceeds maximum length of 1000 characters. Current length: ${key.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (value.length > charLimit) {
|
||||
return res.status(400).json({
|
||||
error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
||||
|
||||
const memories = await getAllUserMemories(req.user.id);
|
||||
|
||||
// Check token limit
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const tokenLimit = memoryConfig?.tokenLimit;
|
||||
|
||||
if (tokenLimit) {
|
||||
@@ -175,7 +191,7 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => {
|
||||
* Body: { key?: string, value: string }
|
||||
* Returns 200 and { updated: true, memory: <updatedDoc> } when successful.
|
||||
*/
|
||||
router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
router.patch('/:key', memoryPayloadLimit, checkMemoryUpdate, async (req, res) => {
|
||||
const { key: urlKey } = req.params;
|
||||
const { key: bodyKey, value } = req.body || {};
|
||||
|
||||
@@ -183,9 +199,23 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Value is required and must be a non-empty string.' });
|
||||
}
|
||||
|
||||
// Use the key from the body if provided, otherwise use the key from the URL
|
||||
const newKey = bodyKey || urlKey;
|
||||
|
||||
const memoryConfig = req.app.locals?.memory;
|
||||
const charLimit = memoryConfig?.charLimit || 10000;
|
||||
|
||||
if (newKey.length > 1000) {
|
||||
return res.status(400).json({
|
||||
error: `Key exceeds maximum length of 1000 characters. Current length: ${newKey.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (value.length > charLimit) {
|
||||
return res.status(400).json({
|
||||
error: `Value exceeds maximum length of ${charLimit} characters. Current length: ${value.length} characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base');
|
||||
|
||||
@@ -196,7 +226,6 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Memory not found.' });
|
||||
}
|
||||
|
||||
// If the key is changing, we need to handle it specially
|
||||
if (newKey !== urlKey) {
|
||||
const keyExists = memories.find((m) => m.key === newKey);
|
||||
if (keyExists) {
|
||||
@@ -219,7 +248,6 @@ router.patch('/:key', checkMemoryUpdate, async (req, res) => {
|
||||
return res.status(500).json({ error: 'Failed to delete old memory.' });
|
||||
}
|
||||
} else {
|
||||
// Key is not changing, just update the value
|
||||
const result = await setMemory({
|
||||
userId: req.user.id,
|
||||
key: newKey,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// 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 {
|
||||
checkBan,
|
||||
logHeaders,
|
||||
@@ -10,8 +13,6 @@ const {
|
||||
checkDomainAllowed,
|
||||
} = require('~/server/middleware');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -46,13 +47,13 @@ const oauthHandler = async (req, res) => {
|
||||
};
|
||||
|
||||
router.get('/error', (req, res) => {
|
||||
// A single error message is pushed by passport when authentication fails.
|
||||
/** A single error message is pushed by passport when authentication fails. */
|
||||
const errorMessage = req.session?.messages?.pop() || 'Unknown error';
|
||||
logger.error('Error in OAuth authentication:', {
|
||||
message: req.session?.messages?.pop() || 'Unknown error',
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
|
||||
res.redirect(`${domains.client}/login?redirect=false`);
|
||||
res.redirect(`${domains.client}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
const { agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
|
||||
const { loadMemoryConfig, agentsConfigSetup, loadWebSearchConfig } = require('@librechat/api');
|
||||
const {
|
||||
FileSources,
|
||||
loadOCRConfig,
|
||||
EModelEndpoint,
|
||||
loadMemoryConfig,
|
||||
getConfigDefaults,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { useAzurePlugins } = require('~/server/services/Config/EndpointService').config;
|
||||
const {
|
||||
getAnthropicModels,
|
||||
getBedrockModels,
|
||||
getOpenAIModels,
|
||||
getGoogleModels,
|
||||
getBedrockModels,
|
||||
getAnthropicModels,
|
||||
} = require('~/server/services/ModelService');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Loads the default models for the application.
|
||||
@@ -16,58 +15,42 @@ const { logger } = require('~/config');
|
||||
*/
|
||||
async function loadDefaultModels(req) {
|
||||
try {
|
||||
const [
|
||||
openAI,
|
||||
anthropic,
|
||||
azureOpenAI,
|
||||
gptPlugins,
|
||||
assistants,
|
||||
azureAssistants,
|
||||
google,
|
||||
bedrock,
|
||||
] = await Promise.all([
|
||||
getOpenAIModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI models:', error);
|
||||
return [];
|
||||
}),
|
||||
getAnthropicModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching Anthropic models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ user: req.user.id, azure: useAzurePlugins, plugins: true }).catch(
|
||||
(error) => {
|
||||
logger.error('Error fetching Plugin models:', error);
|
||||
const [openAI, anthropic, azureOpenAI, assistants, azureAssistants, google, bedrock] =
|
||||
await Promise.all([
|
||||
getOpenAIModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI models:', error);
|
||||
return [];
|
||||
},
|
||||
),
|
||||
getOpenAIModels({ assistants: true }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ azureAssistants: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getGoogleModels()).catch((error) => {
|
||||
logger.error('Error getting Google models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getBedrockModels()).catch((error) => {
|
||||
logger.error('Error getting Bedrock models:', error);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
getAnthropicModels({ user: req.user.id }).catch((error) => {
|
||||
logger.error('Error fetching Anthropic models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ assistants: true }).catch((error) => {
|
||||
logger.error('Error fetching OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
getOpenAIModels({ azureAssistants: true }).catch((error) => {
|
||||
logger.error('Error fetching Azure OpenAI Assistants API models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getGoogleModels()).catch((error) => {
|
||||
logger.error('Error getting Google models:', error);
|
||||
return [];
|
||||
}),
|
||||
Promise.resolve(getBedrockModels()).catch((error) => {
|
||||
logger.error('Error getting Bedrock models:', error);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
[EModelEndpoint.openAI]: openAI,
|
||||
[EModelEndpoint.agents]: openAI,
|
||||
[EModelEndpoint.google]: google,
|
||||
[EModelEndpoint.anthropic]: anthropic,
|
||||
[EModelEndpoint.gptPlugins]: gptPlugins,
|
||||
[EModelEndpoint.azureOpenAI]: azureOpenAI,
|
||||
[EModelEndpoint.assistants]: assistants,
|
||||
[EModelEndpoint.azureAssistants]: azureAssistants,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-data-provider');
|
||||
const { loadAgent } = require('~/models/Agent');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { validateAgentModel } = require('@librechat/api');
|
||||
const { createContentAggregator } = require('@librechat/agents');
|
||||
const {
|
||||
Constants,
|
||||
@@ -11,10 +12,12 @@ const {
|
||||
getDefaultHandlers,
|
||||
} = require('~/server/controllers/agents/callbacks');
|
||||
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
||||
const { getCustomEndpointConfig } = require('~/server/services/Config');
|
||||
const { loadAgentTools } = require('~/server/services/ToolService');
|
||||
const AgentClient = require('~/server/controllers/agents/client');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
const { logViolation } = require('~/cache');
|
||||
|
||||
function createToolLoader() {
|
||||
/**
|
||||
@@ -72,6 +75,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
|
||||
throw new Error('Agent not found');
|
||||
}
|
||||
|
||||
const modelsConfig = await getModelsConfig(req);
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
agent: primaryAgent,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
}
|
||||
|
||||
const agentConfigs = new Map();
|
||||
/** @type {Set<string>} */
|
||||
const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders);
|
||||
@@ -101,6 +117,19 @@ const initializeClient = async ({ req, res, endpointOption }) => {
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
}
|
||||
|
||||
const config = await initializeAgent({
|
||||
req,
|
||||
res,
|
||||
|
||||
166
api/server/services/Files/documents/encode.js
Normal file
166
api/server/services/Files/documents/encode.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { validateAnthropicPdf } = require('../validation/pdfValidator');
|
||||
|
||||
/**
|
||||
* Converts a readable stream to a buffer.
|
||||
*
|
||||
* @param {NodeJS.ReadableStream} stream - The readable stream to convert.
|
||||
* @returns {Promise<Buffer>} - Promise resolving to the buffer.
|
||||
*/
|
||||
async function streamToBuffer(stream) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
chunks.length = 0; // Clear the array
|
||||
resolve(buffer);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (error) => {
|
||||
chunks.length = 0;
|
||||
reject(error);
|
||||
});
|
||||
}).finally(() => {
|
||||
// Clean up the stream if required
|
||||
if (stream.destroy && typeof stream.destroy === 'function') {
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and encodes document files for various endpoints
|
||||
*
|
||||
* @param {Express.Request} req - Express request object
|
||||
* @param {MongoFile[]} files - Array of file objects to process
|
||||
* @param {string} endpoint - The endpoint identifier (e.g., EModelEndpoint.anthropic)
|
||||
* @returns {Promise<{documents: MessageContentDocument[], files: MongoFile[]}>}
|
||||
*/
|
||||
async function encodeAndFormatDocuments(req, files, endpoint) {
|
||||
const promises = [];
|
||||
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareDocumentPayload' | 'getDownloadStream'>>} */
|
||||
const encodingMethods = {};
|
||||
/** @type {{ documents: MessageContentDocument[]; files: MongoFile[] }} */
|
||||
const result = {
|
||||
documents: [],
|
||||
files: [],
|
||||
};
|
||||
|
||||
if (!files || !files.length) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Filter for document files only
|
||||
const documentFiles = files.filter(
|
||||
(file) => file.type === 'application/pdf' || file.type?.startsWith('application/'), // Future: support for other document types
|
||||
);
|
||||
|
||||
if (!documentFiles.length) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (let file of documentFiles) {
|
||||
/** @type {FileSources} */
|
||||
const source = file.source ?? 'local';
|
||||
|
||||
// Only process PDFs for Anthropic for now
|
||||
if (file.type !== 'application/pdf' || endpoint !== EModelEndpoint.anthropic) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!encodingMethods[source]) {
|
||||
encodingMethods[source] = getStrategyFunctions(source);
|
||||
}
|
||||
|
||||
// Prepare file metadata
|
||||
const fileMetadata = {
|
||||
file_id: file.file_id || file._id,
|
||||
temp_file_id: file.temp_file_id,
|
||||
filepath: file.filepath,
|
||||
source: file.source,
|
||||
filename: file.filename,
|
||||
type: file.type,
|
||||
};
|
||||
|
||||
promises.push([file, fileMetadata]);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
promises.map(async ([file, fileMetadata]) => {
|
||||
if (!file || !fileMetadata) {
|
||||
return { file: null, content: null, metadata: fileMetadata };
|
||||
}
|
||||
|
||||
try {
|
||||
const source = file.source ?? 'local';
|
||||
const { getDownloadStream } = encodingMethods[source];
|
||||
|
||||
const stream = await getDownloadStream(req, file.filepath);
|
||||
const buffer = await streamToBuffer(stream);
|
||||
const documentContent = buffer.toString('base64');
|
||||
|
||||
return {
|
||||
file,
|
||||
content: documentContent,
|
||||
metadata: fileMetadata,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error processing document ${file.filename}:`, error);
|
||||
return { file, content: null, metadata: fileMetadata };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const settledResult of results) {
|
||||
if (settledResult.status === 'rejected') {
|
||||
console.error('Document processing failed:', settledResult.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { file, content, metadata } = settledResult.value;
|
||||
|
||||
if (!content || !file) {
|
||||
if (metadata) {
|
||||
result.files.push(metadata);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.type === 'application/pdf' && endpoint === EModelEndpoint.anthropic) {
|
||||
const pdfBuffer = Buffer.from(content, 'base64');
|
||||
const validation = await validateAnthropicPdf(pdfBuffer, pdfBuffer.length);
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`PDF validation failed: ${validation.error}`);
|
||||
}
|
||||
|
||||
const documentPart = {
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: content,
|
||||
},
|
||||
};
|
||||
|
||||
result.documents.push(documentPart);
|
||||
result.files.push(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encodeAndFormatDocuments,
|
||||
};
|
||||
5
api/server/services/Files/documents/index.js
Normal file
5
api/server/services/Files/documents/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { encodeAndFormatDocuments } = require('./encode');
|
||||
|
||||
module.exports = {
|
||||
encodeAndFormatDocuments,
|
||||
};
|
||||
@@ -391,7 +391,17 @@ const processFileUpload = async ({ req, res, metadata }) => {
|
||||
const isAssistantUpload = isAssistantsEndpoint(metadata.endpoint);
|
||||
const assistantSource =
|
||||
metadata.endpoint === EModelEndpoint.azureAssistants ? FileSources.azure : FileSources.openai;
|
||||
const source = isAssistantUpload ? assistantSource : FileSources.vectordb;
|
||||
|
||||
// Use local storage for Anthropic native PDF support, vectordb for others
|
||||
const isAnthropicUpload = metadata.endpoint === EModelEndpoint.anthropic;
|
||||
let source;
|
||||
if (isAssistantUpload) {
|
||||
source = assistantSource;
|
||||
} else if (isAnthropicUpload) {
|
||||
source = FileSources.local;
|
||||
} else {
|
||||
source = FileSources.vectordb;
|
||||
}
|
||||
const { handleFileUpload } = getStrategyFunctions(source);
|
||||
const { file_id, temp_file_id } = metadata;
|
||||
|
||||
|
||||
77
api/server/services/Files/validation/pdfValidator.js
Normal file
77
api/server/services/Files/validation/pdfValidator.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const { logger } = require('~/config');
|
||||
const { anthropicPdfSizeLimit } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* Validates if a PDF meets Anthropic's requirements
|
||||
* @param {Buffer} pdfBuffer - The PDF file as a buffer
|
||||
* @param {number} fileSize - The file size in bytes
|
||||
* @returns {Promise<{isValid: boolean, error?: string}>}
|
||||
*/
|
||||
async function validateAnthropicPdf(pdfBuffer, fileSize) {
|
||||
try {
|
||||
// Check file size (32MB limit)
|
||||
if (fileSize > anthropicPdfSizeLimit) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `PDF file size (${Math.round(fileSize / (1024 * 1024))}MB) exceeds Anthropic's 32MB limit`,
|
||||
};
|
||||
}
|
||||
|
||||
// Basic PDF header validation
|
||||
if (!pdfBuffer || pdfBuffer.length < 5) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid PDF file: too small or corrupted',
|
||||
};
|
||||
}
|
||||
|
||||
// Check PDF magic bytes
|
||||
const pdfHeader = pdfBuffer.subarray(0, 5).toString();
|
||||
if (!pdfHeader.startsWith('%PDF-')) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid PDF file: missing PDF header',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for password protection/encryption
|
||||
const pdfContent = pdfBuffer.toString('binary');
|
||||
if (
|
||||
pdfContent.includes('/Encrypt ') ||
|
||||
pdfContent.includes('/U (') ||
|
||||
pdfContent.includes('/O (')
|
||||
) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'PDF is password-protected or encrypted. Anthropic requires unencrypted PDFs.',
|
||||
};
|
||||
}
|
||||
|
||||
// Estimate page count (this is a rough estimation)
|
||||
const pageMatches = pdfContent.match(/\/Type[\s]*\/Page[^s]/g);
|
||||
const estimatedPages = pageMatches ? pageMatches.length : 1;
|
||||
|
||||
if (estimatedPages > 100) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `PDF has approximately ${estimatedPages} pages, exceeding Anthropic's 100-page limit`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`PDF validation passed: ${Math.round(fileSize / 1024)}KB, ~${estimatedPages} pages`,
|
||||
);
|
||||
|
||||
return { isValid: true };
|
||||
} catch (error) {
|
||||
logger.error('PDF validation error:', error);
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Failed to validate PDF file',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateAnthropicPdf,
|
||||
};
|
||||
@@ -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 theend of the instructions for the run. This is useful for modifying
|
||||
* at the end 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.
|
||||
|
||||
@@ -2,11 +2,11 @@ const {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
isMemoryEnabled,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isMemoryEnabled } = require('@librechat/api');
|
||||
const { updateAccessPermissions } = require('~/models/Role');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Loads the default interface object.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const LdapStrategy = require('passport-ldapauth');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
|
||||
const { createUser, findUser, updateUser, countUsers } = require('~/models');
|
||||
const { getBalanceConfig } = require('~/server/services/Config');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
|
||||
const {
|
||||
LDAP_URL,
|
||||
@@ -90,6 +90,14 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
|
||||
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
|
||||
|
||||
let user = await findUser({ ldapId });
|
||||
if (user && user.provider !== 'ldap') {
|
||||
logger.info(
|
||||
`[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
|
||||
const fullName =
|
||||
|
||||
@@ -3,9 +3,9 @@ const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const client = require('openid-client');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, ErrorTypes } = require('librechat-data-provider');
|
||||
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
|
||||
const { isEnabled, safeStringify, logHeaders } = require('@librechat/api');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
@@ -320,6 +320,14 @@ async function setupOpenId() {
|
||||
} for openidId: ${claims.sub}`,
|
||||
);
|
||||
}
|
||||
if (user != null && user.provider !== 'openid') {
|
||||
logger.info(
|
||||
`[openidStrategy] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
const userinfo = {
|
||||
...claims,
|
||||
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const fetch = require('node-fetch');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { setupOpenId } = require('./openidStrategy');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { findUser, createUser, updateUser } = require('~/models');
|
||||
const { setupOpenId } = require('./openidStrategy');
|
||||
|
||||
// --- Mocks ---
|
||||
jest.mock('node-fetch');
|
||||
@@ -50,7 +51,7 @@ jest.mock('openid-client', () => {
|
||||
issuer: 'https://fake-issuer.com',
|
||||
// Add any other properties needed by the implementation
|
||||
}),
|
||||
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
|
||||
fetchUserInfo: jest.fn().mockImplementation(() => {
|
||||
// Only return additional properties, but don't override any claims
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
@@ -261,17 +262,20 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should update an existing user on login', async () => {
|
||||
// Arrange – simulate that a user already exists
|
||||
// Arrange – simulate that a user already exists with openid provider
|
||||
const existingUser = {
|
||||
_id: 'existingUserId',
|
||||
provider: 'local',
|
||||
provider: 'openid',
|
||||
email: tokenset.claims().email,
|
||||
openidId: '',
|
||||
username: '',
|
||||
name: '',
|
||||
};
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||
if (
|
||||
query.openidId === tokenset.claims().sub ||
|
||||
(query.email === tokenset.claims().email && query.provider === 'openid')
|
||||
) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
@@ -294,12 +298,38 @@ describe('setupOpenId', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should block login when email exists with different provider', async () => {
|
||||
// Arrange – simulate that a user exists with same email but different provider
|
||||
const existingUser = {
|
||||
_id: 'existingUserId',
|
||||
provider: 'google',
|
||||
email: tokenset.claims().email,
|
||||
googleId: 'some-google-id',
|
||||
username: 'existinguser',
|
||||
name: 'Existing User',
|
||||
};
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.email === tokenset.claims().email && !query.provider) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await validate(tokenset);
|
||||
|
||||
// Assert – verify that the strategy rejects login
|
||||
expect(result.user).toBe(false);
|
||||
expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED);
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enforce the required role and reject login if missing', async () => {
|
||||
// Arrange – simulate a token without the required role.
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['SomeOtherRole'],
|
||||
});
|
||||
const userinfo = tokenset.claims();
|
||||
|
||||
// Act
|
||||
const { user, details } = await validate(tokenset);
|
||||
@@ -310,9 +340,6 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should attempt to download and save the avatar if picture is provided', async () => {
|
||||
// Arrange – ensure userinfo contains a picture URL
|
||||
const userinfo = tokenset.claims();
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
|
||||
@@ -22,9 +22,12 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
|
||||
const isLocal = fileStrategy === FileSources.local;
|
||||
|
||||
let updatedAvatar = false;
|
||||
if (isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
|
||||
const hasManualFlag =
|
||||
typeof oldUser?.avatar === 'string' && oldUser.avatar.includes('?manual=true');
|
||||
|
||||
if (isLocal && (!oldUser?.avatar || !hasManualFlag)) {
|
||||
updatedAvatar = avatarUrl;
|
||||
} else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) {
|
||||
} else if (!isLocal && (!oldUser?.avatar || !hasManualFlag)) {
|
||||
const userId = oldUser._id;
|
||||
const resizedBuffer = await resizeAvatar({
|
||||
userId,
|
||||
|
||||
164
api/strategies/process.test.js
Normal file
164
api/strategies/process.test.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const { FileSources } = require('librechat-data-provider');
|
||||
const { handleExistingUser } = require('./process');
|
||||
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/images/avatar', () => ({
|
||||
resizeAvatar: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
updateUser: jest.fn(),
|
||||
createUser: jest.fn(),
|
||||
getUserById: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getBalanceConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
const { updateUser } = require('~/models');
|
||||
|
||||
describe('handleExistingUser', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.CDN_PROVIDER = FileSources.local;
|
||||
});
|
||||
|
||||
it('should handle null avatar without throwing error', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: null,
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle undefined avatar without throwing error', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
// avatar is undefined
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should not update avatar if it has manual=true flag', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/avatar.png?manual=true',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update avatar for local storage when avatar has no manual flag', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/old-avatar.png',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should process avatar for non-local storage', async () => {
|
||||
process.env.CDN_PROVIDER = 's3';
|
||||
|
||||
const mockProcessAvatar = jest.fn().mockResolvedValue('processed-avatar-url');
|
||||
getStrategyFunctions.mockReturnValue({ processAvatar: mockProcessAvatar });
|
||||
resizeAvatar.mockResolvedValue(Buffer.from('resized-image'));
|
||||
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: null,
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(resizeAvatar).toHaveBeenCalledWith({
|
||||
userId: 'user123',
|
||||
input: avatarUrl,
|
||||
});
|
||||
expect(mockProcessAvatar).toHaveBeenCalledWith({
|
||||
buffer: Buffer.from('resized-image'),
|
||||
userId: 'user123',
|
||||
manual: 'false',
|
||||
});
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: 'processed-avatar-url' });
|
||||
});
|
||||
|
||||
it('should not update if avatar already has manual flag in non-local storage', async () => {
|
||||
process.env.CDN_PROVIDER = 's3';
|
||||
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://cdn.example.com/avatar.png?manual=true',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(resizeAvatar).not.toHaveBeenCalled();
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle avatar with query parameters but without manual flag', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/avatar.png?size=large&format=webp',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle empty string avatar', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: '',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle avatar with manual=false parameter', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
avatar: 'https://example.com/avatar.png?manual=false',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { avatar: avatarUrl });
|
||||
});
|
||||
|
||||
it('should handle oldUser being null gracefully', async () => {
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
// This should throw an error when trying to access oldUser._id
|
||||
await expect(handleExistingUser(null, avatarUrl)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
@@ -203,6 +204,15 @@ async function setupSaml() {
|
||||
);
|
||||
}
|
||||
|
||||
if (user && user.provider !== 'saml') {
|
||||
logger.info(
|
||||
`[samlStrategy] User ${user.email} already exists with provider ${user.provider}`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const fullName = getFullName(profile);
|
||||
|
||||
const username = convertToUsername(
|
||||
|
||||
@@ -378,11 +378,11 @@ u7wlOSk+oFzDIO/UILIA
|
||||
});
|
||||
|
||||
it('should update an existing user on login', async () => {
|
||||
// Set up findUser to return an existing user
|
||||
// Set up findUser to return an existing user with saml provider
|
||||
const { findUser } = require('~/models');
|
||||
const existingUser = {
|
||||
_id: 'existing-user-id',
|
||||
provider: 'local',
|
||||
provider: 'saml',
|
||||
email: baseProfile.email,
|
||||
samlId: '',
|
||||
username: 'oldusername',
|
||||
@@ -400,6 +400,26 @@ u7wlOSk+oFzDIO/UILIA
|
||||
expect(user.email).toBe(baseProfile.email);
|
||||
});
|
||||
|
||||
it('should block login when email exists with different provider', async () => {
|
||||
// Set up findUser to return a user with different provider
|
||||
const { findUser } = require('~/models');
|
||||
const existingUser = {
|
||||
_id: 'existing-user-id',
|
||||
provider: 'google',
|
||||
email: baseProfile.email,
|
||||
googleId: 'some-google-id',
|
||||
username: 'existinguser',
|
||||
name: 'Existing User',
|
||||
};
|
||||
findUser.mockResolvedValue(existingUser);
|
||||
|
||||
const profile = { ...baseProfile };
|
||||
const result = await validate(profile);
|
||||
|
||||
expect(result.user).toBe(false);
|
||||
expect(result.details.message).toBe(require('librechat-data-provider').ErrorTypes.AUTH_FAILED);
|
||||
});
|
||||
|
||||
it('should attempt to download and save the avatar if picture is provided', async () => {
|
||||
const profile = { ...baseProfile };
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { createSocialUser, handleExistingUser } = require('./process');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { findUser } = require('~/models');
|
||||
|
||||
const socialLogin =
|
||||
@@ -11,12 +12,20 @@ const socialLogin =
|
||||
profile,
|
||||
});
|
||||
|
||||
const oldUser = await findUser({ email: email.trim() });
|
||||
const existingUser = await findUser({ email: email.trim() });
|
||||
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
|
||||
|
||||
if (oldUser) {
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
return cb(null, oldUser);
|
||||
if (existingUser?.provider === provider) {
|
||||
await handleExistingUser(existingUser, avatarUrl);
|
||||
return cb(null, existingUser);
|
||||
} else if (existingUser) {
|
||||
logger.info(
|
||||
`[${provider}Login] User ${email} already exists with provider ${existingUser.provider}`,
|
||||
);
|
||||
const error = new Error(ErrorTypes.AUTH_FAILED);
|
||||
error.code = ErrorTypes.AUTH_FAILED;
|
||||
error.provider = existingUser.provider;
|
||||
return cb(error);
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
|
||||
@@ -1370,7 +1370,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 theend of the instructions for the run. This is useful for modifying
|
||||
* at the end 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { OpenIDIcon } from '@librechat/client';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import { OpenIDIcon, useToastContext } from '@librechat/client';
|
||||
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
|
||||
import SocialButton from '~/components/Auth/SocialButton';
|
||||
@@ -11,6 +12,7 @@ import LoginForm from './LoginForm';
|
||||
|
||||
function Login() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { error, setError, login } = useAuthContext();
|
||||
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
@@ -21,6 +23,19 @@ function Login() {
|
||||
// Persist the disable flag locally so that once detected, auto-redirect stays disabled.
|
||||
const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect);
|
||||
|
||||
useEffect(() => {
|
||||
const oauthError = searchParams?.get('error');
|
||||
if (oauthError && oauthError === ErrorTypes.AUTH_FAILED) {
|
||||
showToast({
|
||||
message: localize('com_auth_error_oauth_failed'),
|
||||
status: 'error',
|
||||
});
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('error');
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}, [searchParams, setSearchParams, showToast, localize]);
|
||||
|
||||
// Once the disable flag is detected, update local state and remove the parameter from the URL.
|
||||
useEffect(() => {
|
||||
if (disableAutoRedirect) {
|
||||
|
||||
@@ -36,6 +36,7 @@ function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
|
||||
disabled={disableInputs}
|
||||
conversationId={conversationId}
|
||||
endpointFileConfig={endpointFileConfig}
|
||||
endpoint={endpoint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon, FileText } from 'lucide-react';
|
||||
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '@librechat/client';
|
||||
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
|
||||
import type { EndpointFileConfig } from 'librechat-data-provider';
|
||||
@@ -13,9 +13,15 @@ interface AttachFileMenuProps {
|
||||
conversationId: string;
|
||||
disabled?: boolean | null;
|
||||
endpointFileConfig?: EndpointFileConfig;
|
||||
endpoint?: string | null;
|
||||
}
|
||||
|
||||
const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => {
|
||||
const AttachFileMenu = ({
|
||||
disabled,
|
||||
conversationId,
|
||||
endpointFileConfig,
|
||||
endpoint,
|
||||
}: AttachFileMenuProps) => {
|
||||
const localize = useLocalize();
|
||||
const isUploadDisabled = disabled ?? false;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -23,7 +29,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
|
||||
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
overrideEndpoint: endpoint === EModelEndpoint.anthropic ? undefined : EModelEndpoint.agents,
|
||||
overrideEndpointFileConfig: endpointFileConfig,
|
||||
});
|
||||
|
||||
@@ -34,12 +40,18 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
|
||||
* */
|
||||
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
|
||||
|
||||
const handleUploadClick = (isImage?: boolean) => {
|
||||
const handleUploadClick = (fileType?: 'image' | 'document') => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
inputRef.current.value = '';
|
||||
inputRef.current.accept = isImage === true ? 'image/*' : '';
|
||||
if (fileType === 'image') {
|
||||
inputRef.current.accept = 'image/*';
|
||||
} else if (fileType === 'document') {
|
||||
inputRef.current.accept = '.pdf,application/pdf';
|
||||
} else {
|
||||
inputRef.current.accept = '';
|
||||
}
|
||||
inputRef.current.click();
|
||||
inputRef.current.accept = '';
|
||||
};
|
||||
@@ -50,12 +62,24 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
|
||||
label: localize('com_ui_upload_image_input'),
|
||||
onClick: () => {
|
||||
setToolResource(undefined);
|
||||
handleUploadClick(true);
|
||||
handleUploadClick('image');
|
||||
},
|
||||
icon: <ImageUpIcon className="icon-md" />,
|
||||
},
|
||||
];
|
||||
|
||||
// Add document upload option for Anthropic endpoints
|
||||
if (endpoint === EModelEndpoint.anthropic) {
|
||||
items.push({
|
||||
label: 'Upload to Provider',
|
||||
onClick: () => {
|
||||
setToolResource(undefined);
|
||||
handleUploadClick('document');
|
||||
},
|
||||
icon: <FileText className="icon-md" />,
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities.ocrEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_ocr_text'),
|
||||
@@ -95,7 +119,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [capabilities, localize, setToolResource, setEphemeralAgent]);
|
||||
}, [capabilities, localize, setToolResource, setEphemeralAgent, endpoint]);
|
||||
|
||||
const menuTrigger = (
|
||||
<TooltipAnchor
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets
|
||||
import { ViolationTypes, ErrorTypes, alternateName } from 'librechat-data-provider';
|
||||
import type { TOpenAIMessage } from 'librechat-data-provider';
|
||||
import type { LocalizeFunction } from '~/common';
|
||||
import { formatJSON, extractJson, isJson } from '~/utils/json';
|
||||
import { useLocalize } from '~/hooks';
|
||||
@@ -25,7 +24,7 @@ type TTokenBalance = {
|
||||
prev_count: number;
|
||||
violation_count: number;
|
||||
date: Date;
|
||||
generations?: TOpenAIMessage[];
|
||||
generations?: unknown[];
|
||||
};
|
||||
|
||||
type TExpiredKey = {
|
||||
@@ -44,6 +43,17 @@ const errorMessages = {
|
||||
[ErrorTypes.NO_BASE_URL]: 'com_error_no_base_url',
|
||||
[ErrorTypes.INVALID_ACTION]: `com_error_${ErrorTypes.INVALID_ACTION}`,
|
||||
[ErrorTypes.INVALID_REQUEST]: `com_error_${ErrorTypes.INVALID_REQUEST}`,
|
||||
[ErrorTypes.MISSING_MODEL]: (json: TGenericError, localize: LocalizeFunction) => {
|
||||
const { info: endpoint } = json;
|
||||
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
||||
return localize('com_error_missing_model', { 0: provider });
|
||||
},
|
||||
[ErrorTypes.MODELS_NOT_LOADED]: 'com_error_models_not_loaded',
|
||||
[ErrorTypes.ENDPOINT_MODELS_NOT_LOADED]: (json: TGenericError, localize: LocalizeFunction) => {
|
||||
const { info: endpoint } = json;
|
||||
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
||||
return localize('com_error_endpoint_models_not_loaded', { 0: provider });
|
||||
},
|
||||
[ErrorTypes.NO_SYSTEM_MESSAGES]: `com_error_${ErrorTypes.NO_SYSTEM_MESSAGES}`,
|
||||
[ErrorTypes.EXPIRED_USER_KEY]: (json: TExpiredKey, localize: LocalizeFunction) => {
|
||||
const { expiredAt, endpoint } = json;
|
||||
@@ -65,6 +75,12 @@ const errorMessages = {
|
||||
[ErrorTypes.GOOGLE_TOOL_CONFLICT]: 'com_error_google_tool_conflict',
|
||||
[ViolationTypes.BAN]:
|
||||
'Your account has been temporarily banned due to violations of our service.',
|
||||
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: (json: TGenericError, localize: LocalizeFunction) => {
|
||||
const { info } = json;
|
||||
const [endpoint, model = 'unknown'] = info?.split('|') ?? [];
|
||||
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
||||
return localize('com_error_illegal_model_request', { 0: model, 1: provider });
|
||||
},
|
||||
invalid_api_key:
|
||||
'Invalid API key. Please check your API key and try again. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.',
|
||||
insufficient_quota:
|
||||
|
||||
@@ -7,7 +7,7 @@ import BackupCodesItem from './BackupCodesItem';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
|
||||
function Account() {
|
||||
const user = useAuthContext();
|
||||
const { user } = useAuthContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
@@ -17,12 +17,12 @@ function Account() {
|
||||
<div className="pb-3">
|
||||
<Avatar />
|
||||
</div>
|
||||
{user?.user?.provider === 'local' && (
|
||||
{user?.provider === 'local' && (
|
||||
<>
|
||||
<div className="pb-3">
|
||||
<EnableTwoFactorItem />
|
||||
</div>
|
||||
{user?.user?.twoFactorEnabled && (
|
||||
{user?.twoFactorEnabled && (
|
||||
<div className="pb-3">
|
||||
<BackupCodesItem />
|
||||
</div>
|
||||
|
||||
@@ -39,8 +39,8 @@ const TwoFactorAuthentication: React.FC = () => {
|
||||
const [secret, setSecret] = useState<string>('');
|
||||
const [otpauthUrl, setOtpauthUrl] = useState<string>('');
|
||||
const [downloaded, setDownloaded] = useState<boolean>(false);
|
||||
const [disableToken, setDisableToken] = useState<string>('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [_disableToken, setDisableToken] = useState<string>('');
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [verificationToken, setVerificationToken] = useState<string>('');
|
||||
const [phase, setPhase] = useState<Phase>(user?.twoFactorEnabled ? 'disable' : 'setup');
|
||||
@@ -166,32 +166,26 @@ const TwoFactorAuthentication: React.FC = () => {
|
||||
payload.token = token.trim();
|
||||
}
|
||||
|
||||
verify2FAMutate(payload, {
|
||||
disable2FAMutate(payload, {
|
||||
onSuccess: () => {
|
||||
disable2FAMutate(undefined, {
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_2fa_disabled') });
|
||||
setDialogOpen(false);
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
totpSecret: '',
|
||||
backupCodes: [],
|
||||
twoFactorEnabled: false,
|
||||
}) as TUser,
|
||||
);
|
||||
setPhase('setup');
|
||||
setOtpauthUrl('');
|
||||
},
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
|
||||
});
|
||||
showToast({ message: localize('com_ui_2fa_disabled') });
|
||||
setDialogOpen(false);
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
totpSecret: '',
|
||||
backupCodes: [],
|
||||
twoFactorEnabled: false,
|
||||
}) as TUser,
|
||||
);
|
||||
setPhase('setup');
|
||||
setOtpauthUrl('');
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
});
|
||||
},
|
||||
[verify2FAMutate, disable2FAMutate, showToast, localize, setUser],
|
||||
[disable2FAMutate, showToast, localize, setUser],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -134,12 +134,12 @@ export const useConfirmTwoFactorMutation = (): UseMutationResult<
|
||||
export const useDisableTwoFactorMutation = (): UseMutationResult<
|
||||
t.TDisable2FAResponse,
|
||||
unknown,
|
||||
void,
|
||||
t.TDisable2FARequest | undefined,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(() => dataService.disableTwoFactor(), {
|
||||
onSuccess: (data) => {
|
||||
return useMutation((payload?: t.TDisable2FARequest) => dataService.disableTwoFactor(payload), {
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa'], null);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"com_ui_copy_to_clipboard": "Copia al porta-retalls",
|
||||
"com_ui_create": "Crea",
|
||||
"com_ui_create_link": "Crea enllaç",
|
||||
"com_ui_create_memory": "Crear memòria",
|
||||
"com_ui_create_prompt": "Crea prompt",
|
||||
"com_ui_currently_production": "Actualment en producció",
|
||||
"com_ui_custom": "Personalitzat",
|
||||
@@ -622,6 +623,7 @@
|
||||
"com_ui_delete_confirm": "Això eliminarà",
|
||||
"com_ui_delete_confirm_prompt_version_var": "Això eliminarà la versió seleccionada per a \"{{0}}.\" Si no hi ha altres versions, s'eliminarà el prompt.",
|
||||
"com_ui_delete_conversation": "Vols eliminar el xat?",
|
||||
"com_ui_delete_memory": "Esborrar memòria",
|
||||
"com_ui_delete_prompt": "Vols eliminar el prompt?",
|
||||
"com_ui_delete_shared_link": "Vols eliminar l'enllaç compartit?",
|
||||
"com_ui_delete_tool": "Elimina eina",
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"com_auth_error_login_rl": "Too many login attempts in a short amount of time. Please try again later.",
|
||||
"com_auth_error_login_server": "There was an internal server error. Please wait a few moments and try again.",
|
||||
"com_auth_error_login_unverified": "Your account has not been verified. Please check your email for a verification link.",
|
||||
"com_auth_error_oauth_failed": "Authentication failed. Please check your login method and try again.",
|
||||
"com_auth_facebook_login": "Continue with Facebook",
|
||||
"com_auth_full_name": "Full name",
|
||||
"com_auth_github_login": "Continue with Github",
|
||||
@@ -288,6 +289,7 @@
|
||||
"com_endpoint_use_responses_api": "Use Responses API",
|
||||
"com_endpoint_use_search_grounding": "Grounding with Google Search",
|
||||
"com_endpoint_verbosity": "Verbosity",
|
||||
"com_error_endpoint_models_not_loaded": "Models for {{0}} could not be loaded. Please refresh the page and try again.",
|
||||
"com_error_expired_user_key": "Provided key for {{0}} expired at {{1}}. Please provide a new key and try again.",
|
||||
"com_error_files_dupe": "Duplicate file detected.",
|
||||
"com_error_files_empty": "Empty files are not allowed.",
|
||||
@@ -298,9 +300,12 @@
|
||||
"com_error_files_validation": "An error occurred while validating the file.",
|
||||
"com_error_google_tool_conflict": "Usage of built-in Google tools are not supported with external tools. Please disable either the built-in tools or the external tools.",
|
||||
"com_error_heic_conversion": "Failed to convert HEIC image to JPEG. Please try converting the image manually or use a different format.",
|
||||
"com_error_illegal_model_request": "The model \"{{0}}\" is not available for {{1}}. Please select a different model.",
|
||||
"com_error_input_length": "The latest message token count is too long, exceeding the token limit, or your token limit parameters are misconfigured, adversely affecting the context window. More info: {{0}}. Please shorten your message, adjust the max context size from the conversation parameters, or fork the conversation to continue.",
|
||||
"com_error_invalid_agent_provider": "The \"{{0}}\" provider is not available for use with Agents. Please go to your agent's settings and select a currently available provider.",
|
||||
"com_error_invalid_user_key": "Invalid key provided. Please provide a valid key and try again.",
|
||||
"com_error_missing_model": "No model selected for {{0}}. Please select a model and try again.",
|
||||
"com_error_models_not_loaded": "Models configuration could not be loaded. Please refresh the page and try again.",
|
||||
"com_error_moderation": "It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We're unable to proceed with this specific topic. If you have any other questions or topics you'd like to explore, please edit your message, or create a new conversation.",
|
||||
"com_error_no_base_url": "No base URL found. Please provide one and try again.",
|
||||
"com_error_no_user_key": "No key found. Please provide a key and try again.",
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
"com_auth_submit_registration": "Lähetä rekisteröityminen",
|
||||
"com_auth_to_reset_your_password": "asettaaksesi uuden salasanan.",
|
||||
"com_auth_to_try_again": "kokeillaksesi uudestaan.",
|
||||
"com_auth_two_factor": "Katso kertakäyttöinen koodi tunnistautumissovelluksestasi",
|
||||
"com_auth_username": "Käyttäjänimi (valinnainen)",
|
||||
"com_auth_username_max_length": "Käyttäjänimi voi olla enintään 20 merkkiä pitkä",
|
||||
"com_auth_username_min_length": "Käyttäjänimessä on oltava vähintään 2 merkkiä",
|
||||
|
||||
@@ -572,6 +572,7 @@
|
||||
"com_ui_archive_error": "échec de l'archivage de la conversation",
|
||||
"com_ui_artifact_click": "Cliquer pour ouvrir",
|
||||
"com_ui_artifacts": "Artefacts",
|
||||
"com_ui_artifacts_options": "Options des Artefacts",
|
||||
"com_ui_artifacts_toggle": "Afficher/Masquer l'interface des artefacts",
|
||||
"com_ui_artifacts_toggle_agent": "Activer Artifacts",
|
||||
"com_ui_ascending": "Croissant",
|
||||
@@ -771,6 +772,7 @@
|
||||
"com_ui_fork_change_default": "Option de fourche par défaut",
|
||||
"com_ui_fork_default": "Utiliser l'option de fourche par défaut",
|
||||
"com_ui_fork_error": "Une erreur s'est produite lors du dédoublement de la conversation",
|
||||
"com_ui_fork_error_rate_limit": "Trop de demandes de duplications. Merci de réessayer plus tard.",
|
||||
"com_ui_fork_from_message": "Sélectionner une option de bifurcation",
|
||||
"com_ui_fork_info_1": "Utilisez ce paramètre pour créer une bifurcation des messages avec le comportement souhaité.",
|
||||
"com_ui_fork_info_2": "\"Forker\" fait référence à la création d'une nouvelle conversation qui commence/se termine à partir de messages spécifiques dans la conversation actuelle, en créant une copie selon les options sélectionnées.",
|
||||
@@ -1064,6 +1066,7 @@
|
||||
"com_ui_web_search_scraper": "Extracteur (scraper)",
|
||||
"com_ui_web_search_scraper_firecrawl": "API de Firecrawl",
|
||||
"com_ui_web_search_scraper_firecrawl_key": "Obtenez votre clé API pour Firecrawl",
|
||||
"com_ui_web_search_searxng_api_key": "Entrez la clé d'API de SearXNG (facultatif)",
|
||||
"com_ui_web_search_searxng_instance_url": "Adresse URL de l'instance SearXNG",
|
||||
"com_ui_web_searching": "Rechercher sur le web",
|
||||
"com_ui_web_searching_again": "Rechercher à nouveau sur le web",
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"com_endpoint_top_k": "Top K",
|
||||
"com_endpoint_top_p": "Top P",
|
||||
"com_endpoint_use_active_assistant": "השתמש ב-סייען פעיל",
|
||||
"com_endpoint_use_responses_api": "השתמש ב-API של תגובות",
|
||||
"com_endpoint_use_search_grounding": "התבססות על חיפוש גוגל",
|
||||
"com_error_expired_user_key": "המפתח שסופק עבור {{0}} פג ב-{{1}}. אנא ספק מפתח חדש ונסה שוב.",
|
||||
"com_error_files_dupe": "זוהה קובץ כפול",
|
||||
|
||||
@@ -328,6 +328,7 @@
|
||||
"com_nav_auto_transcribe_audio": "オーディオを自動で書き起こす",
|
||||
"com_nav_automatic_playback": "最新メッセージを自動再生",
|
||||
"com_nav_balance": "バランス",
|
||||
"com_nav_balance_auto_refill_disabled": "自動補充は無効。",
|
||||
"com_nav_balance_auto_refill_error": "自動補充設定の読み込み中にエラーが発生しました。",
|
||||
"com_nav_balance_auto_refill_settings": "自動補充設定",
|
||||
"com_nav_balance_day": "日",
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"com_agents_mcp_icon_size": "최소 크기 128 x 128 픽셀",
|
||||
"com_agents_mcp_info": "에이전트가 작업을 수행하고 외부 서비스와 연동할 수 있도록 MCP 서버를 추가하세요",
|
||||
"com_agents_mcp_name_placeholder": "커스텀 툴",
|
||||
"com_agents_mcp_trust_subtext": "사용자 지정 커넥터는 LibreChat에서 확인되지 않습니다.",
|
||||
"com_agents_mcps_disabled": "MCP를 추가하려면 먼저 에이전트를 생성해야 합니다.",
|
||||
"com_agents_missing_provider_model": "에이전트를 생성하기 전에 제공업체와 모델을 선택해 주세요",
|
||||
"com_agents_name_placeholder": "선택 사항: 에이전트의 이름",
|
||||
"com_agents_no_access": "이 에이전트를 수정할 권한이 없습니다",
|
||||
@@ -337,6 +339,7 @@
|
||||
"com_nav_balance_minute": "분",
|
||||
"com_nav_balance_minutes": "분",
|
||||
"com_nav_balance_month": "월",
|
||||
"com_nav_balance_months": "월",
|
||||
"com_nav_balance_next_refill": "다음 충전:",
|
||||
"com_nav_balance_next_refill_info": "다음 충전은 두 조건이 모두 충족될 때만 자동으로 발생합니다: 마지막 충전 이후 지정된 시간 간격이 지났고, 프롬프트를 보내면 잔액이 0 아래로 떨어질 때입니다.",
|
||||
"com_nav_balance_refill_amount": "충전 금액:",
|
||||
@@ -903,6 +906,7 @@
|
||||
"com_ui_oauth_connected_to": "연결됨:",
|
||||
"com_ui_oauth_error_callback_failed": "인증 콜백이 실패했습니다. 다시 시도하세요.",
|
||||
"com_ui_oauth_error_generic": "인증이 실패했습니다. 다시 시도하세요.",
|
||||
"com_ui_oauth_error_invalid_state": "잘못된 상태값입니다. 다시 시도하세요.",
|
||||
"com_ui_oauth_error_missing_code": "인증 코드가 누락되었습니다. 다시 시도하세요.",
|
||||
"com_ui_oauth_error_missing_state": "상태 파라미터가 누락되었습니다. 다시 시도하세요.",
|
||||
"com_ui_oauth_error_title": "인증 실패",
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
"com_endpoint_prompt_prefix_assistants": "Ytterligare instruktioner",
|
||||
"com_endpoint_prompt_prefix_placeholder": "Ange anpassade instruktioner eller kontext. Ignoreras om tom.",
|
||||
"com_endpoint_save_as_preset": "Spara som förinställning",
|
||||
"com_endpoint_search": "Sök slutpunkt efter namn",
|
||||
"com_endpoint_search_endpoint_models": "Sök {{0}} modeller...",
|
||||
"com_endpoint_search_models": "Sök modeller...",
|
||||
"com_endpoint_search_var": "Sök {{0}}...",
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--markdown-font-size: 1rem;
|
||||
}
|
||||
html {
|
||||
--brand-purple: #ab68ff;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
### ⚠️ Warning:
|
||||
|
||||
This script can be expensive, several dollars worth, even with prompt caching. It can also be slow if has not been ran in a while, with translations contributed.
|
||||
This script can be expensive, several dollars worth, even with prompt caching. It can also be slow if it has not been run in a while, with translations contributed.
|
||||
|
||||
### Instructions:
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// v0.8.0-rc1
|
||||
// v0.8.0-rc2
|
||||
// See .env.test.example for an example of the '.env.test' file.
|
||||
require('dotenv').config({ path: './e2e/.env.test' });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
apiVersion: v2
|
||||
name: librechat
|
||||
description: A Helm chart for LibreChat
|
||||
icon: https://www.librechat.ai/librechat_alt.svg
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
@@ -14,7 +15,7 @@ type: application
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 1.8.9
|
||||
version: 1.8.10
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
@@ -22,7 +23,7 @@ version: 1.8.9
|
||||
# It is recommended to use it with quotes.
|
||||
|
||||
# renovate: image=ghcr.io/danny-avila/librechat
|
||||
appVersion: "v0.8.0-rc1"
|
||||
appVersion: "v0.8.0-rc2"
|
||||
|
||||
home: https://www.librechat.ai
|
||||
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "LibreChat",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "LibreChat",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"api",
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"api": {
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
@@ -2689,7 +2689,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@ariakit/react": "^0.4.15",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "LibreChat",
|
||||
"version": "v0.8.0-rc1",
|
||||
"version": "v0.8.0-rc2",
|
||||
"description": "",
|
||||
"workspaces": [
|
||||
"api",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { ViolationTypes, ErrorTypes } from 'librechat-data-provider';
|
||||
import type { Agent, TModelsConfig } from 'librechat-data-provider';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
/** Avatar schema shared between create and update */
|
||||
export const agentAvatarSchema = z.object({
|
||||
@@ -59,3 +62,90 @@ export const agentUpdateSchema = agentBaseSchema.extend({
|
||||
removeProjectIds: z.array(z.string()).optional(),
|
||||
isCollaborative: z.boolean().optional(),
|
||||
});
|
||||
|
||||
interface ValidateAgentModelParams {
|
||||
req: Request;
|
||||
res: Response;
|
||||
agent: Agent;
|
||||
modelsConfig: TModelsConfig;
|
||||
logViolation: (
|
||||
req: Request,
|
||||
res: Response,
|
||||
type: string,
|
||||
errorMessage: Record<string, unknown>,
|
||||
score?: number | string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
interface ValidateAgentModelResult {
|
||||
isValid: boolean;
|
||||
error?: {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an agent's model against the available models configuration.
|
||||
* This is a non-middleware version of validateModel that can be used
|
||||
* in service initialization flows.
|
||||
*
|
||||
* @param params - Validation parameters
|
||||
* @returns Object indicating whether the model is valid and any error details
|
||||
*/
|
||||
export async function validateAgentModel(
|
||||
params: ValidateAgentModelParams,
|
||||
): Promise<ValidateAgentModelResult> {
|
||||
const { req, res, agent, modelsConfig, logViolation } = params;
|
||||
const { model, provider: endpoint } = agent;
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
message: `{ "type": "${ErrorTypes.MISSING_MODEL}", "info": "${endpoint}" }`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!modelsConfig) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
message: `{ "type": "${ErrorTypes.MODELS_NOT_LOADED}" }`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const availableModels = modelsConfig[endpoint];
|
||||
if (!availableModels) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
message: `{ "type": "${ErrorTypes.ENDPOINT_MODELS_NOT_LOADED}", "info": "${endpoint}" }`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const validModel = !!availableModels.find((availableModel) => availableModel === model);
|
||||
|
||||
if (validModel) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
const { ILLEGAL_MODEL_REQ_SCORE: score = 1 } = process.env ?? {};
|
||||
const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
|
||||
const errorMessage = {
|
||||
type,
|
||||
model,
|
||||
endpoint,
|
||||
};
|
||||
|
||||
await logViolation(req, res, type, errorMessage, score);
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
message: `{ "type": "${ViolationTypes.ILLEGAL_MODEL_REQUEST}", "info": "${endpoint}|${model}" }`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export * from './crypto';
|
||||
export * from './flow/manager';
|
||||
/* Middleware */
|
||||
export * from './middleware';
|
||||
/* Memory */
|
||||
export * from './memory';
|
||||
/* Agents */
|
||||
export * from './agents';
|
||||
/* Endpoints */
|
||||
|
||||
28
packages/api/src/memory/config.ts
Normal file
28
packages/api/src/memory/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { memorySchema } from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TMemoryConfig } from 'librechat-data-provider';
|
||||
|
||||
const hasValidAgent = (agent: TMemoryConfig['agent']) =>
|
||||
!!agent &&
|
||||
(('id' in agent && !!agent.id) ||
|
||||
('provider' in agent && 'model' in agent && !!agent.provider && !!agent.model));
|
||||
|
||||
const isDisabled = (config?: TMemoryConfig | TCustomConfig['memory']) =>
|
||||
!config || config.disabled === true;
|
||||
|
||||
export function loadMemoryConfig(config: TCustomConfig['memory']): TMemoryConfig | undefined {
|
||||
if (!config) return undefined;
|
||||
if (isDisabled(config)) return config as TMemoryConfig;
|
||||
|
||||
if (!hasValidAgent(config.agent)) {
|
||||
return { ...config, disabled: true } as TMemoryConfig;
|
||||
}
|
||||
|
||||
const charLimit = memorySchema.shape.charLimit.safeParse(config.charLimit).data ?? 10000;
|
||||
|
||||
return { ...config, charLimit };
|
||||
}
|
||||
|
||||
export function isMemoryEnabled(config: TMemoryConfig | undefined): boolean {
|
||||
if (isDisabled(config)) return false;
|
||||
return hasValidAgent(config!.agent);
|
||||
}
|
||||
1
packages/api/src/memory/index.ts
Normal file
1
packages/api/src/memory/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './config';
|
||||
@@ -1,36 +1,43 @@
|
||||
const errorController = require('./ErrorController');
|
||||
const { logger } = require('~/config');
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { ErrorController } from './error';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { ValidationError, MongoServerError, CustomError } from '~/types';
|
||||
|
||||
// Mock the logger
|
||||
jest.mock('~/config', () => ({
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ErrorController', () => {
|
||||
let mockReq, mockRes, mockNext;
|
||||
let mockReq: Request;
|
||||
let mockRes: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReq = {};
|
||||
mockReq = {
|
||||
originalUrl: '',
|
||||
} as Request;
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mockNext = jest.fn();
|
||||
logger.error.mockClear();
|
||||
} as unknown as Response;
|
||||
(logger.error as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('ValidationError handling', () => {
|
||||
it('should handle ValidationError with single error', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
message: 'Validation error',
|
||||
errors: {
|
||||
email: { message: 'Email is required', path: 'email' },
|
||||
},
|
||||
};
|
||||
} as ValidationError;
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
ErrorController(validationError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
@@ -43,13 +50,14 @@ describe('ErrorController', () => {
|
||||
it('should handle ValidationError with multiple errors', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
message: 'Validation error',
|
||||
errors: {
|
||||
email: { message: 'Email is required', path: 'email' },
|
||||
password: { message: 'Password is required', path: 'password' },
|
||||
},
|
||||
};
|
||||
} as ValidationError;
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
ErrorController(validationError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
@@ -63,9 +71,9 @@ describe('ErrorController', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
errors: {},
|
||||
};
|
||||
} as ValidationError;
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
ErrorController(validationError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
@@ -78,43 +86,59 @@ describe('ErrorController', () => {
|
||||
describe('Duplicate key error handling', () => {
|
||||
it('should handle duplicate key error (code 11000)', () => {
|
||||
const duplicateKeyError = {
|
||||
name: 'MongoServerError',
|
||||
message: 'Duplicate key error',
|
||||
code: 11000,
|
||||
keyValue: { email: 'test@example.com' },
|
||||
};
|
||||
errmsg:
|
||||
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
ErrorController(duplicateKeyError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email"] already exists.',
|
||||
fields: '["email"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Duplicate key error: E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle duplicate key error with multiple fields', () => {
|
||||
const duplicateKeyError = {
|
||||
name: 'MongoServerError',
|
||||
message: 'Duplicate key error',
|
||||
code: 11000,
|
||||
keyValue: { email: 'test@example.com', username: 'testuser' },
|
||||
};
|
||||
errmsg:
|
||||
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
ErrorController(duplicateKeyError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email","username"] already exists.',
|
||||
fields: '["email","username"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Duplicate key error: E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error with code 11000 as string', () => {
|
||||
const duplicateKeyError = {
|
||||
code: '11000',
|
||||
name: 'MongoServerError',
|
||||
message: 'Duplicate key error',
|
||||
code: 11000,
|
||||
keyValue: { email: 'test@example.com' },
|
||||
};
|
||||
errmsg:
|
||||
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
ErrorController(duplicateKeyError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
@@ -129,9 +153,9 @@ describe('ErrorController', () => {
|
||||
const syntaxError = {
|
||||
statusCode: 400,
|
||||
body: 'Invalid JSON syntax',
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(syntaxError, mockReq, mockRes, mockNext);
|
||||
ErrorController(syntaxError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('Invalid JSON syntax');
|
||||
@@ -141,9 +165,9 @@ describe('ErrorController', () => {
|
||||
const customError = {
|
||||
statusCode: 422,
|
||||
body: { error: 'Unprocessable entity' },
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(customError, mockReq, mockRes, mockNext);
|
||||
ErrorController(customError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(422);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({ error: 'Unprocessable entity' });
|
||||
@@ -152,9 +176,9 @@ describe('ErrorController', () => {
|
||||
it('should handle error with statusCode but no body', () => {
|
||||
const partialError = {
|
||||
statusCode: 400,
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(partialError, mockReq, mockRes, mockNext);
|
||||
ErrorController(partialError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
@@ -163,9 +187,9 @@ describe('ErrorController', () => {
|
||||
it('should handle error with body but no statusCode', () => {
|
||||
const partialError = {
|
||||
body: 'Some error message',
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(partialError, mockReq, mockRes, mockNext);
|
||||
ErrorController(partialError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
@@ -176,7 +200,7 @@ describe('ErrorController', () => {
|
||||
it('should handle unknown errors', () => {
|
||||
const unknownError = new Error('Some unknown error');
|
||||
|
||||
errorController(unknownError, mockReq, mockRes, mockNext);
|
||||
ErrorController(unknownError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
@@ -187,32 +211,31 @@ describe('ErrorController', () => {
|
||||
const mongoError = {
|
||||
code: 11100,
|
||||
message: 'Some MongoDB error',
|
||||
};
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(mongoError, mockReq, mockRes, mockNext);
|
||||
ErrorController(mongoError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', mongoError);
|
||||
});
|
||||
|
||||
it('should handle null/undefined errors', () => {
|
||||
errorController(null, mockReq, mockRes, mockNext);
|
||||
it('should handle generic errors', () => {
|
||||
const genericError = new Error('Test error');
|
||||
|
||||
ErrorController(genericError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'ErrorController => processing error',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', genericError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Catch block handling', () => {
|
||||
beforeEach(() => {
|
||||
// Restore logger mock to normal behavior for these tests
|
||||
logger.error.mockRestore();
|
||||
logger.error = jest.fn();
|
||||
(logger.error as jest.Mock).mockRestore();
|
||||
(logger.error as jest.Mock) = jest.fn();
|
||||
});
|
||||
|
||||
it('should handle errors when logger.error throws', () => {
|
||||
@@ -220,10 +243,10 @@ describe('ErrorController', () => {
|
||||
const freshMockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
} as unknown as Response;
|
||||
|
||||
// Mock logger to throw on the first call, succeed on the second
|
||||
logger.error
|
||||
(logger.error as jest.Mock)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Logger error');
|
||||
})
|
||||
@@ -231,7 +254,7 @@ describe('ErrorController', () => {
|
||||
|
||||
const testError = new Error('Test error');
|
||||
|
||||
errorController(testError, mockReq, freshMockRes, mockNext);
|
||||
ErrorController(testError, mockReq, freshMockRes);
|
||||
|
||||
expect(freshMockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(freshMockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
|
||||
83
packages/api/src/middleware/error.ts
Normal file
83
packages/api/src/middleware/error.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { MongoServerError, ValidationError, CustomError } from '~/types';
|
||||
|
||||
const handleDuplicateKeyError = (err: MongoServerError, res: Response) => {
|
||||
logger.warn('Duplicate key error: ' + (err.errmsg || err.message));
|
||||
const field = err.keyValue ? `${JSON.stringify(Object.keys(err.keyValue))}` : 'unknown';
|
||||
const code = 409;
|
||||
res
|
||||
.status(code)
|
||||
.send({ messages: `An document with that ${field} already exists.`, fields: field });
|
||||
};
|
||||
|
||||
const handleValidationError = (err: ValidationError, res: Response) => {
|
||||
logger.error('Validation error:', err.errors);
|
||||
const errorMessages = Object.values(err.errors).map((el) => el.message);
|
||||
const fields = `${JSON.stringify(Object.values(err.errors).map((el) => el.path))}`;
|
||||
const code = 400;
|
||||
const messages =
|
||||
errorMessages.length > 1
|
||||
? `${JSON.stringify(errorMessages.join(' '))}`
|
||||
: `${JSON.stringify(errorMessages)}`;
|
||||
|
||||
res.status(code).send({ messages, fields });
|
||||
};
|
||||
|
||||
/** Type guard for ValidationError */
|
||||
function isValidationError(err: unknown): err is ValidationError {
|
||||
return err !== null && typeof err === 'object' && 'name' in err && err.name === 'ValidationError';
|
||||
}
|
||||
|
||||
/** Type guard for MongoServerError (duplicate key) */
|
||||
function isMongoServerError(err: unknown): err is MongoServerError {
|
||||
return err !== null && typeof err === 'object' && 'code' in err && err.code === 11000;
|
||||
}
|
||||
|
||||
/** Type guard for CustomError with statusCode and body */
|
||||
function isCustomError(err: unknown): err is CustomError {
|
||||
return err !== null && typeof err === 'object' && 'statusCode' in err && 'body' in err;
|
||||
}
|
||||
|
||||
export const ErrorController = (
|
||||
err: Error | CustomError,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Response | void => {
|
||||
try {
|
||||
if (!err) {
|
||||
return next();
|
||||
}
|
||||
const error = err as CustomError;
|
||||
|
||||
if (
|
||||
(error.message === ErrorTypes.AUTH_FAILED || error.code === ErrorTypes.AUTH_FAILED) &&
|
||||
req.originalUrl &&
|
||||
req.originalUrl.includes('/oauth/') &&
|
||||
req.originalUrl.includes('/callback')
|
||||
) {
|
||||
const domain = process.env.DOMAIN_CLIENT || 'http://localhost:3080';
|
||||
return res.redirect(`${domain}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`);
|
||||
}
|
||||
|
||||
if (isValidationError(error)) {
|
||||
return handleValidationError(error, res);
|
||||
}
|
||||
|
||||
if (isMongoServerError(error)) {
|
||||
return handleDuplicateKeyError(error, res);
|
||||
}
|
||||
|
||||
if (isCustomError(error) && error.statusCode && error.body) {
|
||||
return res.status(error.statusCode).send(error.body);
|
||||
}
|
||||
|
||||
logger.error('ErrorController => error', err);
|
||||
return res.status(500).send('An unknown error occurred.');
|
||||
} catch (processingError) {
|
||||
logger.error('ErrorController => processing error', processingError);
|
||||
return res.status(500).send('Processing error in ErrorController.');
|
||||
}
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from './access';
|
||||
export * from './error';
|
||||
|
||||
27
packages/api/src/types/error.ts
Normal file
27
packages/api/src/types/error.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Error as MongooseError } from 'mongoose';
|
||||
|
||||
/** MongoDB duplicate key error interface */
|
||||
export interface MongoServerError extends Error {
|
||||
code: number;
|
||||
keyValue?: Record<string, unknown>;
|
||||
errmsg?: string;
|
||||
}
|
||||
|
||||
/** Mongoose validation error interface */
|
||||
export interface ValidationError extends MongooseError {
|
||||
name: 'ValidationError';
|
||||
errors: Record<
|
||||
string,
|
||||
{
|
||||
message: string;
|
||||
path?: string;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
/** Custom error with status code and body */
|
||||
export interface CustomError extends Error {
|
||||
statusCode?: number;
|
||||
body?: unknown;
|
||||
code?: string | number;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './azure';
|
||||
export * from './events';
|
||||
export * from './error';
|
||||
export * from './google';
|
||||
export * from './mistral';
|
||||
export * from './openai';
|
||||
|
||||
@@ -727,6 +727,7 @@ export const memorySchema = z.object({
|
||||
disabled: z.boolean().optional(),
|
||||
validKeys: z.array(z.string()).optional(),
|
||||
tokenLimit: z.number().optional(),
|
||||
charLimit: z.number().optional().default(10000),
|
||||
personalize: z.boolean().default(true),
|
||||
messageWindowSize: z.number().optional().default(5),
|
||||
agent: z
|
||||
@@ -1346,6 +1347,22 @@ export enum ErrorTypes {
|
||||
* Invalid Agent Provider (excluded by Admin)
|
||||
*/
|
||||
INVALID_AGENT_PROVIDER = 'invalid_agent_provider',
|
||||
/**
|
||||
* Missing model selection
|
||||
*/
|
||||
MISSING_MODEL = 'missing_model',
|
||||
/**
|
||||
* Models configuration not loaded
|
||||
*/
|
||||
MODELS_NOT_LOADED = 'models_not_loaded',
|
||||
/**
|
||||
* Endpoint models not loaded
|
||||
*/
|
||||
ENDPOINT_MODELS_NOT_LOADED = 'endpoint_models_not_loaded',
|
||||
/**
|
||||
* Generic Authentication failure
|
||||
*/
|
||||
AUTH_FAILED = 'auth_failed',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1464,7 +1481,7 @@ export enum TTSProviders {
|
||||
/** Enum for app-wide constants */
|
||||
export enum Constants {
|
||||
/** Key for the app's version. */
|
||||
VERSION = 'v0.8.0-rc1',
|
||||
VERSION = 'v0.8.0-rc2',
|
||||
/** Key for the Custom Config's version (librechat.yaml). */
|
||||
CONFIG_VERSION = '1.2.8',
|
||||
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
|
||||
|
||||
@@ -815,8 +815,8 @@ export function confirmTwoFactor(payload: t.TVerify2FARequest): Promise<t.TVerif
|
||||
return request.post(endpoints.confirmTwoFactor(), payload);
|
||||
}
|
||||
|
||||
export function disableTwoFactor(): Promise<t.TDisable2FAResponse> {
|
||||
return request.post(endpoints.disableTwoFactor());
|
||||
export function disableTwoFactor(payload?: t.TDisable2FARequest): Promise<t.TDisable2FAResponse> {
|
||||
return request.post(endpoints.disableTwoFactor(), payload);
|
||||
}
|
||||
|
||||
export function regenerateBackupCodes(): Promise<t.TRegenerateBackupCodesResponse> {
|
||||
|
||||
@@ -169,6 +169,10 @@ export const megabyte = 1024 * 1024;
|
||||
export const mbToBytes = (mb: number): number => mb * megabyte;
|
||||
|
||||
const defaultSizeLimit = mbToBytes(512);
|
||||
|
||||
// Anthropic PDF limits: 32MB max, 100 pages max
|
||||
export const anthropicPdfSizeLimit = mbToBytes(32);
|
||||
|
||||
const assistantsFileConfig = {
|
||||
fileLimit: 10,
|
||||
fileSizeLimit: defaultSizeLimit,
|
||||
@@ -182,6 +186,14 @@ export const fileConfig = {
|
||||
[EModelEndpoint.assistants]: assistantsFileConfig,
|
||||
[EModelEndpoint.azureAssistants]: assistantsFileConfig,
|
||||
[EModelEndpoint.agents]: assistantsFileConfig,
|
||||
[EModelEndpoint.anthropic]: {
|
||||
fileLimit: 10,
|
||||
fileSizeLimit: defaultSizeLimit,
|
||||
totalSizeLimit: defaultSizeLimit,
|
||||
supportedMimeTypes,
|
||||
disabled: false,
|
||||
pdfSizeLimit: anthropicPdfSizeLimit,
|
||||
},
|
||||
default: {
|
||||
fileLimit: 10,
|
||||
fileSizeLimit: defaultSizeLimit,
|
||||
|
||||
@@ -13,8 +13,6 @@ export * from './generate';
|
||||
export * from './models';
|
||||
/* mcp */
|
||||
export * from './mcp';
|
||||
/* memory */
|
||||
export * from './memory';
|
||||
/* RBAC */
|
||||
export * from './permissions';
|
||||
export * from './roles';
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { TCustomConfig, TMemoryConfig } from './config';
|
||||
|
||||
/**
|
||||
* Loads the memory configuration and validates it
|
||||
* @param config - The memory configuration from librechat.yaml
|
||||
* @returns The validated memory configuration
|
||||
*/
|
||||
export function loadMemoryConfig(config: TCustomConfig['memory']): TMemoryConfig | undefined {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If disabled is explicitly true, return the config as-is
|
||||
if (config.disabled === true) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// Check if the agent configuration is valid
|
||||
const hasValidAgent =
|
||||
config.agent &&
|
||||
(('id' in config.agent && !!config.agent.id) ||
|
||||
('provider' in config.agent &&
|
||||
'model' in config.agent &&
|
||||
!!config.agent.provider &&
|
||||
!!config.agent.model));
|
||||
|
||||
// If agent config is invalid, treat as disabled
|
||||
if (!hasValidAgent) {
|
||||
return {
|
||||
...config,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if memory feature is enabled based on the configuration
|
||||
* @param config - The memory configuration
|
||||
* @returns True if memory is enabled, false otherwise
|
||||
*/
|
||||
export function isMemoryEnabled(config: TMemoryConfig | undefined): boolean {
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.disabled === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if agent configuration is valid
|
||||
const hasValidAgent =
|
||||
config.agent &&
|
||||
(('id' in config.agent && !!config.agent.id) ||
|
||||
('provider' in config.agent &&
|
||||
'model' in config.agent &&
|
||||
!!config.agent.provider &&
|
||||
!!config.agent.model));
|
||||
|
||||
return !!hasValidAgent;
|
||||
}
|
||||
@@ -413,6 +413,14 @@ export type TVerify2FATempResponse = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request for disabling 2FA.
|
||||
*/
|
||||
export type TDisable2FARequest = {
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response from disabling 2FA.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user