From dbe4dd96b4d4d23cf4db5c6063446a840aa40260 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 1 Oct 2025 23:30:47 -0400 Subject: [PATCH 001/272] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Cleanup=20Logge?= =?UTF-8?q?r=20and=20Utility=20Imports=20(#9935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧹 chore: Update logger imports to use @librechat/data-schemas across multiple files and remove unused sleep function from queue.js (#9930) * chore: Replace local isEnabled utility with @librechat/api import across multiple files, update test files * chore: Replace local logger import with @librechat/data-schemas logger in countTokens.js and fork.js * chore: Update logs volume path in docker-compose.yml to correct directory * chore: import order of isEnabled in static.js --- api/app/clients/AnthropicClient.js | 5 ++-- api/app/clients/GoogleClient.js | 4 +-- api/app/clients/OpenAIClient.js | 8 ++--- api/app/clients/TextStream.js | 2 +- .../agents/CustomAgent/outputParser.js | 2 +- api/app/clients/chains/runTitleChain.js | 2 +- api/app/clients/memory/summaryBuffer.js | 2 +- api/app/clients/output_parsers/addImages.js | 2 +- .../clients/tools/structured/AzureAISearch.js | 4 +-- api/app/clients/tools/structured/FluxAPI.js | 4 +-- .../tools/structured/StableDiffusion.js | 2 +- .../tools/structured/TraversaalSearch.js | 5 ++-- api/app/clients/tools/structured/Wolfram.js | 4 +-- .../clients/tools/util/handleOpenAIErrors.js | 2 +- api/cache/clearPendingReq.js | 2 +- api/cache/keyvMongo.js | 2 +- api/cache/logViolation.js | 2 +- api/models/Categories.js | 2 +- api/models/spendTokens.js | 2 +- api/server/controllers/ModelController.js | 2 +- .../controllers/auth/LoginController.js | 2 +- .../controllers/auth/LogoutController.js | 6 ++-- api/server/middleware/canDeleteAccount.js | 4 +-- api/server/middleware/checkBan.js | 3 +- .../middleware/checkPeoplePickerAccess.js | 2 +- .../checkPeoplePickerAccess.spec.js | 5 ++-- api/server/middleware/concurrentLimiter.js | 2 +- api/server/middleware/logHeaders.js | 2 +- api/server/middleware/moderateText.js | 4 +-- api/server/middleware/optionalJwtAuth.js | 2 +- api/server/middleware/requireJwtAuth.js | 4 +-- api/server/middleware/requireLocalAuth.js | 2 +- .../middleware/validatePasswordReset.js | 4 +-- api/server/middleware/validateRegistration.js | 2 +- api/server/routes/__tests__/ldap.spec.js | 9 ++++-- api/server/routes/agents/index.js | 2 +- api/server/routes/edit/index.js | 24 +++++++-------- api/server/routes/files/files.js | 2 +- api/server/routes/files/speech/tts.js | 2 +- api/server/routes/presets.js | 4 +-- api/server/routes/search.js | 2 +- api/server/routes/static.js | 2 +- api/server/routes/tokenizer.js | 5 ++-- api/server/services/Config/ldap.js | 2 +- .../services/Endpoints/anthropic/title.js | 2 +- .../services/Endpoints/assistants/title.js | 2 +- api/server/services/Endpoints/openAI/title.js | 2 +- api/server/services/Files/Azure/crud.js | 2 +- api/server/services/Files/Azure/initialize.js | 2 +- .../services/Files/Firebase/initialize.js | 2 +- api/server/services/Files/OpenAI/crud.js | 4 +-- api/server/services/Files/S3/crud.js | 2 +- api/server/services/Files/S3/initialize.js | 2 +- api/server/services/Files/images/avatar.js | 2 +- api/server/services/Files/images/convert.js | 2 +- api/server/services/GraphTokenService.js | 8 ++--- api/server/services/Runs/RunManager.js | 2 +- api/server/services/Runs/handle.js | 4 +-- api/server/services/cleanup.js | 3 +- api/server/utils/countTokens.js | 2 +- api/server/utils/handleText.js | 29 ------------------- api/server/utils/import/fork.js | 2 +- api/server/utils/import/importBatchBuilder.js | 2 +- api/server/utils/queue.js | 11 ------- api/strategies/appleStrategy.js | 6 ++-- api/utils/deriveBaseURL.js | 2 +- api/utils/findMessageContent.js | 2 +- docker-compose.yml | 2 +- 68 files changed, 111 insertions(+), 146 deletions(-) diff --git a/api/app/clients/AnthropicClient.js b/api/app/clients/AnthropicClient.js index 834877bb42..43e546a0a3 100644 --- a/api/app/clients/AnthropicClient.js +++ b/api/app/clients/AnthropicClient.js @@ -1,4 +1,5 @@ const Anthropic = require('@anthropic-ai/sdk'); +const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { Constants, @@ -9,7 +10,7 @@ const { getResponseSender, validateVisionModel, } = require('librechat-data-provider'); -const { SplitStreamHandler: _Handler } = require('@librechat/agents'); +const { sleep, SplitStreamHandler: _Handler } = require('@librechat/agents'); const { Tokenizer, createFetch, @@ -31,9 +32,7 @@ const { } = require('./prompts'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); -const { sleep } = require('~/server/utils'); const BaseClient = require('./BaseClient'); -const { logger } = require('~/config'); const HUMAN_PROMPT = '\n\nHuman:'; const AI_PROMPT = '\n\nAssistant:'; diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js index 715f48ff5a..9322778e73 100644 --- a/api/app/clients/GoogleClient.js +++ b/api/app/clients/GoogleClient.js @@ -1,4 +1,6 @@ const { google } = require('googleapis'); +const { sleep } = require('@librechat/agents'); +const { logger } = require('@librechat/data-schemas'); const { getModelMaxTokens } = require('@librechat/api'); const { concat } = require('@langchain/core/utils/stream'); const { ChatVertexAI } = require('@langchain/google-vertexai'); @@ -22,8 +24,6 @@ const { } = require('librechat-data-provider'); const { encodeAndFormat } = require('~/server/services/Files/images'); const { spendTokens } = require('~/models/spendTokens'); -const { sleep } = require('~/server/utils'); -const { logger } = require('~/config'); const { formatMessage, createContextHandlers, diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 2b254036c5..17c4891dcd 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -1,6 +1,6 @@ -const { OllamaClient } = require('./OllamaClient'); +const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); -const { SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents'); +const { sleep, SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents'); const { isEnabled, Tokenizer, @@ -34,16 +34,16 @@ const { createContextHandlers, } = require('./prompts'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); -const { addSpaceIfNeeded, sleep } = require('~/server/utils'); const { spendTokens } = require('~/models/spendTokens'); +const { addSpaceIfNeeded } = require('~/server/utils'); const { handleOpenAIErrors } = require('./tools/util'); +const { OllamaClient } = require('./OllamaClient'); const { summaryBuffer } = require('./memory'); const { runTitleChain } = require('./chains'); const { extractBaseURL } = require('~/utils'); const { tokenSplit } = require('./document'); const BaseClient = require('./BaseClient'); const { createLLM } = require('./llm'); -const { logger } = require('~/config'); class OpenAIClient extends BaseClient { constructor(apiKey, options = {}) { diff --git a/api/app/clients/TextStream.js b/api/app/clients/TextStream.js index 01809e87fa..ea3d8980e9 100644 --- a/api/app/clients/TextStream.js +++ b/api/app/clients/TextStream.js @@ -1,5 +1,5 @@ const { Readable } = require('stream'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); class TextStream extends Readable { constructor(text, options = {}) { diff --git a/api/app/clients/agents/CustomAgent/outputParser.js b/api/app/clients/agents/CustomAgent/outputParser.js index 9d849519f5..f75200e692 100644 --- a/api/app/clients/agents/CustomAgent/outputParser.js +++ b/api/app/clients/agents/CustomAgent/outputParser.js @@ -1,5 +1,5 @@ +const { logger } = require('@librechat/data-schemas'); const { ZeroShotAgentOutputParser } = require('langchain/agents'); -const { logger } = require('~/config'); class CustomOutputParser extends ZeroShotAgentOutputParser { constructor(fields) { diff --git a/api/app/clients/chains/runTitleChain.js b/api/app/clients/chains/runTitleChain.js index a020ffb8e3..5c3d9738e1 100644 --- a/api/app/clients/chains/runTitleChain.js +++ b/api/app/clients/chains/runTitleChain.js @@ -1,7 +1,7 @@ const { z } = require('zod'); +const { logger } = require('@librechat/data-schemas'); const { langPrompt, createTitlePrompt, escapeBraces, getSnippet } = require('../prompts'); const { createStructuredOutputChainFromZod } = require('langchain/chains/openai_functions'); -const { logger } = require('~/config'); const langSchema = z.object({ language: z.string().describe('The language of the input text (full noun, no abbreviations).'), diff --git a/api/app/clients/memory/summaryBuffer.js b/api/app/clients/memory/summaryBuffer.js index 0555fc214e..84ff34d151 100644 --- a/api/app/clients/memory/summaryBuffer.js +++ b/api/app/clients/memory/summaryBuffer.js @@ -1,7 +1,7 @@ +const { logger } = require('@librechat/data-schemas'); const { ConversationSummaryBufferMemory, ChatMessageHistory } = require('langchain/memory'); const { formatLangChainMessages, SUMMARY_PROMPT } = require('../prompts'); const { predictNewSummary } = require('../chains'); -const { logger } = require('~/config'); const createSummaryBufferMemory = ({ llm, prompt, messages, ...rest }) => { const chatHistory = new ChatMessageHistory(messages); diff --git a/api/app/clients/output_parsers/addImages.js b/api/app/clients/output_parsers/addImages.js index 7bef60259c..4b5019279a 100644 --- a/api/app/clients/output_parsers/addImages.js +++ b/api/app/clients/output_parsers/addImages.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); /** * The `addImages` function corrects any erroneous image URLs in the `responseMessage.text` diff --git a/api/app/clients/tools/structured/AzureAISearch.js b/api/app/clients/tools/structured/AzureAISearch.js index e25da94426..55af3cdff5 100644 --- a/api/app/clients/tools/structured/AzureAISearch.js +++ b/api/app/clients/tools/structured/AzureAISearch.js @@ -1,7 +1,7 @@ const { z } = require('zod'); const { Tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); const { SearchClient, AzureKeyCredential } = require('@azure/search-documents'); -const { logger } = require('~/config'); class AzureAISearch extends Tool { // Constants for default values @@ -18,7 +18,7 @@ class AzureAISearch extends Tool { super(); this.name = 'azure-ai-search'; this.description = - 'Use the \'azure-ai-search\' tool to retrieve search results relevant to your input'; + "Use the 'azure-ai-search' tool to retrieve search results relevant to your input"; /* Used to initialize the Tool without necessary variables. */ this.override = fields.override ?? false; diff --git a/api/app/clients/tools/structured/FluxAPI.js b/api/app/clients/tools/structured/FluxAPI.js index 80f9772200..9fa08a0343 100644 --- a/api/app/clients/tools/structured/FluxAPI.js +++ b/api/app/clients/tools/structured/FluxAPI.js @@ -3,12 +3,12 @@ const axios = require('axios'); const fetch = require('node-fetch'); const { v4: uuidv4 } = require('uuid'); const { Tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { FileContext, ContentTypes } = require('librechat-data-provider'); -const { logger } = require('~/config'); const displayMessage = - 'Flux displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.'; + "Flux displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user."; /** * FluxAPI - A tool for generating high-quality images from text prompts using the Flux API. diff --git a/api/app/clients/tools/structured/StableDiffusion.js b/api/app/clients/tools/structured/StableDiffusion.js index b08e42e7c1..05687923e6 100644 --- a/api/app/clients/tools/structured/StableDiffusion.js +++ b/api/app/clients/tools/structured/StableDiffusion.js @@ -6,9 +6,9 @@ const axios = require('axios'); const sharp = require('sharp'); const { v4: uuidv4 } = require('uuid'); const { Tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); const { FileContext, ContentTypes } = require('librechat-data-provider'); const paths = require('~/config/paths'); -const { logger } = require('~/config'); const displayMessage = "Stable Diffusion displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user."; diff --git a/api/app/clients/tools/structured/TraversaalSearch.js b/api/app/clients/tools/structured/TraversaalSearch.js index e8ceeda134..d2ccc35c75 100644 --- a/api/app/clients/tools/structured/TraversaalSearch.js +++ b/api/app/clients/tools/structured/TraversaalSearch.js @@ -1,7 +1,7 @@ const { z } = require('zod'); const { Tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); const { getEnvironmentVariable } = require('@langchain/core/utils/env'); -const { logger } = require('~/config'); /** * Tool for the Traversaal AI search API, Ares. @@ -21,7 +21,7 @@ class TraversaalSearch extends Tool { query: z .string() .describe( - 'A properly written sentence to be interpreted by an AI to search the web according to the user\'s request.', + "A properly written sentence to be interpreted by an AI to search the web according to the user's request.", ), }); @@ -38,7 +38,6 @@ class TraversaalSearch extends Tool { return apiKey; } - // eslint-disable-next-line no-unused-vars async _call({ query }, _runManager) { const body = { query: [query], diff --git a/api/app/clients/tools/structured/Wolfram.js b/api/app/clients/tools/structured/Wolfram.js index 1b426298cc..1f7fe6b1b7 100644 --- a/api/app/clients/tools/structured/Wolfram.js +++ b/api/app/clients/tools/structured/Wolfram.js @@ -1,8 +1,8 @@ /* eslint-disable no-useless-escape */ -const axios = require('axios'); const { z } = require('zod'); +const axios = require('axios'); const { Tool } = require('@langchain/core/tools'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); class WolframAlphaAPI extends Tool { constructor(fields) { diff --git a/api/app/clients/tools/util/handleOpenAIErrors.js b/api/app/clients/tools/util/handleOpenAIErrors.js index 490f3882a8..b3a7c2bfdc 100644 --- a/api/app/clients/tools/util/handleOpenAIErrors.js +++ b/api/app/clients/tools/util/handleOpenAIErrors.js @@ -1,5 +1,5 @@ const OpenAI = require('openai'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); /** * Handles errors that may occur when making requests to OpenAI's API. diff --git a/api/cache/clearPendingReq.js b/api/cache/clearPendingReq.js index 54db8e9690..b4329b1f06 100644 --- a/api/cache/clearPendingReq.js +++ b/api/cache/clearPendingReq.js @@ -1,5 +1,5 @@ +const { isEnabled } = require('@librechat/api'); const { Time, CacheKeys } = require('librechat-data-provider'); -const { isEnabled } = require('~/server/utils'); const getLogStores = require('./getLogStores'); const { USE_REDIS, LIMIT_CONCURRENT_MESSAGES } = process.env ?? {}; diff --git a/api/cache/keyvMongo.js b/api/cache/keyvMongo.js index 1606e98eb8..efcce76752 100644 --- a/api/cache/keyvMongo.js +++ b/api/cache/keyvMongo.js @@ -2,7 +2,7 @@ const mongoose = require('mongoose'); const EventEmitter = require('events'); const { GridFSBucket } = require('mongodb'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); const storeMap = new Map(); diff --git a/api/cache/logViolation.js b/api/cache/logViolation.js index 16dc2e4eae..1ff65c6ccd 100644 --- a/api/cache/logViolation.js +++ b/api/cache/logViolation.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('~/server/utils'); +const { isEnabled } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const getLogStores = require('./getLogStores'); const banViolation = require('./banViolation'); diff --git a/api/models/Categories.js b/api/models/Categories.js index 5da1f4b2da..34bd2d8ed2 100644 --- a/api/models/Categories.js +++ b/api/models/Categories.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); const options = [ { diff --git a/api/models/spendTokens.js b/api/models/spendTokens.js index 65fadd7896..cfd983f6bb 100644 --- a/api/models/spendTokens.js +++ b/api/models/spendTokens.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); const { createTransaction, createStructuredTransaction } = require('./Transaction'); /** * Creates up to two transactions to record the spending of tokens. diff --git a/api/server/controllers/ModelController.js b/api/server/controllers/ModelController.js index 7430655df3..805d9eef27 100644 --- a/api/server/controllers/ModelController.js +++ b/api/server/controllers/ModelController.js @@ -1,7 +1,7 @@ +const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config'); const { getLogStores } = require('~/cache'); -const { logger } = require('~/config'); /** * @param {ServerRequest} req diff --git a/api/server/controllers/auth/LoginController.js b/api/server/controllers/auth/LoginController.js index 226b5605cc..930eee86be 100644 --- a/api/server/controllers/auth/LoginController.js +++ b/api/server/controllers/auth/LoginController.js @@ -1,6 +1,6 @@ +const { logger } = require('@librechat/data-schemas'); const { generate2FATempToken } = require('~/server/services/twoFactorService'); const { setAuthTokens } = require('~/server/services/AuthService'); -const { logger } = require('~/config'); const loginController = async (req, res) => { try { diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index 1d18e4a94d..02d3d0302d 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -1,8 +1,8 @@ const cookies = require('cookie'); -const { getOpenIdConfig } = require('~/strategies'); +const { isEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { logoutUser } = require('~/server/services/AuthService'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); +const { getOpenIdConfig } = require('~/strategies'); const logoutController = async (req, res) => { const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null; diff --git a/api/server/middleware/canDeleteAccount.js b/api/server/middleware/canDeleteAccount.js index 5f2479fb54..a913495287 100644 --- a/api/server/middleware/canDeleteAccount.js +++ b/api/server/middleware/canDeleteAccount.js @@ -1,6 +1,6 @@ +const { isEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { SystemRoles } = require('librechat-data-provider'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); /** * Checks if the user can delete their account diff --git a/api/server/middleware/checkBan.js b/api/server/middleware/checkBan.js index ad4e4c86ec..9f735592f5 100644 --- a/api/server/middleware/checkBan.js +++ b/api/server/middleware/checkBan.js @@ -1,8 +1,9 @@ const { Keyv } = require('keyv'); const uap = require('ua-parser-js'); +const { isEnabled } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { ViolationTypes } = require('librechat-data-provider'); -const { isEnabled, removePorts } = require('~/server/utils'); +const { removePorts } = require('~/server/utils'); const keyvMongo = require('~/cache/keyvMongo'); const denyRequest = require('./denyRequest'); const { getLogStores } = require('~/cache'); diff --git a/api/server/middleware/checkPeoplePickerAccess.js b/api/server/middleware/checkPeoplePickerAccess.js index 3f462362a1..0e604272db 100644 --- a/api/server/middleware/checkPeoplePickerAccess.js +++ b/api/server/middleware/checkPeoplePickerAccess.js @@ -1,6 +1,6 @@ +const { logger } = require('@librechat/data-schemas'); const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider'); const { getRoleByName } = require('~/models/Role'); -const { logger } = require('~/config'); /** * Middleware to check if user has permission to access people picker functionality diff --git a/api/server/middleware/checkPeoplePickerAccess.spec.js b/api/server/middleware/checkPeoplePickerAccess.spec.js index ddbf6f86a9..52bf0e6724 100644 --- a/api/server/middleware/checkPeoplePickerAccess.spec.js +++ b/api/server/middleware/checkPeoplePickerAccess.spec.js @@ -1,10 +1,11 @@ +const { logger } = require('@librechat/data-schemas'); const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider'); const { checkPeoplePickerAccess } = require('./checkPeoplePickerAccess'); const { getRoleByName } = require('~/models/Role'); -const { logger } = require('~/config'); jest.mock('~/models/Role'); -jest.mock('~/config', () => ({ +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), logger: { error: jest.fn(), }, diff --git a/api/server/middleware/concurrentLimiter.js b/api/server/middleware/concurrentLimiter.js index 79de88609b..96885e2fd4 100644 --- a/api/server/middleware/concurrentLimiter.js +++ b/api/server/middleware/concurrentLimiter.js @@ -1,7 +1,7 @@ +const { isEnabled } = require('@librechat/api'); const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider'); const clearPendingReq = require('~/cache/clearPendingReq'); const { logViolation, getLogStores } = require('~/cache'); -const { isEnabled } = require('~/server/utils'); const denyRequest = require('./denyRequest'); const { diff --git a/api/server/middleware/logHeaders.js b/api/server/middleware/logHeaders.js index 26ca04da38..398542747a 100644 --- a/api/server/middleware/logHeaders.js +++ b/api/server/middleware/logHeaders.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); /** * Middleware to log Forwarded Headers diff --git a/api/server/middleware/moderateText.js b/api/server/middleware/moderateText.js index ff1a9de856..775afbafbf 100644 --- a/api/server/middleware/moderateText.js +++ b/api/server/middleware/moderateText.js @@ -1,8 +1,8 @@ const axios = require('axios'); +const { isEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { ErrorTypes } = require('librechat-data-provider'); -const { isEnabled } = require('~/server/utils'); const denyRequest = require('./denyRequest'); -const { logger } = require('~/config'); async function moderateText(req, res, next) { if (!isEnabled(process.env.OPENAI_MODERATION)) { diff --git a/api/server/middleware/optionalJwtAuth.js b/api/server/middleware/optionalJwtAuth.js index 953bc54029..2f59fdda4a 100644 --- a/api/server/middleware/optionalJwtAuth.js +++ b/api/server/middleware/optionalJwtAuth.js @@ -1,6 +1,6 @@ const cookies = require('cookie'); -const { isEnabled } = require('~/server/utils'); const passport = require('passport'); +const { isEnabled } = require('@librechat/api'); // This middleware does not require authentication, // but if the user is authenticated, it will set the user object. diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js index c00b37ee0b..ed83c4773e 100644 --- a/api/server/middleware/requireJwtAuth.js +++ b/api/server/middleware/requireJwtAuth.js @@ -1,6 +1,6 @@ -const passport = require('passport'); const cookies = require('cookie'); -const { isEnabled } = require('~/server/utils'); +const passport = require('passport'); +const { isEnabled } = require('@librechat/api'); /** * Custom Middleware to handle JWT authentication, with support for OpenID token reuse diff --git a/api/server/middleware/requireLocalAuth.js b/api/server/middleware/requireLocalAuth.js index a71bd6c5b0..e82388f298 100644 --- a/api/server/middleware/requireLocalAuth.js +++ b/api/server/middleware/requireLocalAuth.js @@ -1,5 +1,5 @@ const passport = require('passport'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); const requireLocalAuth = (req, res, next) => { passport.authenticate('local', (err, user, info) => { diff --git a/api/server/middleware/validatePasswordReset.js b/api/server/middleware/validatePasswordReset.js index 7f5616722a..b4acd66bb1 100644 --- a/api/server/middleware/validatePasswordReset.js +++ b/api/server/middleware/validatePasswordReset.js @@ -1,5 +1,5 @@ -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); +const { isEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); function validatePasswordReset(req, res, next) { if (isEnabled(process.env.ALLOW_PASSWORD_RESET)) { diff --git a/api/server/middleware/validateRegistration.js b/api/server/middleware/validateRegistration.js index 07911bd9c7..79b5caca2d 100644 --- a/api/server/middleware/validateRegistration.js +++ b/api/server/middleware/validateRegistration.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('~/server/utils'); +const { isEnabled } = require('@librechat/api'); function validateRegistration(req, res, next) { if (req.invite) { diff --git a/api/server/routes/__tests__/ldap.spec.js b/api/server/routes/__tests__/ldap.spec.js index 6e0a95bfe4..bc7efa7859 100644 --- a/api/server/routes/__tests__/ldap.spec.js +++ b/api/server/routes/__tests__/ldap.spec.js @@ -1,10 +1,13 @@ -const request = require('supertest'); const express = require('express'); +const request = require('supertest'); +const { isEnabled } = require('@librechat/api'); const { getLdapConfig } = require('~/server/services/Config/ldap'); -const { isEnabled } = require('~/server/utils'); jest.mock('~/server/services/Config/ldap'); -jest.mock('~/server/utils'); +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + isEnabled: jest.fn(), +})); const app = express(); diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index a1cf5f751a..b5e249b059 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -1,4 +1,5 @@ const express = require('express'); +const { isEnabled } = require('@librechat/api'); const { uaParser, checkBan, @@ -8,7 +9,6 @@ const { concurrentLimiter, messageUserLimiter, } = require('~/server/middleware'); -const { isEnabled } = require('~/server/utils'); const { v1 } = require('./v1'); const chat = require('./chat'); diff --git a/api/server/routes/edit/index.js b/api/server/routes/edit/index.js index 92a1e63f63..2ebc57a13f 100644 --- a/api/server/routes/edit/index.js +++ b/api/server/routes/edit/index.js @@ -1,19 +1,19 @@ +const { isEnabled } = require('@librechat/api'); +const { EModelEndpoint } = require('librechat-data-provider'); +const { + validateConvoAccess, + messageUserLimiter, + concurrentLimiter, + messageIpLimiter, + requireJwtAuth, + checkBan, + uaParser, +} = require('~/server/middleware'); +const anthropic = require('./anthropic'); const express = require('express'); const openAI = require('./openAI'); const custom = require('./custom'); const google = require('./google'); -const anthropic = require('./anthropic'); -const { isEnabled } = require('~/server/utils'); -const { EModelEndpoint } = require('librechat-data-provider'); -const { - checkBan, - uaParser, - requireJwtAuth, - messageIpLimiter, - concurrentLimiter, - messageUserLimiter, - validateConvoAccess, -} = require('~/server/middleware'); const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {}; diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 5d6e72ff2d..5d9761fc6d 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -1,6 +1,7 @@ const fs = require('fs').promises; const express = require('express'); const { EnvVar } = require('@librechat/agents'); +const { logger } = require('@librechat/data-schemas'); const { Time, isUUID, @@ -30,7 +31,6 @@ const { cleanFileName } = require('~/server/utils/files'); const { getAssistant } = require('~/models/Assistant'); const { getAgent } = require('~/models/Agent'); const { getLogStores } = require('~/cache'); -const { logger } = require('~/config'); const { Readable } = require('stream'); const router = express.Router(); diff --git a/api/server/routes/files/speech/tts.js b/api/server/routes/files/speech/tts.js index 1ee540874f..ab1de205b7 100644 --- a/api/server/routes/files/speech/tts.js +++ b/api/server/routes/files/speech/tts.js @@ -1,9 +1,9 @@ const multer = require('multer'); const express = require('express'); +const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); const { getVoices, streamAudio, textToSpeech } = require('~/server/services/Files/Audio'); const { getLogStores } = require('~/cache'); -const { logger } = require('~/config'); const router = express.Router(); const upload = multer(); diff --git a/api/server/routes/presets.js b/api/server/routes/presets.js index 19214a3a7d..b8be1b8f3a 100644 --- a/api/server/routes/presets.js +++ b/api/server/routes/presets.js @@ -1,8 +1,8 @@ -const express = require('express'); const crypto = require('crypto'); +const express = require('express'); +const { logger } = require('@librechat/data-schemas'); const { getPresets, savePreset, deletePresets } = require('~/models'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); -const { logger } = require('~/config'); const router = express.Router(); router.use(requireJwtAuth); diff --git a/api/server/routes/search.js b/api/server/routes/search.js index 5c7846aee1..2cd2fc3534 100644 --- a/api/server/routes/search.js +++ b/api/server/routes/search.js @@ -1,7 +1,7 @@ const express = require('express'); const { MeiliSearch } = require('meilisearch'); +const { isEnabled } = require('@librechat/api'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); -const { isEnabled } = require('~/server/utils'); const router = express.Router(); diff --git a/api/server/routes/static.js b/api/server/routes/static.js index 952ca82fb2..12dfc0ed49 100644 --- a/api/server/routes/static.js +++ b/api/server/routes/static.js @@ -1,7 +1,7 @@ const express = require('express'); +const { isEnabled } = require('@librechat/api'); const staticCache = require('../utils/staticCache'); const paths = require('~/config/paths'); -const { isEnabled } = require('~/server/utils'); const skipGzipScan = !isEnabled(process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN); diff --git a/api/server/routes/tokenizer.js b/api/server/routes/tokenizer.js index e12a86bde1..62eb31b70e 100644 --- a/api/server/routes/tokenizer.js +++ b/api/server/routes/tokenizer.js @@ -1,8 +1,9 @@ const express = require('express'); -const router = express.Router(); +const { logger } = require('@librechat/data-schemas'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const { countTokens } = require('~/server/utils'); -const { logger } = require('~/config'); + +const router = express.Router(); router.post('/', requireJwtAuth, async (req, res) => { try { diff --git a/api/server/services/Config/ldap.js b/api/server/services/Config/ldap.js index 96386d0426..09091b78a4 100644 --- a/api/server/services/Config/ldap.js +++ b/api/server/services/Config/ldap.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('~/server/utils'); +const { isEnabled } = require('@librechat/api'); /** @returns {TStartupConfig['ldap'] | undefined} */ const getLdapConfig = () => { diff --git a/api/server/services/Endpoints/anthropic/title.js b/api/server/services/Endpoints/anthropic/title.js index 0f9a5e97d0..cac39fa2be 100644 --- a/api/server/services/Endpoints/anthropic/title.js +++ b/api/server/services/Endpoints/anthropic/title.js @@ -1,6 +1,6 @@ +const { isEnabled } = require('@librechat/api'); const { CacheKeys } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); -const { isEnabled } = require('~/server/utils'); const { saveConvo } = require('~/models'); const addTitle = async (req, { text, response, client }) => { diff --git a/api/server/services/Endpoints/assistants/title.js b/api/server/services/Endpoints/assistants/title.js index 605d174130..223d3badc6 100644 --- a/api/server/services/Endpoints/assistants/title.js +++ b/api/server/services/Endpoints/assistants/title.js @@ -1,7 +1,7 @@ +const { isEnabled } = require('@librechat/api'); const { CacheKeys } = require('librechat-data-provider'); const { saveConvo } = require('~/models/Conversation'); const getLogStores = require('~/cache/getLogStores'); -const { isEnabled } = require('~/server/utils'); const addTitle = async (req, { text, responseText, conversationId, client }) => { const { TITLE_CONVO = 'true' } = process.env ?? {}; diff --git a/api/server/services/Endpoints/openAI/title.js b/api/server/services/Endpoints/openAI/title.js index 3b9e9c82b7..f8624ef657 100644 --- a/api/server/services/Endpoints/openAI/title.js +++ b/api/server/services/Endpoints/openAI/title.js @@ -1,6 +1,6 @@ +const { isEnabled } = require('@librechat/api'); const { CacheKeys } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); -const { isEnabled } = require('~/server/utils'); const { saveConvo } = require('~/models'); const addTitle = async (req, { text, response, client }) => { diff --git a/api/server/services/Files/Azure/crud.js b/api/server/services/Files/Azure/crud.js index cb52de8317..3e091d1031 100644 --- a/api/server/services/Files/Azure/crud.js +++ b/api/server/services/Files/Azure/crud.js @@ -3,7 +3,7 @@ const path = require('path'); const mime = require('mime'); const axios = require('axios'); const fetch = require('node-fetch'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); const { getAzureContainerClient } = require('./initialize'); const defaultBasePath = 'images'; diff --git a/api/server/services/Files/Azure/initialize.js b/api/server/services/Files/Azure/initialize.js index 56df24d04a..8c63b48122 100644 --- a/api/server/services/Files/Azure/initialize.js +++ b/api/server/services/Files/Azure/initialize.js @@ -1,5 +1,5 @@ +const { logger } = require('@librechat/data-schemas'); const { BlobServiceClient } = require('@azure/storage-blob'); -const { logger } = require('~/config'); let blobServiceClient = null; let azureWarningLogged = false; diff --git a/api/server/services/Files/Firebase/initialize.js b/api/server/services/Files/Firebase/initialize.js index 67d923c44f..efe66be120 100644 --- a/api/server/services/Files/Firebase/initialize.js +++ b/api/server/services/Files/Firebase/initialize.js @@ -1,6 +1,6 @@ const firebase = require('firebase/app'); const { getStorage } = require('firebase/storage'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); let i = 0; let firebaseApp = null; diff --git a/api/server/services/Files/OpenAI/crud.js b/api/server/services/Files/OpenAI/crud.js index 9afe217f60..674c493a34 100644 --- a/api/server/services/Files/OpenAI/crud.js +++ b/api/server/services/Files/OpenAI/crud.js @@ -1,7 +1,7 @@ const fs = require('fs'); +const { sleep } = require('@librechat/agents'); +const { logger } = require('@librechat/data-schemas'); const { FilePurpose } = require('librechat-data-provider'); -const { sleep } = require('~/server/utils'); -const { logger } = require('~/config'); /** * Uploads a file that can be used across various OpenAI services. diff --git a/api/server/services/Files/S3/crud.js b/api/server/services/Files/S3/crud.js index 78220ed30b..7b31a4d24b 100644 --- a/api/server/services/Files/S3/crud.js +++ b/api/server/services/Files/S3/crud.js @@ -1,5 +1,6 @@ const fs = require('fs'); const fetch = require('node-fetch'); +const { logger } = require('@librechat/data-schemas'); const { FileSources } = require('librechat-data-provider'); const { PutObjectCommand, @@ -9,7 +10,6 @@ const { } = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { initializeS3 } = require('./initialize'); -const { logger } = require('~/config'); const bucketName = process.env.AWS_BUCKET_NAME; const defaultBasePath = 'images'; diff --git a/api/server/services/Files/S3/initialize.js b/api/server/services/Files/S3/initialize.js index 2daec25235..59a2568b47 100644 --- a/api/server/services/Files/S3/initialize.js +++ b/api/server/services/Files/S3/initialize.js @@ -1,5 +1,5 @@ const { S3Client } = require('@aws-sdk/client-s3'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); let s3 = null; diff --git a/api/server/services/Files/images/avatar.js b/api/server/services/Files/images/avatar.js index 8e81dea26c..9ce11c04ec 100644 --- a/api/server/services/Files/images/avatar.js +++ b/api/server/services/Files/images/avatar.js @@ -1,9 +1,9 @@ const sharp = require('sharp'); const fs = require('fs').promises; const fetch = require('node-fetch'); +const { logger } = require('@librechat/data-schemas'); const { EImageOutputType } = require('librechat-data-provider'); const { resizeAndConvert } = require('./resize'); -const { logger } = require('~/config'); /** * Uploads an avatar image for a user. This function can handle various types of input (URL, Buffer, or File object), diff --git a/api/server/services/Files/images/convert.js b/api/server/services/Files/images/convert.js index 446de5ba1c..d5f5c2893b 100644 --- a/api/server/services/Files/images/convert.js +++ b/api/server/services/Files/images/convert.js @@ -1,9 +1,9 @@ const fs = require('fs'); const path = require('path'); const sharp = require('sharp'); +const { logger } = require('@librechat/data-schemas'); const { getStrategyFunctions } = require('../strategies'); const { resizeImageBuffer } = require('./resize'); -const { logger } = require('~/config'); /** * Converts an image file or buffer to target output type with specified resolution. diff --git a/api/server/services/GraphTokenService.js b/api/server/services/GraphTokenService.js index d70e7c01c5..d5cd6a94f2 100644 --- a/api/server/services/GraphTokenService.js +++ b/api/server/services/GraphTokenService.js @@ -1,8 +1,8 @@ -const { getOpenIdConfig } = require('~/strategies/openidStrategy'); -const { logger } = require('~/config'); -const { CacheKeys } = require('librechat-data-provider'); -const getLogStores = require('~/cache/getLogStores'); const client = require('openid-client'); +const { logger } = require('@librechat/data-schemas'); +const { CacheKeys } = require('librechat-data-provider'); +const { getOpenIdConfig } = require('~/strategies/openidStrategy'); +const getLogStores = require('~/cache/getLogStores'); /** * Get Microsoft Graph API token using existing token exchange mechanism diff --git a/api/server/services/Runs/RunManager.js b/api/server/services/Runs/RunManager.js index 39e577fb51..7a3ec808df 100644 --- a/api/server/services/Runs/RunManager.js +++ b/api/server/services/Runs/RunManager.js @@ -1,5 +1,5 @@ +const { logger } = require('@librechat/data-schemas'); const { ToolCallTypes } = require('librechat-data-provider'); -const { logger } = require('~/config'); /** * @typedef {import('openai').OpenAI} OpenAI diff --git a/api/server/services/Runs/handle.js b/api/server/services/Runs/handle.js index abe933f92a..c515fabfe4 100644 --- a/api/server/services/Runs/handle.js +++ b/api/server/services/Runs/handle.js @@ -1,9 +1,9 @@ +const { sleep } = require('@librechat/agents'); +const { logger } = require('@librechat/data-schemas'); const { RunStatus, defaultOrderQuery, CacheKeys } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); const { retrieveRun } = require('./methods'); -const { sleep } = require('~/server/utils'); const RunManager = require('./RunManager'); -const { logger } = require('~/config'); async function withTimeout(promise, timeoutMs, timeoutMessage) { let timeoutHandle; diff --git a/api/server/services/cleanup.js b/api/server/services/cleanup.js index 814c0ecc94..7d3dfdec12 100644 --- a/api/server/services/cleanup.js +++ b/api/server/services/cleanup.js @@ -1,5 +1,6 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); const { deleteNullOrEmptyConversations } = require('~/models/Conversation'); + const cleanup = async () => { try { await deleteNullOrEmptyConversations(); diff --git a/api/server/utils/countTokens.js b/api/server/utils/countTokens.js index 641e386101..504de26a5e 100644 --- a/api/server/utils/countTokens.js +++ b/api/server/utils/countTokens.js @@ -1,7 +1,7 @@ const { Tiktoken } = require('tiktoken/lite'); +const { logger } = require('@librechat/data-schemas'); const p50k_base = require('tiktoken/encoders/p50k_base.json'); const cl100k_base = require('tiktoken/encoders/cl100k_base.json'); -const logger = require('~/config/winston'); /** * Counts the number of tokens in a given text using a specified encoding model. diff --git a/api/server/utils/handleText.js b/api/server/utils/handleText.js index 91c2ff25ec..15c2db3fcc 100644 --- a/api/server/utils/handleText.js +++ b/api/server/utils/handleText.js @@ -125,34 +125,6 @@ function formatAction(action) { return formattedAction; } -/** - * Checks if the given value is truthy by being either the boolean `true` or a string - * that case-insensitively matches 'true'. - * - * @function - * @param {string|boolean|null|undefined} value - The value to check. - * @returns {boolean} Returns `true` if the value is the boolean `true` or a case-insensitive - * match for the string 'true', otherwise returns `false`. - * @example - * - * isEnabled("True"); // returns true - * isEnabled("TRUE"); // returns true - * isEnabled(true); // returns true - * isEnabled("false"); // returns false - * isEnabled(false); // returns false - * isEnabled(null); // returns false - * isEnabled(); // returns false - */ -function isEnabled(value) { - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'string') { - return value.toLowerCase().trim() === 'true'; - } - return false; -} - /** * Checks if the provided value is 'user_provided'. * @@ -207,7 +179,6 @@ function generateConfig(key, baseURL, endpoint) { } module.exports = { - isEnabled, handleText, formatSteps, escapeRegExp, diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js index 5aa2599fe3..c4ce8cb5d4 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -1,10 +1,10 @@ const { v4: uuidv4 } = require('uuid'); +const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider'); const { createImportBatchBuilder } = require('./importBatchBuilder'); const BaseClient = require('~/app/clients/BaseClient'); const { getConvo } = require('~/models/Conversation'); const { getMessages } = require('~/models/Message'); -const logger = require('~/config/winston'); /** * Helper function to clone messages with proper parent-child relationships and timestamps diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js index d20f200939..5e499043d2 100644 --- a/api/server/utils/import/importBatchBuilder.js +++ b/api/server/utils/import/importBatchBuilder.js @@ -1,9 +1,9 @@ const { v4: uuidv4 } = require('uuid'); +const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); const { bulkIncrementTagCounts } = require('~/models/ConversationTag'); const { bulkSaveConvos } = require('~/models/Conversation'); const { bulkSaveMessages } = require('~/models/Message'); -const { logger } = require('~/config'); /** * Factory function for creating an instance of ImportBatchBuilder. diff --git a/api/server/utils/queue.js b/api/server/utils/queue.js index c32adaeffd..73d819205c 100644 --- a/api/server/utils/queue.js +++ b/api/server/utils/queue.js @@ -53,17 +53,6 @@ function LB_QueueAsyncCall(asyncFunc, args, callback) { } } -/** - * Delays the execution for a specified number of milliseconds. - * - * @param {number} ms - The number of milliseconds to delay. - * @return {Promise} A promise that resolves after the specified delay. - */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - module.exports = { - sleep, LB_QueueAsyncCall, }; diff --git a/api/strategies/appleStrategy.js b/api/strategies/appleStrategy.js index 4dbac2e364..fbba2a1f41 100644 --- a/api/strategies/appleStrategy.js +++ b/api/strategies/appleStrategy.js @@ -1,7 +1,7 @@ -const socialLogin = require('./socialLogin'); -const { Strategy: AppleStrategy } = require('passport-apple'); -const { logger } = require('~/config'); const jwt = require('jsonwebtoken'); +const { logger } = require('@librechat/data-schemas'); +const { Strategy: AppleStrategy } = require('passport-apple'); +const socialLogin = require('./socialLogin'); /** * Extract profile details from the decoded idToken diff --git a/api/utils/deriveBaseURL.js b/api/utils/deriveBaseURL.js index c377ddf874..982c2c8c2e 100644 --- a/api/utils/deriveBaseURL.js +++ b/api/utils/deriveBaseURL.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); /** * Extracts the base URL from the provided URL. diff --git a/api/utils/findMessageContent.js b/api/utils/findMessageContent.js index 6ee5166348..6aeed1a395 100644 --- a/api/utils/findMessageContent.js +++ b/api/utils/findMessageContent.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); function findContent(obj) { if (obj && typeof obj === 'object') { diff --git a/docker-compose.yml b/docker-compose.yml index dfd11af09f..9a3e4bbd5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: target: /app/.env - ./images:/app/client/public/images - ./uploads:/app/uploads - - ./logs:/app/api/logs + - ./logs:/app/logs mongodb: container_name: chat-mongodb image: mongo From 341435fb258c0458e6640871b4f4fb5fe0876041 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:31:23 -0400 Subject: [PATCH 002/272] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#9932)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/de/translation.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/locales/de/translation.json b/client/src/locales/de/translation.json index 24d431c86e..2aeada60bc 100644 --- a/client/src/locales/de/translation.json +++ b/client/src/locales/de/translation.json @@ -60,6 +60,7 @@ "com_agents_error_timeout_title": "Verbindungs-Timeout", "com_agents_error_title": "Es ist ein Fehler aufgetreten", "com_agents_file_context_disabled": "Der Agent muss vor dem Hochladen von Dateien für den Datei-Kontext erstellt werden.", + "com_agents_file_context_label": "Dateikontext", "com_agents_file_search_disabled": "Der Agent muss erstellt werden, bevor Dateien für die Dateisuche hochgeladen werden können.", "com_agents_file_search_info": "Wenn aktiviert, wird der Agent über die unten aufgelisteten exakten Dateinamen informiert und kann dadurch relevante Informationen aus diesen Dateien abrufen", "com_agents_grid_announcement": "Zeige {{count}} Agenten in der Kategorie {{category}}", @@ -845,7 +846,7 @@ "com_ui_download_error": "Fehler beim Herunterladen der Datei. Die Datei wurde möglicherweise gelöscht.", "com_ui_drag_drop": "Ziehe eine beliebige Datei hierher, um sie zur Unterhaltung hinzuzufügen.", "com_ui_dropdown_variables": "Dropdown-Variablen:", - "com_ui_dropdown_variables_info": "Erstelle benutzerdefinierte Dropdown-Menüs für deine Prompts:: `{{variable_name:option1|option2|option3}}`", + "com_ui_dropdown_variables_info": "Erstelle benutzerdefinierte Dropdown-Menüs für deine Prompts: `{{variable_name:option1|option2|option3}}`", "com_ui_duplicate": "Duplizieren", "com_ui_duplication_error": "Beim Duplizieren der Konversation ist ein Fehler aufgetreten", "com_ui_duplication_processing": "Konversation wird dupliziert...", @@ -1038,6 +1039,7 @@ "com_ui_oauth_error_missing_code": "Autorisierungscode fehlt. Bitte versuchen Sie es erneut.", "com_ui_oauth_error_missing_state": "Statusparameter fehlt. Bitte versuchen Sie es erneut.", "com_ui_oauth_error_title": "Authentifizierung fehlgeschlagen", + "com_ui_oauth_revoke": "Widerrufen", "com_ui_oauth_success_description": "Ihre Authentifizierung war erfolgreich. Dieses Fenster schliesst sich in", "com_ui_oauth_success_title": "Authentifizierung erfolgreich", "com_ui_of": "von", From 0e5bb6f98cc3ab7fd407672b497398fd6ada60dd Mon Sep 17 00:00:00 2001 From: "Theo N. Truong" <644650+nhtruong@users.noreply.github.com> Date: Thu, 2 Oct 2025 07:33:58 -0600 Subject: [PATCH 003/272] =?UTF-8?q?=F0=9F=94=84=20refactor:=20Migrate=20Ca?= =?UTF-8?q?che=20Logic=20to=20TypeScript=20(#9771)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor: Moved Redis cache infra logic into `packages/api` - Moved cacheFactory and redisClients from `api/cache` into `packages/api/src/cache` so that features in `packages/api` can use cache without importing backward from the backend. - Converted all moved files into TS with proper typing. - Created integration tests to run against actual Redis servers for redisClients and cacheFactory. - Added a GitHub workflow to run integration tests for the cache feature. - Bug fix: keyvRedisClient now implements the PING feature properly. * chore: consolidate imports in getLogStores.js * chore: reorder imports * chore: re-add fs-extra as dev dep. * chore: reorder imports in cacheConfig.ts, cacheFactory.ts, and keyvMongo.ts --------- Co-authored-by: Danny Avila --- .github/workflows/cache-integration-tests.yml | 78 ++ api/cache/cacheFactory.js | 108 --- api/cache/cacheFactory.spec.js | 432 ---------- api/cache/getLogStores.js | 14 +- api/cache/index.js | 3 +- api/cache/keyvFiles.js | 9 - api/package.json | 7 - api/server/middleware/checkBan.js | 3 +- .../middleware/limiters/forkLimiters.js | 2 +- .../middleware/limiters/importLimiters.js | 2 +- .../middleware/limiters/loginLimiter.js | 2 +- .../middleware/limiters/messageLimiters.js | 2 +- .../middleware/limiters/registerLimiter.js | 2 +- .../limiters/resetPasswordLimiter.js | 2 +- api/server/middleware/limiters/sttLimiters.js | 2 +- .../middleware/limiters/toolCallLimiter.js | 2 +- api/server/middleware/limiters/ttsLimiters.js | 2 +- .../middleware/limiters/uploadLimiters.js | 2 +- .../middleware/limiters/verifyEmailLimiter.js | 2 +- client/package.json | 1 + package-lock.json | 783 +++++------------- packages/api/package.json | 20 +- packages/api/rollup.config.js | 1 + .../src/cache/__tests__/cacheConfig.spec.ts | 115 ++- .../limiterCache.integration.spec.ts | 113 +++ .../sessionCache.integration.spec.ts | 211 +++++ .../standardCache.integration.spec.ts | 185 +++++ .../violationCache.integration.spec.ts | 241 ++++++ .../redisClients.integration.spec.ts | 168 ++++ .../api/src/cache/cacheConfig.ts | 20 +- packages/api/src/cache/cacheFactory.ts | 116 +++ packages/api/src/cache/index.ts | 5 + packages/api/src/cache/keyvFiles.ts | 6 + .../api/src/cache/keyvMongo.ts | 135 +-- .../api/src/cache/redisClients.ts | 75 +- packages/api/src/index.ts | 2 + packages/api/src/utils/math.ts | 4 +- redis-config/start-cluster.sh | 15 +- 38 files changed, 1552 insertions(+), 1340 deletions(-) create mode 100644 .github/workflows/cache-integration-tests.yml delete mode 100644 api/cache/cacheFactory.js delete mode 100644 api/cache/cacheFactory.spec.js delete mode 100644 api/cache/keyvFiles.js rename api/cache/cacheConfig.spec.js => packages/api/src/cache/__tests__/cacheConfig.spec.ts (59%) create mode 100644 packages/api/src/cache/__tests__/cacheFactory/limiterCache.integration.spec.ts create mode 100644 packages/api/src/cache/__tests__/cacheFactory/sessionCache.integration.spec.ts create mode 100644 packages/api/src/cache/__tests__/cacheFactory/standardCache.integration.spec.ts create mode 100644 packages/api/src/cache/__tests__/cacheFactory/violationCache.integration.spec.ts create mode 100644 packages/api/src/cache/__tests__/redisClients.integration.spec.ts rename api/cache/cacheConfig.js => packages/api/src/cache/cacheConfig.ts (87%) create mode 100644 packages/api/src/cache/cacheFactory.ts create mode 100644 packages/api/src/cache/index.ts create mode 100644 packages/api/src/cache/keyvFiles.ts rename api/cache/keyvMongo.js => packages/api/src/cache/keyvMongo.ts (66%) rename api/cache/redisClients.js => packages/api/src/cache/redisClients.ts (73%) diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml new file mode 100644 index 0000000000..2afe68287e --- /dev/null +++ b/.github/workflows/cache-integration-tests.yml @@ -0,0 +1,78 @@ +name: Cache Integration Tests + +on: + pull_request: + branches: + - main + - dev + - release/* + paths: + - 'packages/api/src/cache/**' + - 'redis-config/**' + - '.github/workflows/cache-integration-tests.yml' + +jobs: + cache_integration_tests: + name: Run Cache Integration Tests + timeout-minutes: 30 + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Use Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install Redis tools + run: | + sudo apt-get update + sudo apt-get install -y redis-server redis-tools + + - name: Start Single Redis Instance + run: | + redis-server --daemonize yes --port 6379 + sleep 2 + # Verify single Redis is running + redis-cli -p 6379 ping || exit 1 + + - name: Start Redis Cluster + working-directory: redis-config + run: | + chmod +x start-cluster.sh stop-cluster.sh + ./start-cluster.sh + sleep 10 + # Verify cluster is running + redis-cli -p 7001 cluster info || exit 1 + redis-cli -p 7002 cluster info || exit 1 + redis-cli -p 7003 cluster info || exit 1 + + - name: Install dependencies + run: npm ci + + - name: Build packages + run: | + npm run build:data-provider + npm run build:data-schemas + npm run build:api + + - name: Run cache integration tests + working-directory: packages/api + env: + NODE_ENV: test + USE_REDIS: true + REDIS_URI: redis://127.0.0.1:6379 + REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003 + run: npm run test:cache:integration + + - name: Stop Redis Cluster + if: always() + working-directory: redis-config + run: ./stop-cluster.sh || true + + - name: Stop Single Redis Instance + if: always() + run: redis-cli -p 6379 shutdown || true \ No newline at end of file diff --git a/api/cache/cacheFactory.js b/api/cache/cacheFactory.js deleted file mode 100644 index bc361d6615..0000000000 --- a/api/cache/cacheFactory.js +++ /dev/null @@ -1,108 +0,0 @@ -const KeyvRedis = require('@keyv/redis').default; -const { Keyv } = require('keyv'); -const { RedisStore } = require('rate-limit-redis'); -const { Time } = require('librechat-data-provider'); -const { logger } = require('@librechat/data-schemas'); -const { RedisStore: ConnectRedis } = require('connect-redis'); -const MemoryStore = require('memorystore')(require('express-session')); -const { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } = require('./redisClients'); -const { cacheConfig } = require('./cacheConfig'); -const { violationFile } = require('./keyvFiles'); - -/** - * Creates a cache instance using Redis or a fallback store. Suitable for general caching needs. - * @param {string} namespace - The cache namespace. - * @param {number} [ttl] - Time to live for cache entries. - * @param {object} [fallbackStore] - Optional fallback store if Redis is not used. - * @returns {Keyv} Cache instance. - */ -const standardCache = (namespace, ttl = undefined, fallbackStore = undefined) => { - if ( - cacheConfig.USE_REDIS && - !cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace) - ) { - try { - const keyvRedis = new KeyvRedis(keyvRedisClient); - const cache = new Keyv(keyvRedis, { namespace, ttl }); - keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX; - keyvRedis.keyPrefixSeparator = GLOBAL_PREFIX_SEPARATOR; - - cache.on('error', (err) => { - logger.error(`Cache error in namespace ${namespace}:`, err); - }); - - return cache; - } catch (err) { - logger.error(`Failed to create Redis cache for namespace ${namespace}:`, err); - throw err; - } - } - if (fallbackStore) return new Keyv({ store: fallbackStore, namespace, ttl }); - return new Keyv({ namespace, ttl }); -}; - -/** - * Creates a cache instance for storing violation data. - * Uses a file-based fallback store if Redis is not enabled. - * @param {string} namespace - The cache namespace for violations. - * @param {number} [ttl] - Time to live for cache entries. - * @returns {Keyv} Cache instance for violations. - */ -const violationCache = (namespace, ttl = undefined) => { - return standardCache(`violations:${namespace}`, ttl, violationFile); -}; - -/** - * Creates a session cache instance using Redis or in-memory store. - * @param {string} namespace - The session namespace. - * @param {number} [ttl] - Time to live for session entries. - * @returns {MemoryStore | ConnectRedis} Session store instance. - */ -const sessionCache = (namespace, ttl = undefined) => { - namespace = namespace.endsWith(':') ? namespace : `${namespace}:`; - if (!cacheConfig.USE_REDIS) return new MemoryStore({ ttl, checkPeriod: Time.ONE_DAY }); - const store = new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace }); - if (ioredisClient) { - ioredisClient.on('error', (err) => { - logger.error(`Session store Redis error for namespace ${namespace}:`, err); - }); - } - return store; -}; - -/** - * Creates a rate limiter cache using Redis. - * @param {string} prefix - The key prefix for rate limiting. - * @returns {RedisStore|undefined} RedisStore instance or undefined if Redis is not used. - */ -const limiterCache = (prefix) => { - if (!prefix) throw new Error('prefix is required'); - if (!cacheConfig.USE_REDIS) return undefined; - prefix = prefix.endsWith(':') ? prefix : `${prefix}:`; - - try { - if (!ioredisClient) { - logger.warn(`Redis client not available for rate limiter with prefix ${prefix}`); - return undefined; - } - - return new RedisStore({ sendCommand, prefix }); - } catch (err) { - logger.error(`Failed to create Redis rate limiter for prefix ${prefix}:`, err); - return undefined; - } -}; - -const sendCommand = (...args) => { - if (!ioredisClient) { - logger.warn('Redis client not available for command execution'); - return Promise.reject(new Error('Redis client not available')); - } - - return ioredisClient.call(...args).catch((err) => { - logger.error('Redis command execution failed:', err); - throw err; - }); -}; - -module.exports = { standardCache, sessionCache, violationCache, limiterCache }; diff --git a/api/cache/cacheFactory.spec.js b/api/cache/cacheFactory.spec.js deleted file mode 100644 index ce364a4a32..0000000000 --- a/api/cache/cacheFactory.spec.js +++ /dev/null @@ -1,432 +0,0 @@ -const { Time } = require('librechat-data-provider'); - -// Mock dependencies first -const mockKeyvRedis = { - namespace: '', - keyPrefixSeparator: '', -}; - -const mockKeyv = jest.fn().mockReturnValue({ - mock: 'keyv', - on: jest.fn(), -}); -const mockConnectRedis = jest.fn().mockReturnValue({ mock: 'connectRedis' }); -const mockMemoryStore = jest.fn().mockReturnValue({ mock: 'memoryStore' }); -const mockRedisStore = jest.fn().mockReturnValue({ mock: 'redisStore' }); - -const mockIoredisClient = { - call: jest.fn(), - on: jest.fn(), -}; - -const mockKeyvRedisClient = {}; -const mockViolationFile = {}; - -// Mock modules before requiring the main module -jest.mock('@keyv/redis', () => ({ - default: jest.fn().mockImplementation(() => mockKeyvRedis), -})); - -jest.mock('keyv', () => ({ - Keyv: mockKeyv, -})); - -jest.mock('./cacheConfig', () => ({ - cacheConfig: { - USE_REDIS: false, - REDIS_KEY_PREFIX: 'test', - FORCED_IN_MEMORY_CACHE_NAMESPACES: [], - }, -})); - -jest.mock('./redisClients', () => ({ - keyvRedisClient: mockKeyvRedisClient, - ioredisClient: mockIoredisClient, - GLOBAL_PREFIX_SEPARATOR: '::', -})); - -jest.mock('./keyvFiles', () => ({ - violationFile: mockViolationFile, -})); - -jest.mock('connect-redis', () => ({ RedisStore: mockConnectRedis })); - -jest.mock('memorystore', () => jest.fn(() => mockMemoryStore)); - -jest.mock('rate-limit-redis', () => ({ - RedisStore: mockRedisStore, -})); - -jest.mock('@librechat/data-schemas', () => ({ - logger: { - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - }, -})); - -// Import after mocking -const { standardCache, sessionCache, violationCache, limiterCache } = require('./cacheFactory'); -const { cacheConfig } = require('./cacheConfig'); - -describe('cacheFactory', () => { - beforeEach(() => { - jest.clearAllMocks(); - - // Reset cache config mock - cacheConfig.USE_REDIS = false; - cacheConfig.REDIS_KEY_PREFIX = 'test'; - cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = []; - }); - - describe('redisCache', () => { - it('should create Redis cache when USE_REDIS is true', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'test-namespace'; - const ttl = 3600; - - standardCache(namespace, ttl); - - expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient); - expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl }); - expect(mockKeyvRedis.namespace).toBe(cacheConfig.REDIS_KEY_PREFIX); - expect(mockKeyvRedis.keyPrefixSeparator).toBe('::'); - }); - - it('should create Redis cache with undefined ttl when not provided', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'test-namespace'; - - standardCache(namespace); - - expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl: undefined }); - }); - - it('should use fallback store when USE_REDIS is false and fallbackStore is provided', () => { - cacheConfig.USE_REDIS = false; - const namespace = 'test-namespace'; - const ttl = 3600; - const fallbackStore = { some: 'store' }; - - standardCache(namespace, ttl, fallbackStore); - - expect(mockKeyv).toHaveBeenCalledWith({ store: fallbackStore, namespace, ttl }); - }); - - it('should create default Keyv instance when USE_REDIS is false and no fallbackStore', () => { - cacheConfig.USE_REDIS = false; - const namespace = 'test-namespace'; - const ttl = 3600; - - standardCache(namespace, ttl); - - expect(mockKeyv).toHaveBeenCalledWith({ namespace, ttl }); - }); - - it('should handle namespace and ttl as undefined', () => { - cacheConfig.USE_REDIS = false; - - standardCache(); - - expect(mockKeyv).toHaveBeenCalledWith({ namespace: undefined, ttl: undefined }); - }); - - it('should use fallback when namespace is in FORCED_IN_MEMORY_CACHE_NAMESPACES', () => { - cacheConfig.USE_REDIS = true; - cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = ['forced-memory']; - const namespace = 'forced-memory'; - const ttl = 3600; - - standardCache(namespace, ttl); - - expect(require('@keyv/redis').default).not.toHaveBeenCalled(); - expect(mockKeyv).toHaveBeenCalledWith({ namespace, ttl }); - }); - - it('should use Redis when namespace is not in FORCED_IN_MEMORY_CACHE_NAMESPACES', () => { - cacheConfig.USE_REDIS = true; - cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = ['other-namespace']; - const namespace = 'test-namespace'; - const ttl = 3600; - - standardCache(namespace, ttl); - - expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient); - expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl }); - }); - - it('should throw error when Redis cache creation fails', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'test-namespace'; - const ttl = 3600; - const testError = new Error('Redis connection failed'); - - const KeyvRedis = require('@keyv/redis').default; - KeyvRedis.mockImplementationOnce(() => { - throw testError; - }); - - expect(() => standardCache(namespace, ttl)).toThrow('Redis connection failed'); - - const { logger } = require('@librechat/data-schemas'); - expect(logger.error).toHaveBeenCalledWith( - `Failed to create Redis cache for namespace ${namespace}:`, - testError, - ); - - expect(mockKeyv).not.toHaveBeenCalled(); - }); - }); - - describe('violationCache', () => { - it('should create violation cache with prefixed namespace', () => { - const namespace = 'test-violations'; - const ttl = 7200; - - // We can't easily mock the internal redisCache call since it's in the same module - // But we can test that the function executes without throwing - expect(() => violationCache(namespace, ttl)).not.toThrow(); - }); - - it('should create violation cache with undefined ttl', () => { - const namespace = 'test-violations'; - - violationCache(namespace); - - // The function should call redisCache with violations: prefixed namespace - // Since we can't easily mock the internal redisCache call, we test the behavior - expect(() => violationCache(namespace)).not.toThrow(); - }); - - it('should handle undefined namespace', () => { - expect(() => violationCache(undefined)).not.toThrow(); - }); - }); - - describe('sessionCache', () => { - it('should return MemoryStore when USE_REDIS is false', () => { - cacheConfig.USE_REDIS = false; - const namespace = 'sessions'; - const ttl = 86400; - - const result = sessionCache(namespace, ttl); - - expect(mockMemoryStore).toHaveBeenCalledWith({ ttl, checkPeriod: Time.ONE_DAY }); - expect(result).toBe(mockMemoryStore()); - }); - - it('should return ConnectRedis when USE_REDIS is true', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions'; - const ttl = 86400; - - const result = sessionCache(namespace, ttl); - - expect(mockConnectRedis).toHaveBeenCalledWith({ - client: mockIoredisClient, - ttl, - prefix: `${namespace}:`, - }); - expect(result).toBe(mockConnectRedis()); - }); - - it('should add colon to namespace if not present', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions'; - - sessionCache(namespace); - - expect(mockConnectRedis).toHaveBeenCalledWith({ - client: mockIoredisClient, - ttl: undefined, - prefix: 'sessions:', - }); - }); - - it('should not add colon to namespace if already present', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions:'; - - sessionCache(namespace); - - expect(mockConnectRedis).toHaveBeenCalledWith({ - client: mockIoredisClient, - ttl: undefined, - prefix: 'sessions:', - }); - }); - - it('should handle undefined ttl', () => { - cacheConfig.USE_REDIS = false; - const namespace = 'sessions'; - - sessionCache(namespace); - - expect(mockMemoryStore).toHaveBeenCalledWith({ - ttl: undefined, - checkPeriod: Time.ONE_DAY, - }); - }); - - it('should throw error when ConnectRedis constructor fails', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions'; - const ttl = 86400; - - // Mock ConnectRedis to throw an error during construction - const redisError = new Error('Redis connection failed'); - mockConnectRedis.mockImplementationOnce(() => { - throw redisError; - }); - - // The error should propagate up, not be caught - expect(() => sessionCache(namespace, ttl)).toThrow('Redis connection failed'); - - // Verify that MemoryStore was NOT used as fallback - expect(mockMemoryStore).not.toHaveBeenCalled(); - }); - - it('should register error handler but let errors propagate to Express', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions'; - - // Create a mock session store with middleware methods - const mockSessionStore = { - get: jest.fn(), - set: jest.fn(), - destroy: jest.fn(), - }; - mockConnectRedis.mockReturnValue(mockSessionStore); - - const store = sessionCache(namespace); - - // Verify error handler was registered - expect(mockIoredisClient.on).toHaveBeenCalledWith('error', expect.any(Function)); - - // Get the error handler - const errorHandler = mockIoredisClient.on.mock.calls.find((call) => call[0] === 'error')[1]; - - // Simulate an error from Redis during a session operation - const redisError = new Error('Socket closed unexpectedly'); - - // The error handler should log but not swallow the error - const { logger } = require('@librechat/data-schemas'); - errorHandler(redisError); - - expect(logger.error).toHaveBeenCalledWith( - `Session store Redis error for namespace ${namespace}::`, - redisError, - ); - - // Now simulate what happens when session middleware tries to use the store - const callback = jest.fn(); - mockSessionStore.get.mockImplementation((sid, cb) => { - cb(new Error('Redis connection lost')); - }); - - // Call the store's get method (as Express session would) - store.get('test-session-id', callback); - - // The error should be passed to the callback, not swallowed - expect(callback).toHaveBeenCalledWith(new Error('Redis connection lost')); - }); - - it('should handle null ioredisClient gracefully', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions'; - - // Temporarily set ioredisClient to null (simulating connection not established) - const originalClient = require('./redisClients').ioredisClient; - require('./redisClients').ioredisClient = null; - - // ConnectRedis might accept null client but would fail on first use - // The important thing is it doesn't throw uncaught exceptions during construction - const store = sessionCache(namespace); - expect(store).toBeDefined(); - - // Restore original client - require('./redisClients').ioredisClient = originalClient; - }); - }); - - describe('limiterCache', () => { - it('should return undefined when USE_REDIS is false', () => { - cacheConfig.USE_REDIS = false; - const result = limiterCache('prefix'); - - expect(result).toBeUndefined(); - }); - - it('should return RedisStore when USE_REDIS is true', () => { - cacheConfig.USE_REDIS = true; - const result = limiterCache('rate-limit'); - - expect(mockRedisStore).toHaveBeenCalledWith({ - sendCommand: expect.any(Function), - prefix: `rate-limit:`, - }); - expect(result).toBe(mockRedisStore()); - }); - - it('should add colon to prefix if not present', () => { - cacheConfig.USE_REDIS = true; - limiterCache('rate-limit'); - - expect(mockRedisStore).toHaveBeenCalledWith({ - sendCommand: expect.any(Function), - prefix: 'rate-limit:', - }); - }); - - it('should not add colon to prefix if already present', () => { - cacheConfig.USE_REDIS = true; - limiterCache('rate-limit:'); - - expect(mockRedisStore).toHaveBeenCalledWith({ - sendCommand: expect.any(Function), - prefix: 'rate-limit:', - }); - }); - - it('should pass sendCommand function that calls ioredisClient.call', async () => { - cacheConfig.USE_REDIS = true; - mockIoredisClient.call.mockResolvedValue('test-value'); - - limiterCache('rate-limit'); - - const sendCommandCall = mockRedisStore.mock.calls[0][0]; - const sendCommand = sendCommandCall.sendCommand; - - // Test that sendCommand properly delegates to ioredisClient.call - const args = ['GET', 'test-key']; - const result = await sendCommand(...args); - - expect(mockIoredisClient.call).toHaveBeenCalledWith(...args); - expect(result).toBe('test-value'); - }); - - it('should handle sendCommand errors properly', async () => { - cacheConfig.USE_REDIS = true; - - // Mock the call method to reject with an error - const testError = new Error('Redis error'); - mockIoredisClient.call.mockRejectedValue(testError); - - limiterCache('rate-limit'); - - const sendCommandCall = mockRedisStore.mock.calls[0][0]; - const sendCommand = sendCommandCall.sendCommand; - - // Test that sendCommand properly handles errors - const args = ['GET', 'test-key']; - - await expect(sendCommand(...args)).rejects.toThrow('Redis error'); - expect(mockIoredisClient.call).toHaveBeenCalledWith(...args); - }); - - it('should handle undefined prefix', () => { - cacheConfig.USE_REDIS = true; - expect(() => limiterCache()).toThrow('prefix is required'); - }); - }); -}); diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index aebe9e6c20..40aac08ee6 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -1,9 +1,13 @@ -const { cacheConfig } = require('./cacheConfig'); const { Keyv } = require('keyv'); -const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider'); -const { logFile } = require('./keyvFiles'); -const keyvMongo = require('./keyvMongo'); -const { standardCache, sessionCache, violationCache } = require('./cacheFactory'); +const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider'); +const { + logFile, + keyvMongo, + cacheConfig, + sessionCache, + standardCache, + violationCache, +} = require('@librechat/api'); const namespaces = { [ViolationTypes.GENERAL]: new Keyv({ store: logFile, namespace: 'violations' }), diff --git a/api/cache/index.js b/api/cache/index.js index bb1e774183..1bad242f13 100644 --- a/api/cache/index.js +++ b/api/cache/index.js @@ -1,5 +1,4 @@ -const keyvFiles = require('./keyvFiles'); const getLogStores = require('./getLogStores'); const logViolation = require('./logViolation'); -module.exports = { ...keyvFiles, getLogStores, logViolation }; +module.exports = { getLogStores, logViolation }; diff --git a/api/cache/keyvFiles.js b/api/cache/keyvFiles.js deleted file mode 100644 index 1476b60cb8..0000000000 --- a/api/cache/keyvFiles.js +++ /dev/null @@ -1,9 +0,0 @@ -const { KeyvFile } = require('keyv-file'); - -const logFile = new KeyvFile({ filename: './data/logs.json' }).setMaxListeners(20); -const violationFile = new KeyvFile({ filename: './data/violations.json' }).setMaxListeners(20); - -module.exports = { - logFile, - violationFile, -}; diff --git a/api/package.json b/api/package.json index 5d4015b0ee..782d958102 100644 --- a/api/package.json +++ b/api/package.json @@ -42,7 +42,6 @@ "@azure/storage-blob": "^12.27.0", "@google/generative-ai": "^0.24.0", "@googleapis/youtube": "^20.0.0", - "@keyv/redis": "^4.3.3", "@langchain/community": "^0.3.47", "@langchain/core": "^0.3.62", "@langchain/google-genai": "^0.2.13", @@ -59,7 +58,6 @@ "axios": "^1.12.1", "bcryptjs": "^2.4.3", "compression": "^1.8.1", - "connect-redis": "^8.1.0", "cookie": "^0.7.2", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -77,17 +75,13 @@ "googleapis": "^126.0.1", "handlebars": "^4.7.7", "https-proxy-agent": "^7.0.6", - "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.2.0", - "keyv": "^5.3.2", - "keyv-file": "^5.1.2", "klona": "^2.0.6", "librechat-data-provider": "*", "lodash": "^4.17.21", "meilisearch": "^0.38.0", - "memorystore": "^1.6.7", "mime": "^3.0.0", "module-alias": "^2.2.3", "mongoose": "^8.12.1", @@ -107,7 +101,6 @@ "passport-jwt": "^4.0.1", "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", - "rate-limit-redis": "^4.2.0", "sharp": "^0.33.5", "tiktoken": "^1.0.15", "traverse": "^0.6.7", diff --git a/api/server/middleware/checkBan.js b/api/server/middleware/checkBan.js index 9f735592f5..b8e680cb94 100644 --- a/api/server/middleware/checkBan.js +++ b/api/server/middleware/checkBan.js @@ -1,10 +1,9 @@ const { Keyv } = require('keyv'); const uap = require('ua-parser-js'); -const { isEnabled } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); +const { isEnabled, keyvMongo } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const { removePorts } = require('~/server/utils'); -const keyvMongo = require('~/cache/keyvMongo'); const denyRequest = require('./denyRequest'); const { getLogStores } = require('~/cache'); const { findUser } = require('~/models'); diff --git a/api/server/middleware/limiters/forkLimiters.js b/api/server/middleware/limiters/forkLimiters.js index 35fda10e94..e0aa65700c 100644 --- a/api/server/middleware/limiters/forkLimiters.js +++ b/api/server/middleware/limiters/forkLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { diff --git a/api/server/middleware/limiters/importLimiters.js b/api/server/middleware/limiters/importLimiters.js index 0d8204393f..f383e99563 100644 --- a/api/server/middleware/limiters/importLimiters.js +++ b/api/server/middleware/limiters/importLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { diff --git a/api/server/middleware/limiters/loginLimiter.js b/api/server/middleware/limiters/loginLimiter.js index cc21f68792..eef0c56bfc 100644 --- a/api/server/middleware/limiters/loginLimiter.js +++ b/api/server/middleware/limiters/loginLimiter.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const { removePorts } = require('~/server/utils'); -const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env; diff --git a/api/server/middleware/limiters/messageLimiters.js b/api/server/middleware/limiters/messageLimiters.js index 553d39959b..50f4dbc644 100644 --- a/api/server/middleware/limiters/messageLimiters.js +++ b/api/server/middleware/limiters/messageLimiters.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const denyRequest = require('~/server/middleware/denyRequest'); -const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); const { diff --git a/api/server/middleware/limiters/registerLimiter.js b/api/server/middleware/limiters/registerLimiter.js index 15c91eba37..eeebebdb42 100644 --- a/api/server/middleware/limiters/registerLimiter.js +++ b/api/server/middleware/limiters/registerLimiter.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const { removePorts } = require('~/server/utils'); -const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env; diff --git a/api/server/middleware/limiters/resetPasswordLimiter.js b/api/server/middleware/limiters/resetPasswordLimiter.js index 1905d5f2bc..d1dfe52a98 100644 --- a/api/server/middleware/limiters/resetPasswordLimiter.js +++ b/api/server/middleware/limiters/resetPasswordLimiter.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const { removePorts } = require('~/server/utils'); -const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); const { diff --git a/api/server/middleware/limiters/sttLimiters.js b/api/server/middleware/limiters/sttLimiters.js index 138e68caa7..f2f47cf680 100644 --- a/api/server/middleware/limiters/sttLimiters.js +++ b/api/server/middleware/limiters/sttLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { diff --git a/api/server/middleware/limiters/toolCallLimiter.js b/api/server/middleware/limiters/toolCallLimiter.js index 28c1f78912..56dbfff1cf 100644 --- a/api/server/middleware/limiters/toolCallLimiter.js +++ b/api/server/middleware/limiters/toolCallLimiter.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const { TOOL_CALL_VIOLATION_SCORE: score } = process.env; diff --git a/api/server/middleware/limiters/ttsLimiters.js b/api/server/middleware/limiters/ttsLimiters.js index 89742c88a8..41dd9a6ba5 100644 --- a/api/server/middleware/limiters/ttsLimiters.js +++ b/api/server/middleware/limiters/ttsLimiters.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const logViolation = require('~/cache/logViolation'); -const { limiterCache } = require('~/cache/cacheFactory'); const getEnvironmentVariables = () => { const TTS_IP_MAX = parseInt(process.env.TTS_IP_MAX) || 100; diff --git a/api/server/middleware/limiters/uploadLimiters.js b/api/server/middleware/limiters/uploadLimiters.js index 0ec4bde8d1..df6987877c 100644 --- a/api/server/middleware/limiters/uploadLimiters.js +++ b/api/server/middleware/limiters/uploadLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { diff --git a/api/server/middleware/limiters/verifyEmailLimiter.js b/api/server/middleware/limiters/verifyEmailLimiter.js index 0025c041fd..006c4df656 100644 --- a/api/server/middleware/limiters/verifyEmailLimiter.js +++ b/api/server/middleware/limiters/verifyEmailLimiter.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const { removePorts } = require('~/server/utils'); -const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); const { diff --git a/client/package.json b/client/package.json index f96aefa2d5..b46f77cbd8 100644 --- a/client/package.json +++ b/client/package.json @@ -136,6 +136,7 @@ "babel-plugin-transform-import-meta": "^2.3.2", "babel-plugin-transform-vite-meta-env": "^1.0.3", "eslint-plugin-jest": "^28.11.0", + "fs-extra": "^11.3.2", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-canvas-mock": "^2.5.2", diff --git a/package-lock.json b/package-lock.json index 5b484fd552..153120b9ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,6 @@ "@azure/storage-blob": "^12.27.0", "@google/generative-ai": "^0.24.0", "@googleapis/youtube": "^20.0.0", - "@keyv/redis": "^4.3.3", "@langchain/community": "^0.3.47", "@langchain/core": "^0.3.62", "@langchain/google-genai": "^0.2.13", @@ -75,7 +74,6 @@ "axios": "^1.12.1", "bcryptjs": "^2.4.3", "compression": "^1.8.1", - "connect-redis": "^8.1.0", "cookie": "^0.7.2", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -93,17 +91,13 @@ "googleapis": "^126.0.1", "handlebars": "^4.7.7", "https-proxy-agent": "^7.0.6", - "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.2.0", - "keyv": "^5.3.2", - "keyv-file": "^5.1.2", "klona": "^2.0.6", "librechat-data-provider": "*", "lodash": "^4.17.21", "meilisearch": "^0.38.0", - "memorystore": "^1.6.7", "mime": "^3.0.0", "module-alias": "^2.2.3", "mongoose": "^8.12.1", @@ -123,7 +117,6 @@ "passport-jwt": "^4.0.1", "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", - "rate-limit-redis": "^4.2.0", "sharp": "^0.33.5", "tiktoken": "^1.0.15", "traverse": "^0.6.7", @@ -799,19 +792,6 @@ "node": ">=18.0.0" } }, - "api/node_modules/@keyv/redis": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-4.3.3.tgz", - "integrity": "sha512-J/uhvKu/Qfh11yMUs+9KdcGCLmWFd3vMxtDVQh2j9cOcnrpnM5jE1xU+K1/kI89czSVEdeMyqTC9gGNtwi3JEQ==", - "dependencies": { - "cluster-key-slot": "^1.1.2", - "keyv": "^5.3.2", - "redis": "^4.7.0" - }, - "engines": { - "node": ">= 18" - } - }, "api/node_modules/@langchain/community": { "version": "0.3.47", "resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.3.47.tgz", @@ -2212,18 +2192,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "api/node_modules/connect-redis": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-8.1.0.tgz", - "integrity": "sha512-Km0EYLDlmExF52UCss5gLGTtrukGC57G6WCC2aqEMft5Vr4xNWuM4tL+T97kWrw+vp40SXFteb6Xk/7MxgpwdA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "express-session": ">=1" - } - }, "api/node_modules/cookie-parser": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", @@ -2236,46 +2204,6 @@ "node": ">= 0.8.0" } }, - "api/node_modules/express-session": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", - "license": "MIT", - "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.7", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-headers": "~1.1.0", - "parseurl": "~1.3.3", - "safe-buffer": "5.2.1", - "uid-safe": "~2.1.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "api/node_modules/express-session/node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "api/node_modules/express-session/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "api/node_modules/express-session/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "api/node_modules/express/node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -2424,21 +2352,6 @@ "url": "https://github.com/sponsors/panva" } }, - "api/node_modules/keyv-file": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.1.2.tgz", - "integrity": "sha512-Sx5W55HeSbmsX4BfanJwaJZd3xePoQKxfuysvIhZ3JTPoSeZjApFO1QnuXGVy9hDXpmztS5mm39wlBFOUalVgw==", - "dependencies": { - "@keyv/serialize": "^1.0.1", - "fs-extra": "^4.0.1", - "tslib": "^1.14.1" - } - }, - "api/node_modules/keyv-file/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, "api/node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -2487,15 +2400,6 @@ "node": ">= 6" } }, - "api/node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "api/node_modules/openai": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/openai/-/openai-5.10.1.tgz", @@ -2733,6 +2637,7 @@ "babel-plugin-transform-import-meta": "^2.3.2", "babel-plugin-transform-vite-meta-env": "^1.0.3", "eslint-plugin-jest": "^28.11.0", + "fs-extra": "^11.3.2", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-canvas-mock": "^2.5.2", @@ -4067,390 +3972,6 @@ "react-dom": "^16.8.0 || ^17 || ^18" } }, - "client/node_modules/@dicebear/adventurer": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.2.2.tgz", - "integrity": "sha512-WjBXCP9EXbUul2zC3BS2/R3/4diw1uh/lU4jTEnujK1mhqwIwanFboIMzQsasNNL/xf+m3OHN7MUNJfHZ1fLZA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/adventurer-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.2.2.tgz", - "integrity": "sha512-XVAjhUWjav6luTZ7txz8zVJU/H0DiUy4uU1Z7IO5MDO6kWvum+If1+0OUgEWYZwM+RDI7rt2CgVP910DyZGd1w==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/avataaars": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.2.2.tgz", - "integrity": "sha512-WqJPQEt0OhBybTpI0TqU1uD1pSk9M2+VPIwvBye/dXo46b+0jHGpftmxjQwk6tX8z0+mRko8pwV5n+cWht1/+w==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/avataaars-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.2.2.tgz", - "integrity": "sha512-pRj16P27dFDBI3LtdiHUDwIXIGndHAbZf5AxaMkn6/+0X93mVQ/btVJDXyW0G96WCsyC88wKAWr6/KJotPxU6Q==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/big-ears": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.2.2.tgz", - "integrity": "sha512-hz4UXdPq4qqZpu0YVvlqM4RDFhk5i0WgPcuwj/MOLlgTjuj63uHUhCQSk6ZiW1DQOs12qpwUBMGWVHxBRBas9g==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/big-ears-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.2.2.tgz", - "integrity": "sha512-IPHt8fi3dv9cyfBJBZ4s8T+PhFCrQvOCf91iRHBT3iOLNPdyZpI5GNLmGiV0XMAvIDP5NvA5+f6wdoBLhYhbDA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/big-smile": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.2.2.tgz", - "integrity": "sha512-D4td0GL8or1nTNnXvZqkEXlzyqzGPWs3znOnm1HIohtFTeIwXm72Ob2lNDsaQJSJvXmVlwaQQ0CCTvyCl8Stjw==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/bottts": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.2.2.tgz", - "integrity": "sha512-wugFkzw8JNWV1nftq/Wp/vmQsLAXDxrMtRK3AoMODuUpSVoP3EHRUfKS043xggOsQFvoj0HZ7kadmhn0AMLf5A==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/bottts-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.2.2.tgz", - "integrity": "sha512-lSgpqmSJtlnyxVuUgNdBwyzuA0O9xa5zRJtz7x2KyWbicXir5iYdX0MVMCkp1EDvlcxm9rGJsclktugOyakTlw==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/collection": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.2.2.tgz", - "integrity": "sha512-vZAmXhPWCK3sf8Fj9/QflFC6XOLroJOT5K1HdnzHaPboEvffUQideGCrrEamnJtlH0iF0ZDXh8gqmwy2fu+yHA==", - "dependencies": { - "@dicebear/adventurer": "9.2.2", - "@dicebear/adventurer-neutral": "9.2.2", - "@dicebear/avataaars": "9.2.2", - "@dicebear/avataaars-neutral": "9.2.2", - "@dicebear/big-ears": "9.2.2", - "@dicebear/big-ears-neutral": "9.2.2", - "@dicebear/big-smile": "9.2.2", - "@dicebear/bottts": "9.2.2", - "@dicebear/bottts-neutral": "9.2.2", - "@dicebear/croodles": "9.2.2", - "@dicebear/croodles-neutral": "9.2.2", - "@dicebear/dylan": "9.2.2", - "@dicebear/fun-emoji": "9.2.2", - "@dicebear/glass": "9.2.2", - "@dicebear/icons": "9.2.2", - "@dicebear/identicon": "9.2.2", - "@dicebear/initials": "9.2.2", - "@dicebear/lorelei": "9.2.2", - "@dicebear/lorelei-neutral": "9.2.2", - "@dicebear/micah": "9.2.2", - "@dicebear/miniavs": "9.2.2", - "@dicebear/notionists": "9.2.2", - "@dicebear/notionists-neutral": "9.2.2", - "@dicebear/open-peeps": "9.2.2", - "@dicebear/personas": "9.2.2", - "@dicebear/pixel-art": "9.2.2", - "@dicebear/pixel-art-neutral": "9.2.2", - "@dicebear/rings": "9.2.2", - "@dicebear/shapes": "9.2.2", - "@dicebear/thumbs": "9.2.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/core": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.2.2.tgz", - "integrity": "sha512-ROhgHG249dPtcXgBHcqPEsDeAPRPRD/9d+tZCjLYyueO+cXDlIA8dUlxpwIVcOuZFvCyW6RJtqo8BhNAi16pIQ==", - "dependencies": { - "@types/json-schema": "^7.0.11" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "client/node_modules/@dicebear/croodles": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.2.2.tgz", - "integrity": "sha512-OzvAXQWsOgMwL3Sl+lBxCubqSOWoBJpC78c4TKnNTS21rR63TtXUyVdLLzgKVN4YHRnvMgtPf8F/W9YAgIDK4w==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/croodles-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.2.2.tgz", - "integrity": "sha512-/4mNirxoQ+z1kHXnpDRbJ1JV1ZgXogeTeNp0MaFYxocCgHfJ7ckNM23EE1I7akoo9pqPxrKlaeNzGAjKHdS9vA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/dylan": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.2.2.tgz", - "integrity": "sha512-s7e3XliC1YXP+Wykj+j5kwdOWFRXFzYHYk/PB4oZ1F3sJandXiG0HS4chaNu4EoP0yZgKyFMUVTGZx+o6tMaYg==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/fun-emoji": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.2.2.tgz", - "integrity": "sha512-M+rYTpB3lfwz18f+/i+ggNwNWUoEj58SJqXJ1wr7Jh/4E5uL+NmJg9JGwYNaVtGbCFrKAjSaILNUWGQSFgMfog==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/glass": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.2.2.tgz", - "integrity": "sha512-imCMxcg+XScHYtQq2MUv1lCzhQSCUglMlPSezKEpXhTxgbgUpmGlSGVkOfmX5EEc7SQowKkF1W/1gNk6CXvBaQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/icons": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.2.2.tgz", - "integrity": "sha512-Tqq2OVCdS7J02DNw58xwlgLGl40sWEckbqXT3qRvIF63FfVq+wQZBGuhuiyAURcSgvsc3h2oQeYFi9iXh7HTOA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/identicon": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.2.2.tgz", - "integrity": "sha512-POVKFulIrcuZf3rdAgxYaSm2XUg/TJg3tg9zq9150reEGPpzWR7ijyJ03dzAADPzS3DExfdYVT9+z3JKwwJnTQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/initials": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.2.2.tgz", - "integrity": "sha512-/xNnsEmsstWjmF77htAOuwOMhFlP6eBVXgcgFlTl/CCH/Oc6H7t0vwX1he8KLQBBzjGpvJcvIAn4Wh9rE4D5/A==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/lorelei": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.2.2.tgz", - "integrity": "sha512-koXqVr/vcWUPo00VP5H6Czsit+uF1tmwd2NK7Q/e34/9Sd1f4QLLxHjjBNm/iNjCI1+UNTOvZ2Qqu0N5eo7Flw==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/lorelei-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.2.2.tgz", - "integrity": "sha512-Eys9Os6nt2Xll7Mvu66CfRR2YggTopWcmFcRZ9pPdohS96kT0MsLI2iTcfZXQ51K8hvT3IbwoGc86W8n0cDxAQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/micah": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.2.2.tgz", - "integrity": "sha512-NCajcJV5yw8uMKiACp694w1T/UyYme2CUEzyTzWHgWnQ+drAuCcH8gpAoLWd67viNdQB/MTpNlaelUgTjmI4AQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/miniavs": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.2.2.tgz", - "integrity": "sha512-vvkWXttdw+KHF3j+9qcUFzK+P0nbNnImGjvN48wwkPIh2h08WWFq0MnoOls4IHwUJC4GXBjWtiyVoCxz6hhtOA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/notionists": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.2.2.tgz", - "integrity": "sha512-Z9orRaHoj7Y9Ap4wEu8XOrFACsG1KbbBQUPV1R50uh6AHwsyNrm4cS84ICoGLvxgLNHHOae3YCjd8aMu2z19zg==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/notionists-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.2.2.tgz", - "integrity": "sha512-AhOzk+lz6kB4uxGun8AJhV+W1nttnMlxmxd+5KbQ/txCIziYIaeD3il44wsAGegEpGFvAZyMYtR/jjfHcem3TA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/open-peeps": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.2.2.tgz", - "integrity": "sha512-6PeQDHYyjvKrGSl/gP+RE5dSYAQGKpcGnM65HorgyTIugZK7STo0W4hvEycedupZ3MCCEH8x/XyiChKM2sHXog==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/personas": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.2.2.tgz", - "integrity": "sha512-705+ObNLC0w1fcgE/Utav+8bqO+Esu53TXegpX5j7trGEoIMf2bThqJGHuhknZ3+T2az3Wr89cGyOGlI0KLzLA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/pixel-art": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.2.2.tgz", - "integrity": "sha512-BvbFdrpzQl04+Y9UsWP63YGug+ENGC7GMG88qbEFWxb/IqRavGa4H3D0T4Zl2PSLiw7f2Ctv98bsCQZ1PtCznQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/pixel-art-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.2.2.tgz", - "integrity": "sha512-CdUY77H6Aj7dKLW3hdkv7tu0XQJArUjaWoXihQxlhl3oVYplWaoyu9omYy5pl8HTqs8YgVTGljjMXYoFuK0JUw==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/rings": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.2.2.tgz", - "integrity": "sha512-eD1J1k364Arny+UlvGrk12HP/XGG6WxPSm4BarFqdJGSV45XOZlwqoi7FlcMr9r9yvE/nGL8OizbwMYusEEdjw==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/shapes": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.2.2.tgz", - "integrity": "sha512-e741NNWBa7fg0BjomxXa0fFPME2XCIR0FA+VHdq9AD2taTGHEPsg5x1QJhCRdK6ww85yeu3V3ucpZXdSrHVw5Q==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/thumbs": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.2.2.tgz", - "integrity": "sha512-FkPLDNu7n5kThLSk7lR/0cz/NkUqgGdZGfLZv6fLkGNGtv6W+e2vZaO7HCXVwIgJ+II+kImN41zVIZ6Jlll7pQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, "client/node_modules/@react-spring/web": { "version": "9.7.5", "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz", @@ -17660,7 +17181,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.2.4.tgz", "integrity": "sha512-Xvboay3VH1qe7lH17T+bA3qPawf5EjccssDiyhCX/VT0P21c65JyjTIUJV36Nsv08HKeyDscyP0kgt9nPTRKvA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17673,7 +17193,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.2.4.tgz", "integrity": "sha512-I9IrB4ZYbUHSOUpWoUbfX3vG8FrjcW8htoQ4bEOR7TYOKKE11Mo1nrGMuHZ7GPfwN0CQeK1YVJhWqLTmtYn7Pg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17686,7 +17205,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.2.4.tgz", "integrity": "sha512-QKNBtA/1QGEzR+JjS4XQyrFHYGbzdOp0oa6gjhGhUDrMegDFS8uyjdRfDQsFTebVkyLWjgBQKZEiDqKqHptB6A==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17699,7 +17217,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.2.4.tgz", "integrity": "sha512-HtBvA7elRv50QTOOsBdtYB1GVimCpGEDlDgWsu1snL5Z3d1+3dIESoXQd3mXVvKTVT8Z9ciA4TEaF09WfxDjAA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17712,7 +17229,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.2.4.tgz", "integrity": "sha512-U33tbh7Io6wG6ViUMN5fkWPER7hPKMaPPaYgafaYQlCT4E7QPKF2u8X1XGag3jCKm0uf4SLXfuZ8v+YONcHmNQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17725,7 +17241,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.2.4.tgz", "integrity": "sha512-pPjYu80zMFl43A9sa5+tAKPkhp4n9nd7eN878IOrA1HAowh/XePh5JN8PTkNFS9eM+rnN9m8WX08XYFe30kLYw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17738,7 +17253,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.2.4.tgz", "integrity": "sha512-zeEfXOOXy7j9tfkPLzfQdLBPyQsctBetTdEfKRArc1k3RUliNPxfJG9j88+cXQC6GXrVW2pcT2X50NSPtugCFQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17751,7 +17265,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.2.4.tgz", "integrity": "sha512-4CTqrnVg+NQm6lZ4UuCJish8gGWe8EqSJrzvHQRO5TEyAKjYxbTdVqejpkycG1xkawha4FfxsYgtlSx7UwoVMw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17764,7 +17277,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.2.4.tgz", "integrity": "sha512-eMVdofdD/udHsKIaeWEXShDRtiwk7vp4FjY7l0f79vIzfhkIsXKEhPcnvHKOl/yoArlDVS3Uhgjj0crWTO9RJA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17777,7 +17289,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.2.4.tgz", "integrity": "sha512-I1wCUp0yu5qSIeMQHmDYXQIXKkKjcja/SYBxppPkYFXpR2alxb0k9/swFDdMbkY6a1c9AT1kI1y+Pg6ywQ2rTA==", "license": "MIT", - "peer": true, "dependencies": { "@dicebear/adventurer": "9.2.4", "@dicebear/adventurer-neutral": "9.2.4", @@ -17822,7 +17333,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.2.4.tgz", "integrity": "sha512-hz6zArEcUwkZzGOSJkWICrvqnEZY7BKeiq9rqKzVJIc1tRVv0MkR0FGvIxSvXiK9TTIgKwu656xCWAGAl6oh+w==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.11" }, @@ -17835,7 +17345,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.2.4.tgz", "integrity": "sha512-CqT0NgVfm+5kd+VnjGY4WECNFeOrj5p7GCPTSEA7tCuN72dMQOX47P9KioD3wbExXYrIlJgOcxNrQeb/FMGc3A==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17848,7 +17357,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.2.4.tgz", "integrity": "sha512-8vAS9lIEKffSUVx256GSRAlisB8oMX38UcPWw72venO/nitLVsyZ6hZ3V7eBdII0Onrjqw1RDndslQODbVcpTw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17861,7 +17369,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.2.4.tgz", "integrity": "sha512-tiih1358djAq0jDDzmW3N3S4C3ynC2yn4hhlTAq/MaUAQtAi47QxdHdFGdxH0HBMZKqA4ThLdVk3yVgN4xsukg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17874,7 +17381,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.2.4.tgz", "integrity": "sha512-Od729skczse1HvHekgEFv+mSuJKMC4sl5hENGi/izYNe6DZDqJrrD0trkGT/IVh/SLXUFbq1ZFY9I2LoUGzFZg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17887,7 +17393,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.2.4.tgz", "integrity": "sha512-5lxbJode1t99eoIIgW0iwZMoZU4jNMJv/6vbsgYUhAslYFX5zP0jVRscksFuo89TTtS7YKqRqZAL3eNhz4bTDw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17900,7 +17405,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.2.4.tgz", "integrity": "sha512-bRsK1qj8u9Z76xs8XhXlgVr/oHh68tsHTJ/1xtkX9DeTQTSamo2tS26+r231IHu+oW3mePtFnwzdG9LqEPRd4A==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17913,7 +17417,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.2.4.tgz", "integrity": "sha512-R9nw/E8fbu9HltHOqI9iL/o9i7zM+2QauXWMreQyERc39oGR9qXiwgBxsfYGcIS4C85xPyuL5B3I2RXrLBlJPg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17926,7 +17429,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.2.4.tgz", "integrity": "sha512-4SzHG5WoQZl1TGcpEZR4bdsSkUVqwNQCOwWSPAoBJa3BNxbVsvL08LF7I97BMgrCoknWZjQHUYt05amwTPTKtg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17939,7 +17441,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.2.4.tgz", "integrity": "sha512-eS4mPYUgDpo89HvyFAx/kgqSSKh8W4zlUA8QJeIUCWTB0WpQmeqkSgIyUJjGDYSrIujWi+zEhhckksM5EwW0Dg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17952,7 +17453,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.2.4.tgz", "integrity": "sha512-bWq2/GonbcJULtT+B/MGcM2UnA7kBQoH+INw8/oW83WI3GNTZ6qEwe3/W4QnCgtSOhUsuwuiSULguAFyvtkOZQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17965,7 +17465,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.2.4.tgz", "integrity": "sha512-XNWJ8Mx+pncIV8Ye0XYc/VkMiax8kTxcP3hLTC5vmELQyMSLXzg/9SdpI+W/tCQghtPZRYTT3JdY9oU9IUlP2g==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17978,7 +17477,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.2.4.tgz", "integrity": "sha512-k7IYTAHE/4jSO6boMBRrNlqPT3bh7PLFM1atfe0nOeCDwmz/qJUBP3HdONajbf3fmo8f2IZYhELrNWTOE7Ox3Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17991,7 +17489,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.2.4.tgz", "integrity": "sha512-zcvpAJ93EfC0xQffaPZQuJPShwPhnu9aTcoPsaYGmw0oEDLcv2XYmDhUUdX84QYCn6LtCZH053rHLVazRW+OGw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18004,7 +17501,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.2.4.tgz", "integrity": "sha512-fskWzBVxQzJhCKqY24DGZbYHSBaauoRa1DgXM7+7xBuksH7mfbTmZTvnUAsAqJYBkla8IPb4ERKduDWtlWYYjQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18017,7 +17513,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.2.4.tgz", "integrity": "sha512-s6nwdjXFsplqEI7imlsel4Gt6kFVJm6YIgtZSpry0UdwDoxUUudei5bn957j9lXwVpVUcRjJW+TuEKztYjXkKQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18030,7 +17525,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.2.4.tgz", "integrity": "sha512-JNim8RfZYwb0MfxW6DLVfvreCFIevQg+V225Xe5tDfbFgbcYEp4OU/KaiqqO2476OBjCw7i7/8USbv2acBhjwA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18043,7 +17537,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.2.4.tgz", "integrity": "sha512-4Ao45asieswUdlCTBZqcoF/0zHR3OWUWB0Mvhlu9b1Fbc6IlPBiOfx2vsp6bnVGVnMag58tJLecx2omeXdECBQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18056,7 +17549,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.2.4.tgz", "integrity": "sha512-ZITPLD1cPN4GjKkhWi80s7e5dcbXy34ijWlvmxbc4eb/V7fZSsyRa9EDUW3QStpo+xrCJLcLR+3RBE5iz0PC/A==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18069,7 +17561,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.2.4.tgz", "integrity": "sha512-teZxELYyV2ogzgb5Mvtn/rHptT0HXo9SjUGS4A52mOwhIdHSGGU71MqA1YUzfae9yJThsw6K7Z9kzuY2LlZZHA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18082,7 +17573,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.2.4.tgz", "integrity": "sha512-MhK9ZdFm1wUnH4zWeKPRMZ98UyApolf5OLzhCywfu38tRN6RVbwtBRHc/42ZwoN1JU1JgXr7hzjYucMqISHtbA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18095,7 +17585,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.2.4.tgz", "integrity": "sha512-EL4sMqv9p2+1Xy3d8e8UxyeKZV2+cgt3X2x2RTRzEOIIhobtkL8u6lJxmJbiGbpVtVALmrt5e7gjmwqpryYDpg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -19886,7 +19375,8 @@ "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "devOptional": true }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -20456,6 +19946,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.3.tgz", "integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==", + "dev": true, "dependencies": { "buffer": "^6.0.3" } @@ -26902,6 +26393,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "optional": true, + "peer": true, "peerDependencies": { "@redis/client": "^1.0.0" } @@ -26910,6 +26403,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", + "devOptional": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -26922,12 +26416,15 @@ "node_modules/@redis/client/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "devOptional": true }, "node_modules/@redis/graph": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "optional": true, + "peer": true, "peerDependencies": { "@redis/client": "^1.0.0" } @@ -26936,6 +26433,8 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "optional": true, + "peer": true, "peerDependencies": { "@redis/client": "^1.0.0" } @@ -26944,6 +26443,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "optional": true, + "peer": true, "peerDependencies": { "@redis/client": "^1.0.0" } @@ -26952,6 +26453,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "optional": true, + "peer": true, "peerDependencies": { "@redis/client": "^1.0.0" } @@ -29647,6 +29150,16 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -32217,6 +31730,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -32505,6 +32019,19 @@ "source-map": "^0.6.1" } }, + "node_modules/connect-redis": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-8.1.0.tgz", + "integrity": "sha512-Km0EYLDlmExF52UCss5gLGTtrukGC57G6WCC2aqEMft5Vr4xNWuM4tL+T97kWrw+vp40SXFteb6Xk/7MxgpwdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "express-session": ">=1" + } + }, "node_modules/console-browserify": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", @@ -33409,6 +32936,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, "engines": { "node": ">=0.10" } @@ -35058,6 +34586,46 @@ "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/express-static-gzip": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/express-static-gzip/-/express-static-gzip-2.2.0.tgz", @@ -35776,13 +35344,18 @@ } }, "node_modules/fs-extra": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", - "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "dev": true, + "license": "MIT", "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" } }, "node_modules/fs.realpath": { @@ -35923,6 +35496,7 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "devOptional": true, "engines": { "node": ">= 4" } @@ -36259,7 +35833,8 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true }, "node_modules/graphemer": { "version": "1.4.0", @@ -36716,6 +36291,13 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/hookified": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.1.tgz", + "integrity": "sha512-xnKGl+iMIlhrZmGHB729MqlmPoWBznctSQTYCpFKqNsCgimJQmithcW0xSQMMFzYnV2iKUh25alswn6epgxS0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/htm": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz", @@ -37236,6 +36818,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", + "devOptional": true, "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", @@ -38968,9 +38551,14 @@ } }, "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -39130,13 +38718,40 @@ } }, "node_modules/keyv": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.3.2.tgz", - "integrity": "sha512-Lji2XRxqqa5Wg+CHLVfFKBImfJZ4pCSccu9eVWK6w4c2SDFLd8JAn1zqTuSFnsxb7ope6rMsnIHfp+eBbRBRZQ==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.2.tgz", + "integrity": "sha512-TXcFHbmm/z7MGd1u9ASiCSfTS+ei6Z8B3a5JHzx3oPa/o7QzWVtPRpc4KGER5RR469IC+/nfg4U5YLIuDUua2g==", + "dev": true, + "license": "MIT", "dependencies": { - "@keyv/serialize": "^1.0.3" + "@keyv/serialize": "^1.1.1" } }, + "node_modules/keyv-file": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.2.0.tgz", + "integrity": "sha512-5JEBqQiDzjGCQHtf7KLReJdHKchaJyUZW+9TvBu+4dc+uuTqUG9KcdA3ICMXlwky3qjKc0ecNCNefbgjyDtlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.0.1", + "tslib": "^1.14.1" + } + }, + "node_modules/keyv-file/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/keyv/node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -39775,7 +39390,8 @@ "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "devOptional": true }, "node_modules/lodash.includes": { "version": "4.3.0", @@ -39785,7 +39401,8 @@ "node_modules/lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "devOptional": true }, "node_modules/lodash.isboolean": { "version": "3.0.3", @@ -40921,6 +40538,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/memorystore/-/memorystore-1.6.7.tgz", "integrity": "sha512-OZnmNY/NDrKohPQ+hxp0muBcBKrzKNtHr55DbqSx9hLsYVNnomSAMRAtI7R64t3gf3ID7tHQA7mG4oL3Hu9hdw==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.0", @@ -40934,6 +40552,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, "license": "ISC", "dependencies": { "pseudomap": "^1.0.2", @@ -40944,6 +40563,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", + "dev": true, "license": "ISC" }, "node_modules/merge-descriptors": { @@ -42552,6 +42172,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -45322,6 +44951,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", + "dev": true, "license": "ISC" }, "node_modules/psl": { @@ -45476,6 +45106,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.2.0.tgz", "integrity": "sha512-wV450NQyKC24NmPosJb2131RoczLdfIJdKCReNwtVpm5998U8SgKrAZrIHaN/NfQgqOHaan8Uq++B4sa5REwjA==", + "dev": true, "engines": { "node": ">= 16" }, @@ -46104,6 +45735,8 @@ "version": "4.7.0", "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", + "optional": true, + "peer": true, "workspaces": [ "./packages/*" ], @@ -46120,6 +45753,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "devOptional": true, "engines": { "node": ">=4" } @@ -46128,6 +45762,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "devOptional": true, "dependencies": { "redis-errors": "^1.0.0" }, @@ -47256,27 +46891,6 @@ "node": ">=12" } }, - "node_modules/rollup-plugin-typescript2/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/rollup-plugin-typescript2/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/rollup-pluginutils": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", @@ -47975,7 +47589,8 @@ "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "devOptional": true }, "node_modules/static-browser-server": { "version": "1.0.3", @@ -49878,11 +49493,13 @@ } }, "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 4.0.0" + "node": ">= 10.0.0" } }, "node_modules/unpipe": { @@ -51006,18 +50623,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/workbox-build/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/workbox-build/node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -51075,15 +50680,6 @@ "punycode": "^2.1.0" } }, - "node_modules/workbox-build/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/workbox-build/node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -51614,6 +51210,7 @@ "@babel/preset-env": "^7.21.5", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.0", + "@keyv/redis": "^4.3.3", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^25.0.2", "@rollup/plugin-json": "^6.1.0", @@ -51623,16 +51220,23 @@ "@types/bun": "^1.2.15", "@types/diff": "^6.0.0", "@types/express": "^5.0.0", + "@types/express-session": "^1.18.2", "@types/jest": "^29.5.2", "@types/jsonwebtoken": "^9.0.0", "@types/multer": "^1.4.13", "@types/node": "^20.3.0", "@types/react": "^18.2.18", "@types/winston": "^2.4.4", + "connect-redis": "^8.1.0", + "ioredis": "^5.3.2", "jest": "^29.5.0", "jest-junit": "^16.0.0", + "keyv": "^5.3.2", + "keyv-file": "^5.1.2", "librechat-data-provider": "*", + "memorystore": "^1.6.7", "mongoose": "^8.12.1", + "rate-limit-redis": "^4.2.0", "rimraf": "^5.0.1", "rollup": "^4.22.4", "rollup-plugin-peer-deps-external": "^2.2.4", @@ -51640,25 +51244,50 @@ "typescript": "^5.0.4" }, "peerDependencies": { + "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", "@librechat/agents": "^2.4.82", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", + "connect-redis": "^8.1.0", "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^4.21.2", + "express-session": "^1.18.2", "form-data": "^4.0.4", + "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "keyv": "^5.3.2", + "keyv-file": "^5.1.2", "librechat-data-provider": "*", + "memorystore": "^1.6.7", "node-fetch": "2.7.0", + "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", "undici": "^7.10.0", "zod": "^3.22.4" } }, + "packages/api/node_modules/@keyv/redis": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-4.6.0.tgz", + "integrity": "sha512-FP3FP42RiQ3j0UC6f4Maf7ISTLAIivm37/SdfG5xvhqceMMq3kabtC6T4a2h5byMnh4S8PjP51DY/9CpyrcfsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redis/client": "^1.6.0", + "cluster-key-slot": "^1.1.2", + "hookified": "^1.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.3.4" + } + }, "packages/api/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", diff --git a/packages/api/package.json b/packages/api/package.json index 7be7b193b5..da11212c96 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -18,8 +18,9 @@ "build:dev": "npm run clean && NODE_ENV=development rollup -c --bundleConfigAsCjs", "build:watch": "NODE_ENV=development rollup -c -w --bundleConfigAsCjs", "build:watch:prod": "rollup -c -w --bundleConfigAsCjs", - "test": "jest --coverage --watch", - "test:ci": "jest --coverage --ci", + "test": "jest --coverage --watch --testPathIgnorePatterns=\"\\.integration\\.\"", + "test:ci": "jest --coverage --ci --testPathIgnorePatterns=\"\\.integration\\.\"", + "test:cache:integration": "jest --testPathPattern=\"src/cache/.*\\.integration\\.spec\\.ts$\" --coverage=false", "verify": "npm run test:ci", "b:clean": "bun run rimraf dist", "b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs", @@ -43,6 +44,7 @@ "@babel/preset-env": "^7.21.5", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.0", + "@keyv/redis": "^4.3.3", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^25.0.2", "@rollup/plugin-json": "^6.1.0", @@ -52,16 +54,23 @@ "@types/bun": "^1.2.15", "@types/diff": "^6.0.0", "@types/express": "^5.0.0", + "@types/express-session": "^1.18.2", "@types/jest": "^29.5.2", "@types/jsonwebtoken": "^9.0.0", "@types/multer": "^1.4.13", "@types/node": "^20.3.0", "@types/react": "^18.2.18", "@types/winston": "^2.4.4", + "connect-redis": "^8.1.0", + "ioredis": "^5.3.2", "jest": "^29.5.0", "jest-junit": "^16.0.0", + "keyv": "^5.3.2", + "keyv-file": "^5.1.2", "librechat-data-provider": "*", + "memorystore": "^1.6.7", "mongoose": "^8.12.1", + "rate-limit-redis": "^4.2.0", "rimraf": "^5.0.1", "rollup": "^4.22.4", "rollup-plugin-peer-deps-external": "^2.2.4", @@ -72,20 +81,27 @@ "registry": "https://registry.npmjs.org/" }, "peerDependencies": { + "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", "@librechat/agents": "^2.4.82", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", + "connect-redis": "^8.1.0", "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^4.21.2", + "express-session": "^1.18.2", "form-data": "^4.0.4", + "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "keyv": "^5.3.2", + "keyv-file": "^5.1.2", "librechat-data-provider": "*", + "memorystore": "^1.6.7", "node-fetch": "2.7.0", + "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", "undici": "^7.10.0", "zod": "^3.22.4" diff --git a/packages/api/rollup.config.js b/packages/api/rollup.config.js index 38f2df1559..9a9de35e8f 100644 --- a/packages/api/rollup.config.js +++ b/packages/api/rollup.config.js @@ -18,6 +18,7 @@ const plugins = [ peerDepsExternal(), resolve({ preferBuiltins: true, + skipSelf: true, }), replace({ __IS_DEV__: isDevelopment, diff --git a/api/cache/cacheConfig.spec.js b/packages/api/src/cache/__tests__/cacheConfig.spec.ts similarity index 59% rename from api/cache/cacheConfig.spec.js rename to packages/api/src/cache/__tests__/cacheConfig.spec.ts index a86acc5512..24f12f1d57 100644 --- a/api/cache/cacheConfig.spec.js +++ b/packages/api/src/cache/__tests__/cacheConfig.spec.ts @@ -1,12 +1,8 @@ -const fs = require('fs'); - describe('cacheConfig', () => { - let originalEnv; - let originalReadFileSync; + let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { originalEnv = { ...process.env }; - originalReadFileSync = fs.readFileSync; // Clear all related env vars first delete process.env.REDIS_URI; @@ -18,116 +14,116 @@ describe('cacheConfig', () => { delete process.env.REDIS_PING_INTERVAL; delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES; - // Clear require cache + // Clear module cache jest.resetModules(); }); afterEach(() => { process.env = originalEnv; - fs.readFileSync = originalReadFileSync; jest.resetModules(); }); describe('REDIS_KEY_PREFIX validation and resolution', () => { - test('should throw error when both REDIS_KEY_PREFIX_VAR and REDIS_KEY_PREFIX are set', () => { + test('should throw error when both REDIS_KEY_PREFIX_VAR and REDIS_KEY_PREFIX are set', async () => { process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID'; process.env.REDIS_KEY_PREFIX = 'manual-prefix'; - expect(() => { - require('./cacheConfig'); - }).toThrow('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.'); + await expect(async () => { + await import('../cacheConfig'); + }).rejects.toThrow('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.'); }); - test('should resolve REDIS_KEY_PREFIX from variable reference', () => { + test('should resolve REDIS_KEY_PREFIX from variable reference', async () => { process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID'; process.env.DEPLOYMENT_ID = 'test-deployment-123'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_KEY_PREFIX).toBe('test-deployment-123'); }); - test('should use direct REDIS_KEY_PREFIX value', () => { + test('should use direct REDIS_KEY_PREFIX value', async () => { process.env.REDIS_KEY_PREFIX = 'direct-prefix'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_KEY_PREFIX).toBe('direct-prefix'); }); - test('should default to empty string when no prefix is configured', () => { - const { cacheConfig } = require('./cacheConfig'); + test('should default to empty string when no prefix is configured', async () => { + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); }); - test('should handle empty variable reference', () => { + test('should handle empty variable reference', async () => { process.env.REDIS_KEY_PREFIX_VAR = 'EMPTY_VAR'; process.env.EMPTY_VAR = ''; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); }); - test('should handle undefined variable reference', () => { + test('should handle undefined variable reference', async () => { process.env.REDIS_KEY_PREFIX_VAR = 'UNDEFINED_VAR'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); }); }); describe('USE_REDIS and REDIS_URI validation', () => { - test('should throw error when USE_REDIS is enabled but REDIS_URI is not set', () => { + test('should throw error when USE_REDIS is enabled but REDIS_URI is not set', async () => { process.env.USE_REDIS = 'true'; - expect(() => { - require('./cacheConfig'); - }).toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); + await expect(async () => { + await import('../cacheConfig'); + }).rejects.toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); }); - test('should not throw error when USE_REDIS is enabled and REDIS_URI is set', () => { + test('should not throw error when USE_REDIS is enabled and REDIS_URI is set', async () => { process.env.USE_REDIS = 'true'; process.env.REDIS_URI = 'redis://localhost:6379'; - expect(() => { - require('./cacheConfig'); - }).not.toThrow(); + const importModule = async () => { + await import('../cacheConfig'); + }; + await expect(importModule()).resolves.not.toThrow(); }); - test('should handle empty REDIS_URI when USE_REDIS is enabled', () => { + test('should handle empty REDIS_URI when USE_REDIS is enabled', async () => { process.env.USE_REDIS = 'true'; process.env.REDIS_URI = ''; - expect(() => { - require('./cacheConfig'); - }).toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); + await expect(async () => { + await import('../cacheConfig'); + }).rejects.toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); }); }); describe('USE_REDIS_CLUSTER configuration', () => { - test('should default to false when USE_REDIS_CLUSTER is not set', () => { - const { cacheConfig } = require('./cacheConfig'); + test('should default to false when USE_REDIS_CLUSTER is not set', async () => { + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.USE_REDIS_CLUSTER).toBe(false); }); - test('should be false when USE_REDIS_CLUSTER is set to false', () => { + test('should be false when USE_REDIS_CLUSTER is set to false', async () => { process.env.USE_REDIS_CLUSTER = 'false'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.USE_REDIS_CLUSTER).toBe(false); }); - test('should be true when USE_REDIS_CLUSTER is set to true', () => { + test('should be true when USE_REDIS_CLUSTER is set to true', async () => { process.env.USE_REDIS_CLUSTER = 'true'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.USE_REDIS_CLUSTER).toBe(true); }); - test('should work with USE_REDIS enabled and REDIS_URI set', () => { + test('should work with USE_REDIS enabled and REDIS_URI set', async () => { process.env.USE_REDIS_CLUSTER = 'true'; process.env.USE_REDIS = 'true'; process.env.REDIS_URI = 'redis://localhost:6379'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.USE_REDIS_CLUSTER).toBe(true); expect(cacheConfig.USE_REDIS).toBe(true); expect(cacheConfig.REDIS_URI).toBe('redis://localhost:6379'); @@ -135,54 +131,51 @@ describe('cacheConfig', () => { }); describe('REDIS_CA file reading', () => { - test('should be null when REDIS_CA is not set', () => { - const { cacheConfig } = require('./cacheConfig'); + test('should be null when REDIS_CA is not set', async () => { + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_CA).toBeNull(); }); }); describe('REDIS_PING_INTERVAL configuration', () => { - test('should default to 0 when REDIS_PING_INTERVAL is not set', () => { - const { cacheConfig } = require('./cacheConfig'); + test('should default to 0 when REDIS_PING_INTERVAL is not set', async () => { + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_PING_INTERVAL).toBe(0); }); - test('should use provided REDIS_PING_INTERVAL value', () => { + test('should use provided REDIS_PING_INTERVAL value', async () => { process.env.REDIS_PING_INTERVAL = '300'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_PING_INTERVAL).toBe(300); }); }); describe('FORCED_IN_MEMORY_CACHE_NAMESPACES validation', () => { - test('should parse comma-separated cache keys correctly', () => { + test('should parse comma-separated cache keys correctly', async () => { process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ' ROLES, MESSAGES '; - const { cacheConfig } = require('./cacheConfig'); - expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([ - 'ROLES', - 'MESSAGES', - ]); + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual(['ROLES', 'MESSAGES']); }); - test('should throw error for invalid cache keys', () => { + test('should throw error for invalid cache keys', async () => { process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = 'INVALID_KEY,ROLES'; - expect(() => { - require('./cacheConfig'); - }).toThrow('Invalid cache keys in FORCED_IN_MEMORY_CACHE_NAMESPACES: INVALID_KEY'); + await expect(async () => { + await import('../cacheConfig'); + }).rejects.toThrow('Invalid cache keys in FORCED_IN_MEMORY_CACHE_NAMESPACES: INVALID_KEY'); }); - test('should handle empty string gracefully', () => { + test('should handle empty string gracefully', async () => { process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ''; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]); }); - test('should handle undefined env var gracefully', () => { - const { cacheConfig } = require('./cacheConfig'); + test('should handle undefined env var gracefully', async () => { + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]); }); }); diff --git a/packages/api/src/cache/__tests__/cacheFactory/limiterCache.integration.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/limiterCache.integration.spec.ts new file mode 100644 index 0000000000..ab30ec3d1f --- /dev/null +++ b/packages/api/src/cache/__tests__/cacheFactory/limiterCache.integration.spec.ts @@ -0,0 +1,113 @@ +import type { RedisStore } from 'rate-limit-redis'; + +describe('limiterCache', () => { + let originalEnv: NodeJS.ProcessEnv; + let testStore: RedisStore | undefined = undefined; + + beforeEach(() => { + originalEnv = { ...process.env }; + + // Clear cache-related env vars + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.USE_REDIS_CLUSTER; + delete process.env.REDIS_PING_INTERVAL; + delete process.env.REDIS_KEY_PREFIX; + + // Set test configuration + process.env.REDIS_PING_INTERVAL = '0'; + process.env.REDIS_KEY_PREFIX = 'Cache-Integration-Test'; + process.env.REDIS_RETRY_MAX_ATTEMPTS = '5'; + + // Clear require cache to reload modules + jest.resetModules(); + }); + + afterEach(async () => { + process.env = originalEnv; + jest.resetModules(); + }); + + test('should throw error when prefix is not provided', async () => { + const cacheFactory = await import('../../cacheFactory'); + expect(() => cacheFactory.limiterCache('')).toThrow('prefix is required'); + }); + + test('should return undefined when USE_REDIS is false', async () => { + process.env.USE_REDIS = 'false'; + + const cacheFactory = await import('../../cacheFactory'); + testStore = cacheFactory.limiterCache('test-limiter'); + + expect(testStore).toBeUndefined(); + }); + + test('should return RedisStore with sendCommand when USE_REDIS is true', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + testStore = cacheFactory.limiterCache('test-limiter'); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + // Verify it returns a RedisStore instance + expect(testStore).toBeDefined(); + expect(testStore!.constructor.name).toBe('RedisStore'); + expect(testStore!.prefix).toBe('test-limiter:'); + expect(typeof testStore!.sendCommand).toBe('function'); + + const testKey = 'user:123'; + + // SET operation + await testStore!.sendCommand('SET', testKey, '1', 'EX', '60'); + + // Verify the key was created WITHOUT prefix using ioredis + // Note: Using call method since get method seems to have issues in test environment + // Type assertion for ioredis call method + type RedisClientWithCall = typeof ioredisClient & { + call: (command: string, key: string) => Promise; + }; + const directValue = await (ioredisClient as RedisClientWithCall).call('GET', testKey); + + expect(directValue).toBe('1'); + + // GET operation + const value = await testStore!.sendCommand('GET', testKey); + expect(value).toBe('1'); + + // INCR operation + const incremented = await testStore!.sendCommand('INCR', testKey); + expect(incremented).toBe(2); + + // Verify increment worked with ioredis + const incrementedValue = await (ioredisClient as RedisClientWithCall).call('GET', testKey); + expect(incrementedValue).toBe('2'); + + // TTL operation + const ttl = (await testStore!.sendCommand('TTL', testKey)) as number; + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(60); + + // DEL operation + const deleted = await testStore!.sendCommand('DEL', testKey); + expect(deleted).toBe(1); + + // Verify deletion + const afterDelete = await testStore!.sendCommand('GET', testKey); + expect(afterDelete).toBeNull(); + const directAfterDelete = await ioredisClient!.get(testKey); + expect(directAfterDelete).toBeNull(); + + // Test error handling + await expect(testStore!.sendCommand('INVALID_COMMAND')).rejects.toThrow(); + }); +}); diff --git a/packages/api/src/cache/__tests__/cacheFactory/sessionCache.integration.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/sessionCache.integration.spec.ts new file mode 100644 index 0000000000..144eee557d --- /dev/null +++ b/packages/api/src/cache/__tests__/cacheFactory/sessionCache.integration.spec.ts @@ -0,0 +1,211 @@ +interface SessionData { + [key: string]: unknown; + cookie?: { maxAge: number }; + user?: { id: string; name: string }; + userId?: string; +} + +interface SessionStore { + prefix?: string; + set: (id: string, data: SessionData, callback?: (err?: Error) => void) => void; + get: (id: string, callback: (err: Error | null, data?: SessionData | null) => void) => void; + destroy: (id: string, callback?: (err?: Error) => void) => void; + touch: (id: string, data: SessionData, callback?: (err?: Error) => void) => void; + on?: (event: string, handler: (...args: unknown[]) => void) => void; +} + +describe('sessionCache', () => { + let originalEnv: NodeJS.ProcessEnv; + + // Helper to make session stores async + const asyncStore = (store: SessionStore) => ({ + set: (id: string, data: SessionData) => + new Promise((resolve) => store.set(id, data, () => resolve())), + get: (id: string) => + new Promise((resolve) => + store.get(id, (_, data) => resolve(data)), + ), + destroy: (id: string) => new Promise((resolve) => store.destroy(id, () => resolve())), + touch: (id: string, data: SessionData) => + new Promise((resolve) => store.touch(id, data, () => resolve())), + }); + + beforeEach(() => { + originalEnv = { ...process.env }; + + // Clear cache-related env vars + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.USE_REDIS_CLUSTER; + delete process.env.REDIS_PING_INTERVAL; + delete process.env.REDIS_KEY_PREFIX; + + // Set test configuration + process.env.REDIS_PING_INTERVAL = '0'; + process.env.REDIS_KEY_PREFIX = 'Cache-Integration-Test'; + process.env.REDIS_RETRY_MAX_ATTEMPTS = '5'; + + // Clear require cache to reload modules + jest.resetModules(); + }); + + afterEach(async () => { + process.env = originalEnv; + jest.resetModules(); + }); + + test('should return ConnectRedis store when USE_REDIS is true', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const store = cacheFactory.sessionCache('test-sessions', 3600); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + // Verify it returns a ConnectRedis instance + expect(store).toBeDefined(); + expect(store.constructor.name).toBe('RedisStore'); + expect(store.prefix).toBe('test-sessions:'); + + // Test session operations + const sessionId = 'sess:123456'; + const sessionData: SessionData = { + user: { id: 'user123', name: 'Test User' }, + cookie: { maxAge: 3600000 }, + }; + + const async = asyncStore(store); + + // Set session + await async.set(sessionId, sessionData); + + // Get session + const retrieved = await async.get(sessionId); + expect(retrieved).toEqual(sessionData); + + // Touch session (update expiry) + await async.touch(sessionId, sessionData); + + // Destroy session + await async.destroy(sessionId); + + // Verify deletion + const afterDelete = await async.get(sessionId); + expect(afterDelete).toBeNull(); + }); + + test('should return MemoryStore when USE_REDIS is false', async () => { + process.env.USE_REDIS = 'false'; + + const cacheFactory = await import('../../cacheFactory'); + const store = cacheFactory.sessionCache('test-sessions', 3600); + + // Verify it returns a MemoryStore instance + expect(store).toBeDefined(); + expect(store.constructor.name).toBe('MemoryStore'); + + // Test session operations + const sessionId = 'mem:789012'; + const sessionData: SessionData = { + user: { id: 'user456', name: 'Memory User' }, + cookie: { maxAge: 3600000 }, + }; + + const async = asyncStore(store); + + // Set session + await async.set(sessionId, sessionData); + + // Get session + const retrieved = await async.get(sessionId); + expect(retrieved).toEqual(sessionData); + + // Destroy session + await async.destroy(sessionId); + + // Verify deletion + const afterDelete = await async.get(sessionId); + expect(afterDelete).toBeUndefined(); + }); + + test('should handle namespace with and without trailing colon', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + + const store1 = cacheFactory.sessionCache('namespace1'); + const store2 = cacheFactory.sessionCache('namespace2:'); + + expect(store1.prefix).toBe('namespace1:'); + expect(store2.prefix).toBe('namespace2:'); + }); + + test('should register error handler for Redis connection', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + + // Spy on ioredisClient.on + const onSpy = jest.spyOn(ioredisClient!, 'on'); + + // Create session store + cacheFactory.sessionCache('error-test'); + + // Verify error handler was registered + expect(onSpy).toHaveBeenCalledWith('error', expect.any(Function)); + + onSpy.mockRestore(); + }); + + test('should handle session expiration with TTL', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const ttl = 1; // 1 second TTL + const store = cacheFactory.sessionCache('ttl-sessions', ttl); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + const sessionId = 'ttl:12345'; + const sessionData: SessionData = { userId: 'ttl-user' }; + const async = asyncStore(store); + + // Set session with short TTL + await async.set(sessionId, sessionData); + + // Verify session exists immediately + const immediate = await async.get(sessionId); + expect(immediate).toEqual(sessionData); + + // Wait for TTL to expire + await new Promise((resolve) => setTimeout(resolve, (ttl + 0.5) * 1000)); + + // Verify session has expired + const expired = await async.get(sessionId); + expect(expired).toBeNull(); + }); +}); diff --git a/packages/api/src/cache/__tests__/cacheFactory/standardCache.integration.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/standardCache.integration.spec.ts new file mode 100644 index 0000000000..db40ad636c --- /dev/null +++ b/packages/api/src/cache/__tests__/cacheFactory/standardCache.integration.spec.ts @@ -0,0 +1,185 @@ +import type { Keyv } from 'keyv'; + +// Mock GLOBAL_PREFIX_SEPARATOR +jest.mock('../../redisClients', () => { + const originalModule = jest.requireActual('../../redisClients'); + return { + ...originalModule, + GLOBAL_PREFIX_SEPARATOR: '>>', + }; +}); + +describe('standardCache', () => { + let originalEnv: NodeJS.ProcessEnv; + let testCache: Keyv | null = null; + + // Helper function to verify Redis keys exist + const expectRedisKeysExist = async (expectedKeys: string[]) => { + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + if (!ioredisClient) throw new Error('ioredisClient is null'); + const allKeys = await ioredisClient.keys('Cache-Integration-Test*'); + expectedKeys.forEach((expectedKey) => { + expect(allKeys).toContain(expectedKey); + }); + }; + + beforeEach(() => { + originalEnv = { ...process.env }; + + // Clear cache-related env vars + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.USE_REDIS_CLUSTER; + delete process.env.REDIS_PING_INTERVAL; + delete process.env.REDIS_KEY_PREFIX; + delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES; + + // Set test configuration + process.env.REDIS_PING_INTERVAL = '0'; + process.env.REDIS_KEY_PREFIX = 'Cache-Integration-Test'; + process.env.REDIS_RETRY_MAX_ATTEMPTS = '5'; + + // Clear require cache to reload modules + jest.resetModules(); + }); + + afterEach(async () => { + // Clean up test keys using prefix and test namespaces + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + if (ioredisClient && ioredisClient.status === 'ready') { + try { + const patterns = [ + 'Cache-Integration-Test>>*', + 'Cache-Integration-Test>>test-namespace:*', + 'Cache-Integration-Test>>another-namespace:*', + ]; + + for (const pattern of patterns) { + const keys = await ioredisClient.keys(pattern); + if (keys.length > 0) { + await ioredisClient.del(...keys); + } + } + } catch (error: unknown) { + if (error instanceof Error) { + console.warn('Error cleaning up test keys:', error.message); + } + } + } + + // Clean up cache instance + if (testCache) { + try { + await testCache.clear(); + } catch (error: unknown) { + if (error instanceof Error) { + console.warn('Error clearing cache:', error.message); + } + } + testCache = null; + } + + process.env = originalEnv; + jest.resetModules(); + }); + + describe('when USE_REDIS is false', () => { + test('should create in-memory cache', async () => { + process.env.USE_REDIS = 'false'; + + const cacheFactory = await import('../../cacheFactory'); + testCache = cacheFactory.standardCache('test-namespace'); + + expect(testCache).toBeDefined(); + expect(testCache.constructor.name).toBe('Keyv'); + }); + + test('should use fallback store when provided', async () => { + process.env.USE_REDIS = 'false'; + const fallbackStore = new Map(); + + const cacheFactory = await import('../../cacheFactory'); + testCache = cacheFactory.standardCache('test-namespace', 200, fallbackStore); + + expect(testCache).toBeDefined(); + // Type assertion to access internal options + const cacheWithOpts = testCache as Keyv & { + opts: { store: unknown; namespace: string; ttl: number }; + }; + expect(cacheWithOpts.opts.store).toBe(fallbackStore); + expect(cacheWithOpts.opts.namespace).toBe('test-namespace'); + expect(cacheWithOpts.opts.ttl).toBe(200); + }); + }); + + describe('when connecting to a Redis server', () => { + test('should handle different namespaces with correct prefixes', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + + const cache1 = cacheFactory.standardCache('namespace-one'); + const cache2 = cacheFactory.standardCache('namespace-two'); + + await cache1.set('key1', 'value1'); + await cache2.set('key2', 'value2'); + + // Verify both caches work independently + expect(await cache1.get('key1')).toBe('value1'); + expect(await cache2.get('key2')).toBe('value2'); + expect(await cache1.get('key2')).toBeUndefined(); + expect(await cache2.get('key1')).toBeUndefined(); + + // Verify Redis keys have correct prefixes for different namespaces + await expectRedisKeysExist([ + 'Cache-Integration-Test>>namespace-one:key1', + 'Cache-Integration-Test>>namespace-two:key2', + ]); + + await cache1.clear(); + await cache2.clear(); + }); + + test('should respect FORCED_IN_MEMORY_CACHE_NAMESPACES', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = 'ROLES'; // Use a valid cache key + + const cacheFactory = await import('../../cacheFactory'); + + // This should create an in-memory cache despite USE_REDIS being true + testCache = cacheFactory.standardCache('ROLES', 5000); + + expect(testCache).toBeDefined(); + expect(testCache.constructor.name).toBe('Keyv'); + // Type assertion to access internal options + const cacheWithOpts = testCache as Keyv & { opts: { namespace: string; ttl: number } }; + expect(cacheWithOpts.opts.namespace).toBe('ROLES'); + expect(cacheWithOpts.opts.ttl).toBe(5000); + }); + + test('should handle TTL correctly', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + testCache = cacheFactory.standardCache('ttl-test', 1000); // 1 second TTL + + const testKey = 'ttl-key'; + const testValue = 'ttl-value'; + + await testCache.set(testKey, testValue); + expect(await testCache.get(testKey)).toBe(testValue); + + // Wait for TTL to expire + await new Promise((resolve) => setTimeout(resolve, 1100)); + expect(await testCache.get(testKey)).toBeUndefined(); + }); + }); +}); diff --git a/packages/api/src/cache/__tests__/cacheFactory/violationCache.integration.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/violationCache.integration.spec.ts new file mode 100644 index 0000000000..7ffec8d74e --- /dev/null +++ b/packages/api/src/cache/__tests__/cacheFactory/violationCache.integration.spec.ts @@ -0,0 +1,241 @@ +interface ViolationData { + count?: number; + timestamp?: number; + namespace?: number; + data?: string; + userId?: string; + violations?: Array<{ + type: string; + timestamp: number; + severity: string; + }>; + metadata?: { + ip: string; + userAgent: string; + nested: { + deep: { + value: string; + }; + }; + }; +} + +describe('violationCache', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + + // Clear cache-related env vars + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.USE_REDIS_CLUSTER; + delete process.env.REDIS_PING_INTERVAL; + delete process.env.REDIS_KEY_PREFIX; + + // Set test configuration + process.env.REDIS_PING_INTERVAL = '0'; + process.env.REDIS_KEY_PREFIX = 'Cache-Integration-Test'; + process.env.REDIS_RETRY_MAX_ATTEMPTS = '5'; + + // Clear require cache to reload modules + jest.resetModules(); + }); + + afterEach(async () => { + process.env = originalEnv; + jest.resetModules(); + }); + + test('should create violation cache with Redis when USE_REDIS is true', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const cache = cacheFactory.violationCache('test-violations', 60000); // 60 second TTL + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + // Verify it returns a Keyv instance + expect(cache).toBeDefined(); + expect(cache.constructor.name).toBe('Keyv'); + + // Test basic cache operations + const testKey = 'user:456:violation'; + const testValue: ViolationData = { count: 1, timestamp: Date.now() }; + + // SET operation + await cache.set(testKey, testValue); + + // GET operation + const retrievedValue = await cache.get(testKey); + expect(retrievedValue).toEqual(testValue); + + // DELETE operation + const deleted = await cache.delete(testKey); + expect(deleted).toBe(true); + + // Verify deletion + const afterDelete = await cache.get(testKey); + expect(afterDelete).toBeUndefined(); + }); + + test('should use fallback store when USE_REDIS is false', async () => { + process.env.USE_REDIS = 'false'; + + const cacheFactory = await import('../../cacheFactory'); + const cache = cacheFactory.violationCache('test-violations'); + + // Verify it returns a Keyv instance + expect(cache).toBeDefined(); + expect(cache.constructor.name).toBe('Keyv'); + + // Test basic operations with fallback store + const testKey = 'user:789:violation'; + const testValue: ViolationData = { count: 2, timestamp: Date.now() }; + + // SET operation + await cache.set(testKey, testValue); + + // GET operation + const retrievedValue = await cache.get(testKey); + expect(retrievedValue).toEqual(testValue); + + // DELETE operation + const deleted = await cache.delete(testKey); + expect(deleted).toBe(true); + + // Verify deletion + const afterDelete = await cache.get(testKey); + expect(afterDelete).toBeUndefined(); + }); + + test('should respect namespace prefixing', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const cache1 = cacheFactory.violationCache('namespace1'); + const cache2 = cacheFactory.violationCache('namespace2'); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + const testKey = 'shared-key'; + const value1: ViolationData = { namespace: 1 }; + const value2: ViolationData = { namespace: 2 }; + + // Set same key in different namespaces + await cache1.set(testKey, value1); + await cache2.set(testKey, value2); + + // Verify namespace isolation + const retrieved1 = await cache1.get(testKey); + const retrieved2 = await cache2.get(testKey); + + expect(retrieved1).toEqual(value1); + expect(retrieved2).toEqual(value2); + + // Clean up + await cache1.delete(testKey); + await cache2.delete(testKey); + }); + + test('should respect TTL settings', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const ttl = 1000; // 1 second TTL + const cache = cacheFactory.violationCache('ttl-test', ttl); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + const testKey = 'ttl-key'; + const testValue: ViolationData = { data: 'expires soon' }; + + // Set value with TTL + await cache.set(testKey, testValue); + + // Verify value exists immediately + const immediate = await cache.get(testKey); + expect(immediate).toEqual(testValue); + + // Wait for TTL to expire + await new Promise((resolve) => setTimeout(resolve, ttl + 100)); + + // Verify value has expired + const expired = await cache.get(testKey); + expect(expired).toBeUndefined(); + }); + + test('should handle complex violation data structures', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const cache = cacheFactory.violationCache('complex-violations'); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + const complexData: ViolationData = { + userId: 'user123', + violations: [ + { type: 'rate_limit', timestamp: Date.now(), severity: 'warning' }, + { type: 'spam', timestamp: Date.now() - 1000, severity: 'critical' }, + ], + metadata: { + ip: '192.168.1.1', + userAgent: 'Mozilla/5.0', + nested: { + deep: { + value: 'test', + }, + }, + }, + }; + + const key = 'complex-violation-data'; + + // Store complex data + await cache.set(key, complexData); + + // Retrieve and verify + const retrieved = await cache.get(key); + expect(retrieved).toEqual(complexData); + + // Clean up + await cache.delete(key); + }); +}); diff --git a/packages/api/src/cache/__tests__/redisClients.integration.spec.ts b/packages/api/src/cache/__tests__/redisClients.integration.spec.ts new file mode 100644 index 0000000000..e2bbda7224 --- /dev/null +++ b/packages/api/src/cache/__tests__/redisClients.integration.spec.ts @@ -0,0 +1,168 @@ +import type { Redis, Cluster } from 'ioredis'; +import type { RedisClientType, RedisClusterType } from '@redis/client'; + +type RedisClient = RedisClientType | RedisClusterType | Redis | Cluster; + +describe('redisClients Integration Tests', () => { + let originalEnv: NodeJS.ProcessEnv; + let ioredisClient: Redis | Cluster | null = null; + let keyvRedisClient: RedisClientType | RedisClusterType | null = null; + + // Helper function to test set/get/delete operations + const testRedisOperations = async (client: RedisClient, keyPrefix: string): Promise => { + // Wait cluster to fully initialize + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const testKey = `${keyPrefix}-test-key`; + const testValue = `${keyPrefix}-test-value`; + + // Test set operation + await client.set(testKey, testValue); + + // Test get operation + const result = await client.get(testKey); + expect(result).toBe(testValue); + + // Test delete operation + const deleteResult = await client.del(testKey); + expect(deleteResult).toBe(1); + + // Verify key is deleted + const deletedResult = await client.get(testKey); + expect(deletedResult).toBeNull(); + }; + + beforeEach(() => { + originalEnv = { ...process.env }; + + // Clear Redis-related env vars + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.USE_REDIS_CLUSTER; + delete process.env.REDIS_PING_INTERVAL; + delete process.env.REDIS_KEY_PREFIX; + + // Set common test configuration + process.env.REDIS_PING_INTERVAL = '0'; + process.env.REDIS_KEY_PREFIX = 'Redis-Integration-Test'; + process.env.REDIS_RETRY_MAX_ATTEMPTS = '5'; + process.env.REDIS_PING_INTERVAL = '1000'; + + // Clear module cache to reload module + jest.resetModules(); + }); + + afterEach(async () => { + // Clean up test keys using the prefix + if (ioredisClient && ioredisClient.status === 'ready') { + try { + const keys = await ioredisClient.keys('Redis-Integration-Test::*'); + if (keys.length > 0) { + await ioredisClient.del(...keys); + } + } catch (error: any) { + console.warn('Error cleaning up test keys:', error.message); + } + } + + // Cleanup Redis connections + if (ioredisClient) { + try { + if (ioredisClient.status === 'ready') { + ioredisClient.disconnect(); + } + } catch (error: any) { + console.warn('Error disconnecting ioredis client:', error.message); + } + ioredisClient = null; + } + + if (keyvRedisClient) { + try { + // Try to disconnect - keyv/redis client doesn't have an isReady property + await keyvRedisClient.disconnect(); + } catch (error: any) { + console.warn('Error disconnecting keyv redis client:', error.message); + } + keyvRedisClient = null; + } + + process.env = originalEnv; + jest.resetModules(); + }); + + describe('ioredis Client Tests', () => { + describe('when USE_REDIS is false', () => { + test('should have null client', async () => { + process.env.USE_REDIS = 'false'; + + const clients = await import('../redisClients'); + ioredisClient = clients.ioredisClient; + + expect(ioredisClient).toBeNull(); + }); + }); + + describe('when connecting to a Redis instance', () => { + test('should connect and perform set/get/delete operations', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const clients = await import('../redisClients'); + ioredisClient = clients.ioredisClient; + await testRedisOperations(ioredisClient!, 'ioredis-single'); + }); + }); + + describe('when connecting to a Redis cluster', () => { + test('should connect to cluster and perform set/get/delete operations', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'true'; + process.env.REDIS_URI = + 'redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003'; + + const clients = await import('../redisClients'); + ioredisClient = clients.ioredisClient; + await testRedisOperations(ioredisClient!, 'ioredis-cluster'); + }); + }); + }); + + describe('keyvRedisClient Tests', () => { + describe('when USE_REDIS is false', () => { + test('should have null client', async () => { + process.env.USE_REDIS = 'false'; + + const clients = await import('../redisClients'); + keyvRedisClient = clients.keyvRedisClient; + expect(keyvRedisClient).toBeNull(); + }); + }); + + describe('when connecting to a Redis instance', () => { + test('should connect and perform set/get/delete operations', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const clients = await import('../redisClients'); + keyvRedisClient = clients.keyvRedisClient; + await testRedisOperations(keyvRedisClient!, 'keyv-single'); + }); + }); + + describe('when connecting to a Redis cluster', () => { + test('should connect to cluster and perform set/get/delete operations', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'true'; + process.env.REDIS_URI = + 'redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003'; + + const clients = await import('../redisClients'); + keyvRedisClient = clients.keyvRedisClient; + await testRedisOperations(keyvRedisClient!, 'keyv-cluster'); + }); + }); + }); +}); diff --git a/api/cache/cacheConfig.js b/packages/api/src/cache/cacheConfig.ts similarity index 87% rename from api/cache/cacheConfig.js rename to packages/api/src/cache/cacheConfig.ts index 4a5fea113b..aebfeef3bd 100644 --- a/api/cache/cacheConfig.js +++ b/packages/api/src/cache/cacheConfig.ts @@ -1,7 +1,7 @@ -const fs = require('fs'); -const { logger } = require('@librechat/data-schemas'); -const { math, isEnabled } = require('@librechat/api'); -const { CacheKeys } = require('librechat-data-provider'); +import { readFileSync, existsSync } from 'fs'; +import { logger } from '@librechat/data-schemas'; +import { CacheKeys } from 'librechat-data-provider'; +import { math, isEnabled } from '~/utils'; // To ensure that different deployments do not interfere with each other's cache, we use a prefix for the Redis keys. // This prefix is usually the deployment ID, which is often passed to the container or pod as an env var. @@ -25,7 +25,7 @@ const FORCED_IN_MEMORY_CACHE_NAMESPACES = process.env.FORCED_IN_MEMORY_CACHE_NAM // Validate against CacheKeys enum if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) { - const validKeys = Object.values(CacheKeys); + const validKeys = Object.values(CacheKeys) as string[]; const invalidKeys = FORCED_IN_MEMORY_CACHE_NAMESPACES.filter((key) => !validKeys.includes(key)); if (invalidKeys.length > 0) { @@ -38,15 +38,15 @@ if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) { /** Helper function to safely read Redis CA certificate from file * @returns {string|null} The contents of the CA certificate file, or null if not set or on error */ -const getRedisCA = () => { +const getRedisCA = (): string | null => { const caPath = process.env.REDIS_CA; if (!caPath) { return null; } try { - if (fs.existsSync(caPath)) { - return fs.readFileSync(caPath, 'utf8'); + if (existsSync(caPath)) { + return readFileSync(caPath, 'utf8'); } else { logger.warn(`Redis CA certificate file not found: ${caPath}`); return null; @@ -64,7 +64,7 @@ const cacheConfig = { REDIS_USERNAME: process.env.REDIS_USERNAME, REDIS_PASSWORD: process.env.REDIS_PASSWORD, REDIS_CA: getRedisCA(), - REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR] || REDIS_KEY_PREFIX || '', + REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR ?? ''] || REDIS_KEY_PREFIX || '', REDIS_MAX_LISTENERS: math(process.env.REDIS_MAX_LISTENERS, 40), REDIS_PING_INTERVAL: math(process.env.REDIS_PING_INTERVAL, 0), /** Max delay between reconnection attempts in ms */ @@ -86,4 +86,4 @@ const cacheConfig = { BAN_DURATION: math(process.env.BAN_DURATION, 7200000), // 2 hours }; -module.exports = { cacheConfig }; +export { cacheConfig }; diff --git a/packages/api/src/cache/cacheFactory.ts b/packages/api/src/cache/cacheFactory.ts new file mode 100644 index 0000000000..427b1b38ad --- /dev/null +++ b/packages/api/src/cache/cacheFactory.ts @@ -0,0 +1,116 @@ +/** + * @keyv/redis exports its default class in a non-standard way: + * module.exports = { default: KeyvRedis, ... } instead of module.exports = KeyvRedis + * This breaks ES6 imports when the module is marked as external in rollup. + * We must use require() to access the .default property directly. + */ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const KeyvRedis = require('@keyv/redis').default as typeof import('@keyv/redis').default; +import { Keyv } from 'keyv'; +import createMemoryStore from 'memorystore'; +import { RedisStore } from 'rate-limit-redis'; +import { Time } from 'librechat-data-provider'; +import { logger } from '@librechat/data-schemas'; +import session, { MemoryStore } from 'express-session'; +import { RedisStore as ConnectRedis } from 'connect-redis'; +import type { SendCommandFn } from 'rate-limit-redis'; +import { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } from './redisClients'; +import { cacheConfig } from './cacheConfig'; +import { violationFile } from './keyvFiles'; + +/** + * Creates a cache instance using Redis or a fallback store. Suitable for general caching needs. + * @param namespace - The cache namespace. + * @param ttl - Time to live for cache entries. + * @param fallbackStore - Optional fallback store if Redis is not used. + * @returns Cache instance. + */ +export const standardCache = (namespace: string, ttl?: number, fallbackStore?: object): Keyv => { + if (keyvRedisClient && !cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace)) { + try { + const keyvRedis = new KeyvRedis(keyvRedisClient); + const cache = new Keyv(keyvRedis, { namespace, ttl }); + keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX; + keyvRedis.keyPrefixSeparator = GLOBAL_PREFIX_SEPARATOR; + + cache.on('error', (err) => { + logger.error(`Cache error in namespace ${namespace}:`, err); + }); + + return cache; + } catch (err) { + logger.error(`Failed to create Redis cache for namespace ${namespace}:`, err); + throw err; + } + } + if (fallbackStore) { + return new Keyv({ store: fallbackStore, namespace, ttl }); + } + return new Keyv({ namespace, ttl }); +}; + +/** + * Creates a cache instance for storing violation data. + * Uses a file-based fallback store if Redis is not enabled. + * @param namespace - The cache namespace for violations. + * @param ttl - Time to live for cache entries. + * @returns Cache instance for violations. + */ +export const violationCache = (namespace: string, ttl?: number): Keyv => { + return standardCache(`violations:${namespace}`, ttl, violationFile); +}; + +/** + * Creates a session cache instance using Redis or in-memory store. + * @param namespace - The session namespace. + * @param ttl - Time to live for session entries. + * @returns Session store instance. + */ +export const sessionCache = (namespace: string, ttl?: number): MemoryStore | ConnectRedis => { + namespace = namespace.endsWith(':') ? namespace : `${namespace}:`; + if (!cacheConfig.USE_REDIS) { + const MemoryStore = createMemoryStore(session); + return new MemoryStore({ ttl, checkPeriod: Time.ONE_DAY }); + } + const store = new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace }); + if (ioredisClient) { + ioredisClient.on('error', (err) => { + logger.error(`Session store Redis error for namespace ${namespace}:`, err); + }); + } + return store; +}; + +/** + * Creates a rate limiter cache using Redis. + * @param prefix - The key prefix for rate limiting. + * @returns RedisStore instance or undefined if Redis is not used. + */ +export const limiterCache = (prefix: string): RedisStore | undefined => { + if (!prefix) { + throw new Error('prefix is required'); + } + if (!cacheConfig.USE_REDIS) { + return undefined; + } + // TODO: The prefix is not actually applied. Also needs to account for global prefix. + prefix = prefix.endsWith(':') ? prefix : `${prefix}:`; + + try { + const sendCommand: SendCommandFn = (async (...args: string[]) => { + if (ioredisClient == null) { + throw new Error('Redis client not available'); + } + try { + return await ioredisClient.call(args[0], ...args.slice(1)); + } catch (err) { + logger.error('Redis command execution failed:', err); + throw err; + } + }) as SendCommandFn; + return new RedisStore({ sendCommand, prefix }); + } catch (err) { + logger.error(`Failed to create Redis rate limiter for prefix ${prefix}:`, err); + return undefined; + } +}; diff --git a/packages/api/src/cache/index.ts b/packages/api/src/cache/index.ts new file mode 100644 index 0000000000..de1076e803 --- /dev/null +++ b/packages/api/src/cache/index.ts @@ -0,0 +1,5 @@ +export * from './cacheConfig'; +export * from './redisClients'; +export * from './keyvFiles'; +export { default as keyvMongo } from './keyvMongo'; +export * from './cacheFactory'; diff --git a/packages/api/src/cache/keyvFiles.ts b/packages/api/src/cache/keyvFiles.ts new file mode 100644 index 0000000000..94fe9b7cab --- /dev/null +++ b/packages/api/src/cache/keyvFiles.ts @@ -0,0 +1,6 @@ +import { KeyvFile } from 'keyv-file'; + +export const logFile = new KeyvFile({ filename: './data/logs.json' }).setMaxListeners(20); +export const violationFile = new KeyvFile({ filename: './data/violations.json' }).setMaxListeners( + 20, +); diff --git a/api/cache/keyvMongo.js b/packages/api/src/cache/keyvMongo.ts similarity index 66% rename from api/cache/keyvMongo.js rename to packages/api/src/cache/keyvMongo.ts index efcce76752..68c7262c6f 100644 --- a/api/cache/keyvMongo.js +++ b/packages/api/src/cache/keyvMongo.ts @@ -1,65 +1,68 @@ -// api/cache/keyvMongo.js -const mongoose = require('mongoose'); -const EventEmitter = require('events'); -const { GridFSBucket } = require('mongodb'); -const { logger } = require('@librechat/data-schemas'); +import mongoose from 'mongoose'; +import { EventEmitter } from 'events'; +import { logger } from '@librechat/data-schemas'; +import { GridFSBucket, type Db, type ReadPreference, type Collection } from 'mongodb'; -const storeMap = new Map(); +interface KeyvMongoOptions { + url?: string; + collection?: string; + useGridFS?: boolean; + readPreference?: ReadPreference; +} + +interface GridFSClient { + bucket: GridFSBucket; + store: Collection; + db: Db; +} + +interface CollectionClient { + store: Collection; + db: Db; +} + +type Client = GridFSClient | CollectionClient; + +const storeMap = new Map(); class KeyvMongoCustom extends EventEmitter { - constructor(url, options = {}) { - super(); + private opts: KeyvMongoOptions; + public ttlSupport: boolean; + public namespace?: string; - url = url || {}; - if (typeof url === 'string') { - url = { url }; - } - if (url.uri) { - url = { url: url.uri, ...url }; - } + constructor(options: KeyvMongoOptions = {}) { + super(); this.opts = { url: 'mongodb://127.0.0.1:27017', collection: 'keyv', - ...url, ...options, }; this.ttlSupport = false; - - // Filter valid options - const keyvMongoKeys = new Set([ - 'url', - 'collection', - 'namespace', - 'serialize', - 'deserialize', - 'uri', - 'useGridFS', - 'dialect', - ]); - this.opts = Object.fromEntries(Object.entries(this.opts).filter(([k]) => keyvMongoKeys.has(k))); } // Helper to access the store WITHOUT storing a promise on the instance - _getClient() { + private async _getClient(): Promise { const storeKey = `${this.opts.collection}:${this.opts.useGridFS ? 'gridfs' : 'collection'}`; // If we already have the store initialized, return it directly if (storeMap.has(storeKey)) { - return Promise.resolve(storeMap.get(storeKey)); + return storeMap.get(storeKey)!; } // Check mongoose connection state if (mongoose.connection.readyState !== 1) { - return Promise.reject( - new Error('Mongoose connection not ready. Ensure connectDb() is called first.'), - ); + throw new Error('Mongoose connection not ready. Ensure connectDb() is called first.'); } try { - const db = mongoose.connection.db; - let client; + const db = mongoose.connection.db as unknown as Db | undefined; + if (!db) { + throw new Error('MongoDB database not available'); + } + + let client: Client; if (this.opts.useGridFS) { const bucket = new GridFSBucket(db, { @@ -75,17 +78,17 @@ class KeyvMongoCustom extends EventEmitter { } storeMap.set(storeKey, client); - return Promise.resolve(client); + return client; } catch (error) { this.emit('error', error); - return Promise.reject(error); + throw error; } } - async get(key) { + async get(key: string): Promise { const client = await this._getClient(); - if (this.opts.useGridFS) { + if (this.opts.useGridFS && this.isGridFSClient(client)) { await client.store.updateOne( { filename: key, @@ -100,7 +103,7 @@ class KeyvMongoCustom extends EventEmitter { const stream = client.bucket.openDownloadStreamByName(key); return new Promise((resolve) => { - const resp = []; + const resp: Buffer[] = []; stream.on('error', () => { resolve(undefined); }); @@ -110,7 +113,7 @@ class KeyvMongoCustom extends EventEmitter { resolve(data); }); - stream.on('data', (chunk) => { + stream.on('data', (chunk: Buffer) => { resp.push(chunk); }); }); @@ -125,7 +128,7 @@ class KeyvMongoCustom extends EventEmitter { return document.value; } - async getMany(keys) { + async getMany(keys: string[]): Promise { const client = await this._getClient(); if (this.opts.useGridFS) { @@ -135,9 +138,9 @@ class KeyvMongoCustom extends EventEmitter { } const values = await Promise.allSettled(promises); - const data = []; + const data: unknown[] = []; for (const value of values) { - data.push(value.value); + data.push(value.status === 'fulfilled' ? value.value : undefined); } return data; @@ -148,7 +151,7 @@ class KeyvMongoCustom extends EventEmitter { .project({ _id: 0, value: 1, key: 1 }) .toArray(); - const results = [...keys]; + const results: unknown[] = [...keys]; let i = 0; for (const key of keys) { const rowIndex = values.findIndex((row) => row.key === key); @@ -159,11 +162,11 @@ class KeyvMongoCustom extends EventEmitter { return results; } - async set(key, value, ttl) { + async set(key: string, value: string, ttl?: number): Promise { const client = await this._getClient(); const expiresAt = typeof ttl === 'number' ? new Date(Date.now() + ttl) : null; - if (this.opts.useGridFS) { + if (this.opts.useGridFS && this.isGridFSClient(client)) { const stream = client.bucket.openUploadStream(key, { metadata: { expiresAt, @@ -186,20 +189,18 @@ class KeyvMongoCustom extends EventEmitter { ); } - async delete(key) { - if (typeof key !== 'string') { - return false; - } - + async delete(key: string): Promise { const client = await this._getClient(); - if (this.opts.useGridFS) { + if (this.opts.useGridFS && this.isGridFSClient(client)) { try { const bucket = new GridFSBucket(client.db, { bucketName: this.opts.collection, }); const files = await bucket.find({ filename: key }).toArray(); - await client.bucket.delete(files[0]._id); + if (files.length > 0) { + await client.bucket.delete(files[0]._id); + } return true; } catch { return false; @@ -210,10 +211,10 @@ class KeyvMongoCustom extends EventEmitter { return object.deletedCount > 0; } - async deleteMany(keys) { + async deleteMany(keys: string[]): Promise { const client = await this._getClient(); - if (this.opts.useGridFS) { + if (this.opts.useGridFS && this.isGridFSClient(client)) { const bucket = new GridFSBucket(client.db, { bucketName: this.opts.collection, }); @@ -230,15 +231,17 @@ class KeyvMongoCustom extends EventEmitter { return object.deletedCount > 0; } - async clear() { + async clear(): Promise { const client = await this._getClient(); - if (this.opts.useGridFS) { + if (this.opts.useGridFS && this.isGridFSClient(client)) { try { await client.bucket.drop(); - } catch (error) { + } catch (error: unknown) { // Throw error if not "namespace not found" error - if (!(error.code === 26)) { + const errorCode = + error instanceof Error && 'code' in error ? (error as { code?: number }).code : undefined; + if (errorCode !== 26) { throw error; } } @@ -249,7 +252,7 @@ class KeyvMongoCustom extends EventEmitter { }); } - async has(key) { + async has(key: string): Promise { const client = await this._getClient(); const filter = { [this.opts.useGridFS ? 'filename' : 'key']: { $eq: key } }; const document = await client.store.countDocuments(filter, { limit: 1 }); @@ -257,10 +260,14 @@ class KeyvMongoCustom extends EventEmitter { } // No-op disconnect - async disconnect() { + async disconnect(): Promise { // This is a no-op since we don't want to close the shared mongoose connection return true; } + + private isGridFSClient(client: Client): client is GridFSClient { + return (client as GridFSClient).bucket != null; + } } const keyvMongo = new KeyvMongoCustom({ @@ -269,4 +276,4 @@ const keyvMongo = new KeyvMongoCustom({ keyvMongo.on('error', (err) => logger.error('KeyvMongo connection error:', err)); -module.exports = keyvMongo; +export default keyvMongo; diff --git a/api/cache/redisClients.js b/packages/api/src/cache/redisClients.ts similarity index 73% rename from api/cache/redisClients.js rename to packages/api/src/cache/redisClients.ts index ab59656add..6c11c1a0a8 100644 --- a/api/cache/redisClients.js +++ b/packages/api/src/cache/redisClients.ts @@ -1,26 +1,26 @@ -const IoRedis = require('ioredis'); -const { logger } = require('@librechat/data-schemas'); -const { createClient, createCluster } = require('@keyv/redis'); -const { cacheConfig } = require('./cacheConfig'); +import IoRedis from 'ioredis'; +import type { Redis, Cluster } from 'ioredis'; +import { logger } from '@librechat/data-schemas'; +import { createClient, createCluster } from '@keyv/redis'; +import type { RedisClientType, RedisClusterType } from '@redis/client'; +import { cacheConfig } from './cacheConfig'; const GLOBAL_PREFIX_SEPARATOR = '::'; -const urls = cacheConfig.REDIS_URI?.split(',').map((uri) => new URL(uri)); -const username = urls?.[0].username || cacheConfig.REDIS_USERNAME; -const password = urls?.[0].password || cacheConfig.REDIS_PASSWORD; +const urls = cacheConfig.REDIS_URI?.split(',').map((uri) => new URL(uri)) || []; +const username = urls?.[0]?.username || cacheConfig.REDIS_USERNAME; +const password = urls?.[0]?.password || cacheConfig.REDIS_PASSWORD; const ca = cacheConfig.REDIS_CA; -/** @type {import('ioredis').Redis | import('ioredis').Cluster | null} */ -let ioredisClient = null; +let ioredisClient: Redis | Cluster | null = null; if (cacheConfig.USE_REDIS) { - /** @type {import('ioredis').RedisOptions | import('ioredis').ClusterOptions} */ - const redisOptions = { + const redisOptions: Record = { username: username, password: password, tls: ca ? { ca } : undefined, keyPrefix: `${cacheConfig.REDIS_KEY_PREFIX}${GLOBAL_PREFIX_SEPARATOR}`, maxListeners: cacheConfig.REDIS_MAX_LISTENERS, - retryStrategy: (times) => { + retryStrategy: (times: number) => { if ( cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 && times > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS @@ -34,7 +34,7 @@ if (cacheConfig.USE_REDIS) { logger.info(`ioredis reconnecting... attempt ${times}, delay ${delay}ms`); return delay; }, - reconnectOnError: (err) => { + reconnectOnError: (err: Error) => { const targetError = 'READONLY'; if (err.message.includes(targetError)) { logger.warn('ioredis reconnecting due to READONLY error'); @@ -49,15 +49,20 @@ if (cacheConfig.USE_REDIS) { ioredisClient = urls.length === 1 && !cacheConfig.USE_REDIS_CLUSTER - ? new IoRedis(cacheConfig.REDIS_URI, redisOptions) + ? new IoRedis(cacheConfig.REDIS_URI!, redisOptions) : new IoRedis.Cluster( urls.map((url) => ({ host: url.hostname, port: parseInt(url.port, 10) || 6379 })), { ...(cacheConfig.REDIS_USE_ALTERNATIVE_DNS_LOOKUP - ? { dnsLookup: (address, callback) => callback(null, address) } + ? { + dnsLookup: ( + address: string, + callback: (err: Error | null, address: string) => void, + ) => callback(null, address), + } : {}), redisOptions, - clusterRetryStrategy: (times) => { + clusterRetryStrategy: (times: number) => { if ( cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 && times > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS @@ -87,7 +92,7 @@ if (cacheConfig.USE_REDIS) { logger.info('ioredis client ready'); }); - ioredisClient.on('reconnecting', (delay) => { + ioredisClient.on('reconnecting', (delay: number) => { logger.info(`ioredis client reconnecting in ${delay}ms`); }); @@ -96,7 +101,7 @@ if (cacheConfig.USE_REDIS) { }); /** Ping Interval to keep the Redis server connection alive (if enabled) */ - let pingInterval = null; + let pingInterval: NodeJS.Timeout | null = null; const clearPingInterval = () => { if (pingInterval) { clearInterval(pingInterval); @@ -117,22 +122,20 @@ if (cacheConfig.USE_REDIS) { } } -/** @type {import('@keyv/redis').RedisClient | import('@keyv/redis').RedisCluster | null} */ -let keyvRedisClient = null; +let keyvRedisClient: RedisClientType | RedisClusterType | null = null; if (cacheConfig.USE_REDIS) { /** * ** WARNING ** Keyv Redis client does not support Prefix like ioredis above. * The prefix feature will be handled by the Keyv-Redis store in cacheFactory.js - * @type {import('@keyv/redis').RedisClientOptions | import('@keyv/redis').RedisClusterOptions} */ - const redisOptions = { + const redisOptions: Record = { username, password, socket: { tls: ca != null, ca, connectTimeout: cacheConfig.REDIS_CONNECT_TIMEOUT, - reconnectStrategy: (retries) => { + reconnectStrategy: (retries: number) => { if ( cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 && retries > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS @@ -148,6 +151,9 @@ if (cacheConfig.USE_REDIS) { }, }, disableOfflineQueue: !cacheConfig.REDIS_ENABLE_OFFLINE_QUEUE, + ...(cacheConfig.REDIS_PING_INTERVAL > 0 + ? { pingInterval: cacheConfig.REDIS_PING_INTERVAL * 1000 } + : {}), }; keyvRedisClient = @@ -184,27 +190,6 @@ if (cacheConfig.USE_REDIS) { logger.error('@keyv/redis initial connection failed:', err); throw err; }); - - /** Ping Interval to keep the Redis server connection alive (if enabled) */ - let pingInterval = null; - const clearPingInterval = () => { - if (pingInterval) { - clearInterval(pingInterval); - pingInterval = null; - } - }; - - if (cacheConfig.REDIS_PING_INTERVAL > 0) { - pingInterval = setInterval(() => { - if (keyvRedisClient && keyvRedisClient.isReady) { - keyvRedisClient.ping().catch((err) => { - logger.error('@keyv/redis ping failed:', err); - }); - } - }, cacheConfig.REDIS_PING_INTERVAL * 1000); - keyvRedisClient.on('disconnect', clearPingInterval); - keyvRedisClient.on('end', clearPingInterval); - } } -module.exports = { ioredisClient, keyvRedisClient, GLOBAL_PREFIX_SEPARATOR }; +export { ioredisClient, keyvRedisClient, GLOBAL_PREFIX_SEPARATOR }; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 6cfdc9bcc0..bc52c02229 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -35,6 +35,8 @@ export * from './files'; export * from './tools'; /* web search */ export * from './web'; +/* Cache */ +export * from './cache'; /* types */ export type * from './mcp/types'; export type * from './flow/types'; diff --git a/packages/api/src/utils/math.ts b/packages/api/src/utils/math.ts index 08ae04f7eb..7201880ce3 100644 --- a/packages/api/src/utils/math.ts +++ b/packages/api/src/utils/math.ts @@ -12,8 +12,8 @@ * * @throws Throws an error if the input is not a string or number, contains invalid characters, or does not evaluate to a number. */ -export function math(str: string | number, fallbackValue?: number): number { - const fallback = typeof fallbackValue !== 'undefined' && typeof fallbackValue === 'number'; +export function math(str: string | number | undefined, fallbackValue?: number): number { + const fallback = fallbackValue != null; if (typeof str !== 'string' && typeof str === 'number') { return str; } else if (typeof str !== 'string') { diff --git a/redis-config/start-cluster.sh b/redis-config/start-cluster.sh index d46227c348..fd8622fcd9 100755 --- a/redis-config/start-cluster.sh +++ b/redis-config/start-cluster.sh @@ -35,7 +35,7 @@ redis-server redis-7002.conf --daemonize yes redis-server redis-7003.conf --daemonize yes # Wait for nodes to start -sleep 3 +sleep 5 # Check if all nodes are running NODES_RUNNING=0 @@ -66,10 +66,14 @@ fi # Initialize the cluster echo "🔧 Initializing cluster..." -echo "yes" | redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 --cluster-replicas 0 > /dev/null +echo "yes" | redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 --cluster-replicas 0 2>&1 | tee /tmp/cluster-init.log || { + echo "❌ Cluster creation command failed. Output:" + cat /tmp/cluster-init.log + exit 1 +} # Wait for cluster to stabilize -sleep 3 +sleep 5 # Verify cluster status if redis-cli -p 7001 cluster info | grep -q "cluster_state:ok"; then @@ -80,5 +84,10 @@ if redis-cli -p 7001 cluster info | grep -q "cluster_state:ok"; then echo " Stop: ./stop-cluster.sh" else echo "❌ Cluster initialization failed!" + echo "📊 Cluster info from node 7001:" + redis-cli -p 7001 cluster info + echo "" + echo "📊 Cluster nodes from node 7001:" + redis-cli -p 7001 cluster nodes exit 1 fi \ No newline at end of file From c0ed738aed0ae4af7fd710ecca67459980c74691 Mon Sep 17 00:00:00 2001 From: Federico Ruggi Date: Fri, 3 Oct 2025 22:01:34 +0200 Subject: [PATCH 004/272] =?UTF-8?q?=F0=9F=9A=89=20feat:=20MCP=20Registry?= =?UTF-8?q?=20Individual=20Server=20Init=20(2)=20(#9940)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initialize servers sequentially * adjust for exported properties that are not nullable anymore * use underscore separator * mock with set * customize init timeout via env var * refactor for readability, use loaded conns for tool functions * address PR comments * clean up fire-and-forget * fix tests --- api/server/services/MCP.js | 2 +- api/server/services/MCP.spec.js | 2 +- packages/api/src/mcp/MCPManager.ts | 19 +- packages/api/src/mcp/MCPServersRegistry.ts | 140 ++++++---- .../mcp/__tests__/MCPServersRegistry.test.ts | 256 +++++++++++++++++- .../src/mcp/oauth/OAuthReconnectionManager.ts | 2 +- 6 files changed, 341 insertions(+), 80 deletions(-) diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 162e02d91e..b7975b12fa 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -450,7 +450,7 @@ async function getMCPSetupData(userId) { logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error); } const userConnections = mcpManager.getUserConnections(userId) || new Map(); - const oauthServers = mcpManager.getOAuthServers() || new Set(); + const oauthServers = mcpManager.getOAuthServers(); return { mcpConfig, diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 8b9f7b675d..7b192995e3 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -170,7 +170,7 @@ describe('tests for the new helper functions used by the MCP connection status e const mockMCPManager = { appConnections: { getAll: jest.fn(() => null) }, getUserConnections: jest.fn(() => null), - getOAuthServers: jest.fn(() => null), + getOAuthServers: jest.fn(() => new Set()), }; mockGetMCPManager.mockReturnValue(mockMCPManager); diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index a8768cf7b0..c6bfe77b8f 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -38,7 +38,7 @@ export class MCPManager extends UserConnectionManager { /** Initializes the MCPManager by setting up server registry and app connections */ public async initialize() { await this.serversRegistry.initialize(); - this.appConnections = new ConnectionsRepository(this.serversRegistry.appServerConfigs!); + this.appConnections = new ConnectionsRepository(this.serversRegistry.appServerConfigs); } /** Retrieves an app-level or user-specific connection based on provided arguments */ @@ -63,22 +63,23 @@ export class MCPManager extends UserConnectionManager { } /** Get servers that require OAuth */ - public getOAuthServers(): Set | null { - return this.serversRegistry.oauthServers!; + public getOAuthServers(): Set { + return this.serversRegistry.oauthServers; } /** Get all servers */ - public getAllServers(): t.MCPServers | null { - return this.serversRegistry.rawConfigs!; + public getAllServers(): t.MCPServers { + return this.serversRegistry.rawConfigs; } /** Returns all available tool functions from app-level connections */ - public getAppToolFunctions(): t.LCAvailableTools | null { - return this.serversRegistry.toolFunctions!; + public getAppToolFunctions(): t.LCAvailableTools { + return this.serversRegistry.toolFunctions; } + /** Returns all available tool functions from all connections available to user */ public async getAllToolFunctions(userId: string): Promise { - const allToolFunctions: t.LCAvailableTools = this.getAppToolFunctions() ?? {}; + const allToolFunctions: t.LCAvailableTools = this.getAppToolFunctions(); const userConnections = this.getUserConnections(userId); if (!userConnections || userConnections.size === 0) { return allToolFunctions; @@ -128,7 +129,7 @@ export class MCPManager extends UserConnectionManager { * @returns Object mapping server names to their instructions */ public getInstructions(serverNames?: string[]): Record { - const instructions = this.serversRegistry.serverInstructions!; + const instructions = this.serversRegistry.serverInstructions; if (!serverNames) return instructions; return pick(instructions, serverNames); } diff --git a/packages/api/src/mcp/MCPServersRegistry.ts b/packages/api/src/mcp/MCPServersRegistry.ts index 905a62bef8..b6eaa09f01 100644 --- a/packages/api/src/mcp/MCPServersRegistry.ts +++ b/packages/api/src/mcp/MCPServersRegistry.ts @@ -1,5 +1,3 @@ -import pick from 'lodash/pick'; -import pickBy from 'lodash/pickBy'; import mapValues from 'lodash/mapValues'; import { logger } from '@librechat/data-schemas'; import { Constants } from 'librechat-data-provider'; @@ -11,6 +9,14 @@ import { detectOAuthRequirement } from '~/mcp/oauth'; import { sanitizeUrlForLogging } from '~/mcp/utils'; import { processMCPEnv, isEnabled } from '~/utils'; +const DEFAULT_MCP_INIT_TIMEOUT_MS = 30_000; + +function getMCPInitTimeout(): number { + return process.env.MCP_INIT_TIMEOUT_MS != null + ? parseInt(process.env.MCP_INIT_TIMEOUT_MS) + : DEFAULT_MCP_INIT_TIMEOUT_MS; +} + /** * Manages MCP server configurations and metadata discovery. * Fetches server capabilities, OAuth requirements, and tool definitions for registry. @@ -20,19 +26,21 @@ import { processMCPEnv, isEnabled } from '~/utils'; export class MCPServersRegistry { private initialized: boolean = false; private connections: ConnectionsRepository; + private initTimeoutMs: number; public readonly rawConfigs: t.MCPServers; public readonly parsedConfigs: Record; - public oauthServers: Set | null = null; - public serverInstructions: Record | null = null; - public toolFunctions: t.LCAvailableTools | null = null; - public appServerConfigs: t.MCPServers | null = null; + public oauthServers: Set = new Set(); + public serverInstructions: Record = {}; + public toolFunctions: t.LCAvailableTools = {}; + public appServerConfigs: t.MCPServers = {}; constructor(configs: t.MCPServers) { this.rawConfigs = configs; this.parsedConfigs = mapValues(configs, (con) => processMCPEnv({ options: con })); this.connections = new ConnectionsRepository(configs); + this.initTimeoutMs = getMCPInitTimeout(); } /** Initializes all startup-enabled servers by gathering their metadata asynchronously */ @@ -42,21 +50,43 @@ export class MCPServersRegistry { const serverNames = Object.keys(this.parsedConfigs); - await Promise.allSettled(serverNames.map((serverName) => this.gatherServerInfo(serverName))); - - this.setOAuthServers(); - this.setServerInstructions(); - this.setAppServerConfigs(); - await this.setAppToolFunctions(); - - this.connections.disconnectAll(); + await Promise.allSettled( + serverNames.map((serverName) => this.initializeServerWithTimeout(serverName)), + ); } - /** Fetches all metadata for a single server in parallel */ - private async gatherServerInfo(serverName: string): Promise { + /** Wraps server initialization with a timeout to prevent hanging */ + private async initializeServerWithTimeout(serverName: string): Promise { + let timeoutId: NodeJS.Timeout | null = null; + + try { + await Promise.race([ + this.initializeServer(serverName), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error('Server initialization timed out')); + }, this.initTimeoutMs); + }), + ]); + } catch (error) { + logger.warn(`${this.prefix(serverName)} Server initialization failed:`, error); + throw error; + } finally { + if (timeoutId != null) { + clearTimeout(timeoutId); + } + } + } + + /** Initializes a single server with all its metadata and adds it to appropriate collections */ + private async initializeServer(serverName: string): Promise { + const start = Date.now(); + + const config = this.parsedConfigs[serverName]; + + // 1. Detect OAuth requirements if not already specified try { await this.fetchOAuthRequirement(serverName); - const config = this.parsedConfigs[serverName]; if (config.startup !== false && !config.requiresOAuth) { await Promise.allSettled([ @@ -68,54 +98,49 @@ export class MCPServersRegistry { ), ]); } - - this.logUpdatedConfig(serverName); } catch (error) { logger.warn(`${this.prefix(serverName)} Failed to initialize server:`, error); } - } - /** Sets app-level server configs (startup enabled, non-OAuth servers) */ - private setAppServerConfigs(): void { - const appServers = Object.keys( - pickBy( - this.parsedConfigs, - (config) => config.startup !== false && config.requiresOAuth === false, - ), - ); - this.appServerConfigs = pick(this.rawConfigs, appServers); - } - - /** Creates set of server names that require OAuth authentication */ - private setOAuthServers(): Set { - if (this.oauthServers) return this.oauthServers; - this.oauthServers = new Set( - Object.keys(pickBy(this.parsedConfigs, (config) => config.requiresOAuth)), - ); - return this.oauthServers; - } - - /** Collects server instructions from all configured servers */ - private setServerInstructions(): void { - this.serverInstructions = mapValues( - pickBy(this.parsedConfigs, (config) => config.serverInstructions), - (config) => config.serverInstructions as string, - ); - } - - /** Builds registry of all available tool functions from loaded connections */ - private async setAppToolFunctions(): Promise { - const connections = (await this.connections.getLoaded()).entries(); - const allToolFunctions: t.LCAvailableTools = {}; - for (const [serverName, conn] of connections) { + // 2. Fetch tool functions for this server if a connection was established + const getToolFunctions = async (): Promise => { try { - const toolFunctions = await this.getToolFunctions(serverName, conn); - Object.assign(allToolFunctions, toolFunctions); + const loadedConns = await this.connections.getLoaded(); + const conn = loadedConns.get(serverName); + if (conn == null) { + return null; + } + return this.getToolFunctions(serverName, conn); } catch (error) { logger.warn(`${this.prefix(serverName)} Error fetching tool functions:`, error); + return null; } + }; + const toolFunctions = await getToolFunctions(); + + // 3. Disconnect this server's connection if it was established (fire-and-forget) + void this.connections.disconnect(serverName); + + // 4. Side effects + // 4.1 Add to OAuth servers if needed + if (config.requiresOAuth) { + this.oauthServers.add(serverName); } - this.toolFunctions = allToolFunctions; + // 4.2 Add server instructions if available + if (config.serverInstructions != null) { + this.serverInstructions[serverName] = config.serverInstructions as string; + } + // 4.3 Add to app server configs if eligible (startup enabled, non-OAuth servers) + if (config.startup !== false && config.requiresOAuth === false) { + this.appServerConfigs[serverName] = this.rawConfigs[serverName]; + } + // 4.4 Add tool functions if available + if (toolFunctions != null) { + Object.assign(this.toolFunctions, toolFunctions); + } + + const duration = Date.now() - start; + this.logUpdatedConfig(serverName, duration); } /** Converts server tools to LibreChat-compatible tool functions format */ @@ -185,7 +210,7 @@ export class MCPServersRegistry { } // Logs server configuration summary after initialization - private logUpdatedConfig(serverName: string): void { + private logUpdatedConfig(serverName: string, initDuration: number): void { const prefix = this.prefix(serverName); const config = this.parsedConfigs[serverName]; logger.info(`${prefix} -------------------------------------------------┐`); @@ -194,6 +219,7 @@ export class MCPServersRegistry { logger.info(`${prefix} Capabilities: ${config.capabilities}`); logger.info(`${prefix} Tools: ${config.tools}`); logger.info(`${prefix} Server Instructions: ${config.serverInstructions}`); + logger.info(`${prefix} Initialized in: ${initDuration}ms`); logger.info(`${prefix} -------------------------------------------------┘`); } diff --git a/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts b/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts index 9a276e3713..ade8eab32c 100644 --- a/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts +++ b/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts @@ -113,6 +113,7 @@ describe('MCPServersRegistry - Initialize Function', () => { get: jest.fn(), getLoaded: jest.fn(), disconnectAll: jest.fn(), + disconnect: jest.fn().mockResolvedValue(undefined), } as unknown as jest.Mocked; mockConnectionsRepo.get.mockImplementation((serverName: string) => { @@ -160,6 +161,7 @@ describe('MCPServersRegistry - Initialize Function', () => { }); afterEach(() => { + delete process.env.MCP_INIT_TIMEOUT_MS; jest.clearAllMocks(); }); @@ -179,15 +181,14 @@ describe('MCPServersRegistry - Initialize Function', () => { const registry = new MCPServersRegistry(rawConfigs); // Verify initial state - expect(registry.oauthServers).toBeNull(); - expect(registry.serverInstructions).toBeNull(); - expect(registry.toolFunctions).toBeNull(); - expect(registry.appServerConfigs).toBeNull(); + expect(registry.oauthServers.size).toBe(0); + expect(registry.serverInstructions).toEqual({}); + expect(registry.toolFunctions).toEqual({}); + expect(registry.appServerConfigs).toEqual({}); await registry.initialize(); // Test oauthServers Set - expect(registry.oauthServers).toBeInstanceOf(Set); expect(registry.oauthServers).toEqual( new Set(['oauth_server', 'oauth_predefined', 'oauth_startup_enabled']), ); @@ -228,18 +229,49 @@ describe('MCPServersRegistry - Initialize Function', () => { expect(registry.toolFunctions).toEqual(expectedToolFunctions); }); - it('should handle errors gracefully and continue initialization', async () => { + it('should handle errors gracefully and continue initialization of other servers', async () => { const registry = new MCPServersRegistry(rawConfigs); - // Make one server throw an error - mockDetectOAuthRequirement.mockRejectedValueOnce(new Error('OAuth detection failed')); + // Make one specific server throw an error during OAuth detection + mockDetectOAuthRequirement.mockImplementation((url: string) => { + if (url === 'https://api.github.com/mcp') { + return Promise.reject(new Error('OAuth detection failed')); + } + // Return normal responses for other servers + const oauthResults: Record = { + 'https://api.disabled.com/mcp': { + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }, + 'https://api.public.com/mcp': { + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }, + }; + return Promise.resolve( + oauthResults[url] ?? { + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }, + ); + }); await registry.initialize(); - // Should still initialize successfully + // Should still initialize successfully for other servers expect(registry.oauthServers).toBeInstanceOf(Set); expect(registry.toolFunctions).toBeDefined(); + // The failed server should not be in oauthServers (since it failed OAuth detection) + expect(registry.oauthServers.has('oauth_server')).toBe(false); + + // But other servers should still be processed successfully + expect(registry.appServerConfigs).toHaveProperty('stdio_server'); + expect(registry.appServerConfigs).toHaveProperty('non_oauth_server'); + // Error should be logged as a warning at the higher level expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('[MCP][oauth_server] Failed to initialize server:'), @@ -247,12 +279,15 @@ describe('MCPServersRegistry - Initialize Function', () => { ); }); - it('should disconnect all connections after initialization', async () => { + it('should disconnect individual connections after each server initialization', async () => { const registry = new MCPServersRegistry(rawConfigs); await registry.initialize(); - expect(mockConnectionsRepo.disconnectAll).toHaveBeenCalledTimes(1); + // Verify disconnect was called for each server during initialization + // All servers attempt to connect during initialization for metadata gathering + const serverNames = Object.keys(rawConfigs); + expect(mockConnectionsRepo.disconnect).toHaveBeenCalledTimes(serverNames.length); }); it('should log configuration updates for each startup-enabled server', async () => { @@ -357,5 +392,204 @@ describe('MCPServersRegistry - Initialize Function', () => { // Verify getInstructions was called for both "true" cases expect(mockClient.getInstructions).toHaveBeenCalledTimes(2); }); + + it('should use Promise.allSettled for individual server initialization', async () => { + const registry = new MCPServersRegistry(rawConfigs); + + // Spy on Promise.allSettled to verify it's being used + const allSettledSpy = jest.spyOn(Promise, 'allSettled'); + + await registry.initialize(); + + // Verify Promise.allSettled was called with an array of server initialization promises + expect(allSettledSpy).toHaveBeenCalledWith(expect.arrayContaining([expect.any(Promise)])); + + // Verify it was called with the correct number of server promises + const serverNames = Object.keys(rawConfigs); + expect(allSettledSpy).toHaveBeenCalledWith( + expect.arrayContaining(new Array(serverNames.length).fill(expect.any(Promise))), + ); + + allSettledSpy.mockRestore(); + }); + + it('should isolate server failures and not affect other servers', async () => { + const registry = new MCPServersRegistry(rawConfigs); + + // Make multiple servers fail in different ways + mockConnectionsRepo.get.mockImplementation((serverName: string) => { + if (serverName === 'stdio_server') { + // First server fails + throw new Error('Connection failed for stdio_server'); + } + if (serverName === 'websocket_server') { + // Second server fails + throw new Error('Connection failed for websocket_server'); + } + // Other servers succeed + const connection = mockConnections.get(serverName); + if (!connection) { + throw new Error(`Connection not found for server: ${serverName}`); + } + return Promise.resolve(connection); + }); + + await registry.initialize(); + + // Despite failures, initialization should complete + expect(registry.oauthServers).toBeInstanceOf(Set); + expect(registry.toolFunctions).toBeDefined(); + + // Successful servers should still be processed + expect(registry.appServerConfigs).toHaveProperty('non_oauth_server'); + + // Failed servers should not crash the whole initialization + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('[MCP][stdio_server] Failed to fetch server capabilities:'), + expect.any(Error), + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('[MCP][websocket_server] Failed to fetch server capabilities:'), + expect.any(Error), + ); + }); + + it('should properly clean up connections even when some servers fail', async () => { + const registry = new MCPServersRegistry(rawConfigs); + + // Track disconnect failures but suppress unhandled rejections + const disconnectErrors: Error[] = []; + mockConnectionsRepo.disconnect.mockImplementation((serverName: string) => { + if (serverName === 'stdio_server') { + const error = new Error('Disconnect failed'); + disconnectErrors.push(error); + return Promise.reject(error).catch(() => {}); // Suppress unhandled rejection + } + return Promise.resolve(); + }); + + await registry.initialize(); + + // Should still attempt to disconnect all servers during initialization + const serverNames = Object.keys(rawConfigs); + expect(mockConnectionsRepo.disconnect).toHaveBeenCalledTimes(serverNames.length); + expect(disconnectErrors).toHaveLength(1); + }); + + it('should timeout individual server initialization after configured timeout', async () => { + const timeout = 2000; + // Create registry with a short timeout for testing + process.env.MCP_INIT_TIMEOUT_MS = `${timeout}`; + + const registry = new MCPServersRegistry(rawConfigs); + + // Make one server hang indefinitely during OAuth detection + mockDetectOAuthRequirement.mockImplementation((url: string) => { + if (url === 'https://api.github.com/mcp') { + // Slow init + return new Promise((res) => setTimeout(res, timeout * 2)); + } + // Return normal responses for other servers + return Promise.resolve({ + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }); + }); + + const start = Date.now(); + await registry.initialize(); + const duration = Date.now() - start; + + // Should complete within reasonable time despite one server hanging + // Allow some buffer for test execution overhead + expect(duration).toBeLessThan(timeout * 1.5); + + // The timeout should prevent the hanging server from blocking initialization + // Other servers should still be processed successfully + expect(registry.appServerConfigs).toHaveProperty('stdio_server'); + expect(registry.appServerConfigs).toHaveProperty('non_oauth_server'); + }, 10_000); // 10 second Jest timeout + + it('should skip tool function fetching if connection was not established', async () => { + const testConfig: t.MCPServers = { + server_with_connection: { + type: 'stdio', + args: [], + command: 'test-command', + }, + server_without_connection: { + type: 'stdio', + args: [], + command: 'failing-command', + }, + }; + + const registry = new MCPServersRegistry(testConfig); + + const mockClient = { + listTools: jest.fn().mockResolvedValue({ + tools: [ + { + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + }, + ], + }), + getInstructions: jest.fn().mockReturnValue(undefined), + getServerCapabilities: jest.fn().mockReturnValue({ tools: {} }), + }; + const mockConnection = { + client: mockClient, + } as unknown as jest.Mocked; + + mockConnectionsRepo.get.mockImplementation((serverName: string) => { + if (serverName === 'server_with_connection') { + return Promise.resolve(mockConnection); + } + throw new Error('Connection failed'); + }); + + // Mock getLoaded to return connections map - the real implementation returns all loaded connections at once + mockConnectionsRepo.getLoaded.mockResolvedValue( + new Map([['server_with_connection', mockConnection]]), + ); + + mockDetectOAuthRequirement.mockResolvedValue({ + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }); + + await registry.initialize(); + + expect(registry.toolFunctions).toHaveProperty('test_tool_mcp_server_with_connection'); + expect(Object.keys(registry.toolFunctions)).toHaveLength(1); + }); + + it('should handle getLoaded returning empty map gracefully', async () => { + const testConfig: t.MCPServers = { + test_server: { + type: 'stdio', + args: [], + command: 'test-command', + }, + }; + + const registry = new MCPServersRegistry(testConfig); + + mockConnectionsRepo.get.mockRejectedValue(new Error('All connections failed')); + mockConnectionsRepo.getLoaded.mockResolvedValue(new Map()); + mockDetectOAuthRequirement.mockResolvedValue({ + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }); + + await registry.initialize(); + + expect(registry.toolFunctions).toEqual({}); + }); }); }); diff --git a/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts b/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts index 9e84ef1483..09abb2b048 100644 --- a/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts +++ b/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts @@ -72,7 +72,7 @@ export class OAuthReconnectionManager { // 1. derive the servers to reconnect const serversToReconnect = []; - for (const serverName of this.mcpManager.getOAuthServers() ?? []) { + for (const serverName of this.mcpManager.getOAuthServers()) { const canReconnect = await this.canReconnect(userId, serverName); if (canReconnect) { serversToReconnect.push(serverName); From 1b8a0bfaee24a22409cf51ad1d1321843f0b982a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 4 Oct 2025 01:53:37 -0400 Subject: [PATCH 005/272] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20chore:=20Resolve?= =?UTF-8?q?=20Build=20Warning,=20Package=20Cleanup,=20Robust=20Temp=20Chat?= =?UTF-8?q?=20Time=20(#9962)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ⚙️ chore: Resolve Build Warning and `keyvMongo` types * 🔄 chore: Update mongodb version to ^6.14.2 in package.json and package-lock.json * chore: remove @langchain/openai dep * 🔄 refactor: Change log level from warn to debug for missing endpoint config * 🔄 refactor: Improve temp chat expiration date calculation in tests and implementation --- api/app/clients/OpenAIClient.js | 62 +------ api/app/clients/llm/createLLM.js | 81 --------- api/app/clients/llm/index.js | 2 - api/app/clients/memory/summaryBuffer.demo.js | 31 ---- .../clients/tools/util/handleTools.test.js | 4 - api/package.json | 1 - api/server/controllers/agents/client.js | 4 +- package-lock.json | 162 ++++++++++++------ packages/api/package.json | 3 +- packages/api/src/cache/keyvMongo.ts | 7 +- .../api/src/utils/tempChatRetention.spec.ts | 24 +-- packages/api/src/utils/tempChatRetention.ts | 4 +- 12 files changed, 131 insertions(+), 254 deletions(-) delete mode 100644 api/app/clients/llm/createLLM.js delete mode 100644 api/app/clients/memory/summaryBuffer.demo.js diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 17c4891dcd..1194474674 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -43,7 +43,6 @@ const { runTitleChain } = require('./chains'); const { extractBaseURL } = require('~/utils'); const { tokenSplit } = require('./document'); const BaseClient = require('./BaseClient'); -const { createLLM } = require('./llm'); class OpenAIClient extends BaseClient { constructor(apiKey, options = {}) { @@ -614,65 +613,8 @@ class OpenAIClient extends BaseClient { return (reply ?? '').trim(); } - initializeLLM({ - model = openAISettings.model.default, - modelName, - temperature = 0.2, - max_tokens, - streaming, - }) { - const modelOptions = { - modelName: modelName ?? model, - temperature, - user: this.user, - }; - - if (max_tokens) { - modelOptions.max_tokens = max_tokens; - } - - const configOptions = {}; - - if (this.langchainProxy) { - configOptions.basePath = this.langchainProxy; - } - - if (this.useOpenRouter) { - configOptions.basePath = 'https://openrouter.ai/api/v1'; - configOptions.baseOptions = { - headers: { - 'HTTP-Referer': 'https://librechat.ai', - 'X-Title': 'LibreChat', - }, - }; - } - - const { headers } = this.options; - if (headers && typeof headers === 'object' && !Array.isArray(headers)) { - configOptions.baseOptions = { - headers: resolveHeaders({ - headers: { - ...headers, - ...configOptions?.baseOptions?.headers, - }, - }), - }; - } - - if (this.options.proxy) { - configOptions.httpAgent = new HttpsProxyAgent(this.options.proxy); - configOptions.httpsAgent = new HttpsProxyAgent(this.options.proxy); - } - - const llm = createLLM({ - modelOptions, - configOptions, - openAIApiKey: this.apiKey, - azure: this.azure, - streaming, - }); - - return llm; + initializeLLM() { + throw new Error('Deprecated'); } /** diff --git a/api/app/clients/llm/createLLM.js b/api/app/clients/llm/createLLM.js deleted file mode 100644 index 846c4d8e9c..0000000000 --- a/api/app/clients/llm/createLLM.js +++ /dev/null @@ -1,81 +0,0 @@ -const { ChatOpenAI } = require('@langchain/openai'); -const { isEnabled, sanitizeModelName, constructAzureURL } = require('@librechat/api'); - -/** - * Creates a new instance of a language model (LLM) for chat interactions. - * - * @param {Object} options - The options for creating the LLM. - * @param {ModelOptions} options.modelOptions - The options specific to the model, including modelName, temperature, presence_penalty, frequency_penalty, and other model-related settings. - * @param {ConfigOptions} options.configOptions - Configuration options for the API requests, including proxy settings and custom headers. - * @param {Callbacks} [options.callbacks] - Callback functions for managing the lifecycle of the LLM, including token buffers, context, and initial message count. - * @param {boolean} [options.streaming=false] - Determines if the LLM should operate in streaming mode. - * @param {string} options.openAIApiKey - The API key for OpenAI, used for authentication. - * @param {AzureOptions} [options.azure={}] - Optional Azure-specific configurations. If provided, Azure configurations take precedence over OpenAI configurations. - * - * @returns {ChatOpenAI} An instance of the ChatOpenAI class, configured with the provided options. - * - * @example - * const llm = createLLM({ - * modelOptions: { modelName: 'gpt-4o-mini', temperature: 0.2 }, - * configOptions: { basePath: 'https://example.api/path' }, - * callbacks: { onMessage: handleMessage }, - * openAIApiKey: 'your-api-key' - * }); - */ -function createLLM({ - modelOptions, - configOptions, - callbacks, - streaming = false, - openAIApiKey, - azure = {}, -}) { - let credentials = { openAIApiKey }; - let configuration = { - apiKey: openAIApiKey, - ...(configOptions.basePath && { baseURL: configOptions.basePath }), - }; - - /** @type {AzureOptions} */ - let azureOptions = {}; - if (azure) { - const useModelName = isEnabled(process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME); - - credentials = {}; - configuration = {}; - azureOptions = azure; - - azureOptions.azureOpenAIApiDeploymentName = useModelName - ? sanitizeModelName(modelOptions.modelName) - : azureOptions.azureOpenAIApiDeploymentName; - } - - if (azure && process.env.AZURE_OPENAI_DEFAULT_MODEL) { - modelOptions.modelName = process.env.AZURE_OPENAI_DEFAULT_MODEL; - } - - if (azure && configOptions.basePath) { - const azureURL = constructAzureURL({ - baseURL: configOptions.basePath, - azureOptions, - }); - azureOptions.azureOpenAIBasePath = azureURL.split( - `/${azureOptions.azureOpenAIApiDeploymentName}`, - )[0]; - } - - return new ChatOpenAI( - { - streaming, - credentials, - configuration, - ...azureOptions, - ...modelOptions, - ...credentials, - callbacks, - }, - configOptions, - ); -} - -module.exports = createLLM; diff --git a/api/app/clients/llm/index.js b/api/app/clients/llm/index.js index d03e1cda4d..c7770ce103 100644 --- a/api/app/clients/llm/index.js +++ b/api/app/clients/llm/index.js @@ -1,7 +1,5 @@ -const createLLM = require('./createLLM'); const createCoherePayload = require('./createCoherePayload'); module.exports = { - createLLM, createCoherePayload, }; diff --git a/api/app/clients/memory/summaryBuffer.demo.js b/api/app/clients/memory/summaryBuffer.demo.js deleted file mode 100644 index fc575c3032..0000000000 --- a/api/app/clients/memory/summaryBuffer.demo.js +++ /dev/null @@ -1,31 +0,0 @@ -require('dotenv').config(); -const { ChatOpenAI } = require('@langchain/openai'); -const { getBufferString, ConversationSummaryBufferMemory } = require('langchain/memory'); - -const chatPromptMemory = new ConversationSummaryBufferMemory({ - llm: new ChatOpenAI({ modelName: 'gpt-4o-mini', temperature: 0 }), - maxTokenLimit: 10, - returnMessages: true, -}); - -(async () => { - await chatPromptMemory.saveContext({ input: 'hi my name\'s Danny' }, { output: 'whats up' }); - await chatPromptMemory.saveContext({ input: 'not much you' }, { output: 'not much' }); - await chatPromptMemory.saveContext( - { input: 'are you excited for the olympics?' }, - { output: 'not really' }, - ); - - // We can also utilize the predict_new_summary method directly. - const messages = await chatPromptMemory.chatHistory.getMessages(); - console.log('MESSAGES\n\n'); - console.log(JSON.stringify(messages)); - const previous_summary = ''; - const predictSummary = await chatPromptMemory.predictNewSummary(messages, previous_summary); - console.log('SUMMARY\n\n'); - console.log(JSON.stringify(getBufferString([{ role: 'system', content: predictSummary }]))); - - // const { history } = await chatPromptMemory.loadMemoryVariables({}); - // console.log('HISTORY\n\n'); - // console.log(JSON.stringify(history)); -})(); diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js index b01aa2f7ef..b25372653c 100644 --- a/api/app/clients/tools/util/handleTools.test.js +++ b/api/app/clients/tools/util/handleTools.test.js @@ -30,7 +30,6 @@ jest.mock('~/server/services/Config', () => ({ }), })); -const { BaseLLM } = require('@langchain/openai'); const { Calculator } = require('@langchain/community/tools/calculator'); const { User } = require('~/db/models'); @@ -172,7 +171,6 @@ describe('Tool Handlers', () => { beforeAll(async () => { const toolMap = await loadTools({ user: fakeUser._id, - model: BaseLLM, tools: sampleTools, returnMap: true, useSpecs: true, @@ -266,7 +264,6 @@ describe('Tool Handlers', () => { it('returns an empty object when no tools are requested', async () => { toolFunctions = await loadTools({ user: fakeUser._id, - model: BaseLLM, returnMap: true, useSpecs: true, }); @@ -276,7 +273,6 @@ describe('Tool Handlers', () => { process.env.SD_WEBUI_URL = mockCredential; toolFunctions = await loadTools({ user: fakeUser._id, - model: BaseLLM, tools: ['stable-diffusion'], functions: true, returnMap: true, diff --git a/api/package.json b/api/package.json index 782d958102..28d29af8da 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,6 @@ "@langchain/core": "^0.3.62", "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", - "@langchain/openai": "^0.5.18", "@langchain/textsplitters": "^0.1.0", "@librechat/agents": "^2.4.82", "@librechat/api": "*", diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 5825257257..bf32385162 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1116,8 +1116,8 @@ class AgentClient extends BaseClient { appConfig.endpoints?.[endpoint] ?? titleProviderConfig.customEndpointConfig; if (!endpointConfig) { - logger.warn( - '[api/server/controllers/agents/client.js #titleConvo] Error getting endpoint config', + logger.debug( + `[api/server/controllers/agents/client.js #titleConvo] No endpoint config for "${endpoint}"`, ); } diff --git a/package-lock.json b/package-lock.json index 153120b9ad..04cb0e9aaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,6 @@ "@langchain/core": "^0.3.62", "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", - "@langchain/openai": "^0.5.18", "@langchain/textsplitters": "^0.1.0", "@librechat/agents": "^2.4.82", "@librechat/api": "*", @@ -2364,6 +2363,54 @@ "mkdirp": "bin/cmd.js" } }, + "api/node_modules/mongodb": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, "api/node_modules/multer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", @@ -21975,6 +22022,54 @@ "node": ">= 14" } }, + "node_modules/@librechat/agents/node_modules/mongodb": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, "node_modules/@librechat/agents/node_modules/openai": { "version": "5.11.0", "resolved": "https://registry.npmjs.org/openai/-/openai-5.11.0.tgz", @@ -22623,9 +22718,10 @@ } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", - "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.1.tgz", + "integrity": "sha512-6nZrq5kfAz0POWyhljnbWQQJQ5uT8oE2ddX303q1uY0tWsivWKgBDXBBvuFPwOqRRalXJuVO9EjOdVtuhLX0zg==", + "license": "MIT", "dependencies": { "sparse-bitfield": "^3.0.3" } @@ -41347,14 +41443,13 @@ } }, "node_modules/mongodb": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", - "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", - "devOptional": true, + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.14.2.tgz", + "integrity": "sha512-kMEHNo0F3P6QKDq17zcDuPeaywK/YaJVCEQRzPF3TOM/Bl9MFg64YE5Tu7ifj37qZJMhwU1tl2Ioivws5gRG5Q==", "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.1.9", - "bson": "^6.10.4", + "bson": "^6.10.3", "mongodb-connection-string-url": "^3.0.0" }, "engines": { @@ -41510,52 +41605,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongoose/node_modules/mongodb": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.14.2.tgz", - "integrity": "sha512-kMEHNo0F3P6QKDq17zcDuPeaywK/YaJVCEQRzPF3TOM/Bl9MFg64YE5Tu7ifj37qZJMhwU1tl2Ioivws5gRG5Q==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.1.9", - "bson": "^6.10.3", - "mongodb-connection-string-url": "^3.0.0" - }, - "engines": { - "node": ">=16.20.1" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.2.2", - "socks": "^2.7.1" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, "node_modules/moo-color": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", @@ -51235,7 +51284,7 @@ "keyv-file": "^5.1.2", "librechat-data-provider": "*", "memorystore": "^1.6.7", - "mongoose": "^8.12.1", + "mongodb": "^6.14.2", "rate-limit-redis": "^4.2.0", "rimraf": "^5.0.1", "rollup": "^4.22.4", @@ -51263,6 +51312,7 @@ "keyv-file": "^5.1.2", "librechat-data-provider": "*", "memorystore": "^1.6.7", + "mongoose": "^8.12.1", "node-fetch": "2.7.0", "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", diff --git a/packages/api/package.json b/packages/api/package.json index da11212c96..44f5ce9b22 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -69,7 +69,7 @@ "keyv-file": "^5.1.2", "librechat-data-provider": "*", "memorystore": "^1.6.7", - "mongoose": "^8.12.1", + "mongodb": "^6.14.2", "rate-limit-redis": "^4.2.0", "rimraf": "^5.0.1", "rollup": "^4.22.4", @@ -100,6 +100,7 @@ "keyv-file": "^5.1.2", "librechat-data-provider": "*", "memorystore": "^1.6.7", + "mongoose": "^8.12.1", "node-fetch": "2.7.0", "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", diff --git a/packages/api/src/cache/keyvMongo.ts b/packages/api/src/cache/keyvMongo.ts index 68c7262c6f..4c0a08bf63 100644 --- a/packages/api/src/cache/keyvMongo.ts +++ b/packages/api/src/cache/keyvMongo.ts @@ -1,7 +1,8 @@ import mongoose from 'mongoose'; import { EventEmitter } from 'events'; +import { GridFSBucket } from 'mongodb'; import { logger } from '@librechat/data-schemas'; -import { GridFSBucket, type Db, type ReadPreference, type Collection } from 'mongodb'; +import type { Db, ReadPreference, Collection } from 'mongodb'; interface KeyvMongoOptions { url?: string; @@ -103,7 +104,7 @@ class KeyvMongoCustom extends EventEmitter { const stream = client.bucket.openDownloadStreamByName(key); return new Promise((resolve) => { - const resp: Buffer[] = []; + const resp: Uint8Array[] = []; stream.on('error', () => { resolve(undefined); }); @@ -113,7 +114,7 @@ class KeyvMongoCustom extends EventEmitter { resolve(data); }); - stream.on('data', (chunk: Buffer) => { + stream.on('data', (chunk: Uint8Array) => { resp.push(chunk); }); }); diff --git a/packages/api/src/utils/tempChatRetention.spec.ts b/packages/api/src/utils/tempChatRetention.spec.ts index 8e924183c5..847088ab7c 100644 --- a/packages/api/src/utils/tempChatRetention.spec.ts +++ b/packages/api/src/utils/tempChatRetention.spec.ts @@ -92,14 +92,16 @@ describe('tempChatRetention', () => { describe('createTempChatExpirationDate', () => { it('should create expiration date with default retention period', () => { + const beforeCall = Date.now(); const result = createTempChatExpirationDate(); + const afterCall = Date.now(); - const expectedDate = new Date(); - expectedDate.setHours(expectedDate.getHours() + DEFAULT_RETENTION_HOURS); + const expectedMin = beforeCall + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000; + const expectedMax = afterCall + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000; - // Allow for small time differences in test execution - const timeDiff = Math.abs(result.getTime() - expectedDate.getTime()); - expect(timeDiff).toBeLessThan(1000); // Less than 1 second difference + // Result should be between expectedMin and expectedMax + expect(result.getTime()).toBeGreaterThanOrEqual(expectedMin); + expect(result.getTime()).toBeLessThanOrEqual(expectedMax); }); it('should create expiration date with custom retention period', () => { @@ -109,14 +111,16 @@ describe('tempChatRetention', () => { }, }; + const beforeCall = Date.now(); const result = createTempChatExpirationDate(config?.interfaceConfig); + const afterCall = Date.now(); - const expectedDate = new Date(); - expectedDate.setHours(expectedDate.getHours() + 12); + const expectedMin = beforeCall + 12 * 60 * 60 * 1000; + const expectedMax = afterCall + 12 * 60 * 60 * 1000; - // Allow for small time differences in test execution - const timeDiff = Math.abs(result.getTime() - expectedDate.getTime()); - expect(timeDiff).toBeLessThan(1000); // Less than 1 second difference + // Result should be between expectedMin and expectedMax + expect(result.getTime()).toBeGreaterThanOrEqual(expectedMin); + expect(result.getTime()).toBeLessThanOrEqual(expectedMax); }); it('should return a Date object', () => { diff --git a/packages/api/src/utils/tempChatRetention.ts b/packages/api/src/utils/tempChatRetention.ts index 6b78681891..3505a51a1b 100644 --- a/packages/api/src/utils/tempChatRetention.ts +++ b/packages/api/src/utils/tempChatRetention.ts @@ -73,7 +73,5 @@ export function getTempChatRetentionHours( */ export function createTempChatExpirationDate(interfaceConfig?: AppConfig['interfaceConfig']): Date { const retentionHours = getTempChatRetentionHours(interfaceConfig); - const expiredAt = new Date(); - expiredAt.setHours(expiredAt.getHours() + retentionHours); - return expiredAt; + return new Date(Date.now() + retentionHours * 60 * 60 * 1000); } From 9ff608e6af79202a391aa9b30e6c68fe06cbb55f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 4 Oct 2025 16:43:22 -0400 Subject: [PATCH 006/272] =?UTF-8?q?=F0=9F=93=A6=20chore:=20fix=20`packages?= =?UTF-8?q?/api`=20peer=20dependencies=20(#9973)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/package.json | 7 ++ package-lock.json | 143 ++++++++++++++++++++++++-------------- packages/api/package.json | 7 -- 3 files changed, 97 insertions(+), 60 deletions(-) diff --git a/api/package.json b/api/package.json index 28d29af8da..98964f5cb0 100644 --- a/api/package.json +++ b/api/package.json @@ -42,6 +42,7 @@ "@azure/storage-blob": "^12.27.0", "@google/generative-ai": "^0.24.0", "@googleapis/youtube": "^20.0.0", + "@keyv/redis": "^4.3.3", "@langchain/community": "^0.3.47", "@langchain/core": "^0.3.62", "@langchain/google-genai": "^0.2.13", @@ -57,6 +58,7 @@ "axios": "^1.12.1", "bcryptjs": "^2.4.3", "compression": "^1.8.1", + "connect-redis": "^8.1.0", "cookie": "^0.7.2", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -74,13 +76,17 @@ "googleapis": "^126.0.1", "handlebars": "^4.7.7", "https-proxy-agent": "^7.0.6", + "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.2.0", + "keyv": "^5.3.2", + "keyv-file": "^5.1.2", "klona": "^2.0.6", "librechat-data-provider": "*", "lodash": "^4.17.21", "meilisearch": "^0.38.0", + "memorystore": "^1.6.7", "mime": "^3.0.0", "module-alias": "^2.2.3", "mongoose": "^8.12.1", @@ -100,6 +106,7 @@ "passport-jwt": "^4.0.1", "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", + "rate-limit-redis": "^4.2.0", "sharp": "^0.33.5", "tiktoken": "^1.0.15", "traverse": "^0.6.7", diff --git a/package-lock.json b/package-lock.json index 04cb0e9aaf..4a70593821 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "@azure/storage-blob": "^12.27.0", "@google/generative-ai": "^0.24.0", "@googleapis/youtube": "^20.0.0", + "@keyv/redis": "^4.3.3", "@langchain/community": "^0.3.47", "@langchain/core": "^0.3.62", "@langchain/google-genai": "^0.2.13", @@ -73,6 +74,7 @@ "axios": "^1.12.1", "bcryptjs": "^2.4.3", "compression": "^1.8.1", + "connect-redis": "^8.1.0", "cookie": "^0.7.2", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -90,13 +92,17 @@ "googleapis": "^126.0.1", "handlebars": "^4.7.7", "https-proxy-agent": "^7.0.6", + "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.2.0", + "keyv": "^5.3.2", + "keyv-file": "^5.1.2", "klona": "^2.0.6", "librechat-data-provider": "*", "lodash": "^4.17.21", "meilisearch": "^0.38.0", + "memorystore": "^1.6.7", "mime": "^3.0.0", "module-alias": "^2.2.3", "mongoose": "^8.12.1", @@ -116,6 +122,7 @@ "passport-jwt": "^4.0.1", "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", + "rate-limit-redis": "^4.2.0", "sharp": "^0.33.5", "tiktoken": "^1.0.15", "traverse": "^0.6.7", @@ -2232,6 +2239,17 @@ "fxparser": "src/cli/cli.js" } }, + "api/node_modules/fs-extra": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", + "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, "api/node_modules/gaxios": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.0.tgz", @@ -2351,6 +2369,41 @@ "url": "https://github.com/sponsors/panva" } }, + "api/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "api/node_modules/keyv": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.3.2.tgz", + "integrity": "sha512-Lji2XRxqqa5Wg+CHLVfFKBImfJZ4pCSccu9eVWK6w4c2SDFLd8JAn1zqTuSFnsxb7ope6rMsnIHfp+eBbRBRZQ==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.0.3" + } + }, + "api/node_modules/keyv-file": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.1.2.tgz", + "integrity": "sha512-Sx5W55HeSbmsX4BfanJwaJZd3xePoQKxfuysvIhZ3JTPoSeZjApFO1QnuXGVy9hDXpmztS5mm39wlBFOUalVgw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.0.1", + "fs-extra": "^4.0.1", + "tslib": "^1.14.1" + } + }, + "api/node_modules/keyv-file/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "api/node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -2533,6 +2586,15 @@ "optional": true, "peer": true }, + "api/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "api/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -19422,8 +19484,7 @@ "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", - "devOptional": true + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -19989,11 +20050,24 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@keyv/redis": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-4.3.3.tgz", + "integrity": "sha512-J/uhvKu/Qfh11yMUs+9KdcGCLmWFd3vMxtDVQh2j9cOcnrpnM5jE1xU+K1/kI89czSVEdeMyqTC9gGNtwi3JEQ==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "^1.1.2", + "keyv": "^5.3.2", + "redis": "^4.7.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@keyv/serialize": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.3.tgz", "integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==", - "dev": true, "dependencies": { "buffer": "^6.0.3" } @@ -26489,8 +26563,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", - "optional": true, - "peer": true, "peerDependencies": { "@redis/client": "^1.0.0" } @@ -26499,7 +26571,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", - "devOptional": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -26512,15 +26583,12 @@ "node_modules/@redis/client/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "devOptional": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/@redis/graph": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", - "optional": true, - "peer": true, "peerDependencies": { "@redis/client": "^1.0.0" } @@ -26529,8 +26597,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", - "optional": true, - "peer": true, "peerDependencies": { "@redis/client": "^1.0.0" } @@ -26539,8 +26605,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", - "optional": true, - "peer": true, "peerDependencies": { "@redis/client": "^1.0.0" } @@ -26549,8 +26613,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", - "optional": true, - "peer": true, "peerDependencies": { "@redis/client": "^1.0.0" } @@ -31826,7 +31888,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -32119,7 +32180,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-8.1.0.tgz", "integrity": "sha512-Km0EYLDlmExF52UCss5gLGTtrukGC57G6WCC2aqEMft5Vr4xNWuM4tL+T97kWrw+vp40SXFteb6Xk/7MxgpwdA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -33032,7 +33092,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "devOptional": true, "engines": { "node": ">=0.10" } @@ -35592,7 +35651,6 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", - "devOptional": true, "engines": { "node": ">= 4" } @@ -35929,8 +35987,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -36391,8 +36448,8 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.1.tgz", "integrity": "sha512-xnKGl+iMIlhrZmGHB729MqlmPoWBznctSQTYCpFKqNsCgimJQmithcW0xSQMMFzYnV2iKUh25alswn6epgxS0Q==", - "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/htm": { "version": "3.1.1", @@ -36914,7 +36971,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", - "devOptional": true, + "license": "MIT", "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", @@ -38817,7 +38874,6 @@ "version": "5.5.2", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.2.tgz", "integrity": "sha512-TXcFHbmm/z7MGd1u9ASiCSfTS+ei6Z8B3a5JHzx3oPa/o7QzWVtPRpc4KGER5RR469IC+/nfg4U5YLIuDUua2g==", - "dev": true, "license": "MIT", "dependencies": { "@keyv/serialize": "^1.1.1" @@ -38827,8 +38883,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.2.0.tgz", "integrity": "sha512-5JEBqQiDzjGCQHtf7KLReJdHKchaJyUZW+9TvBu+4dc+uuTqUG9KcdA3ICMXlwky3qjKc0ecNCNefbgjyDtlAg==", - "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.0.1", "tslib": "^1.14.1" @@ -38838,14 +38894,13 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/keyv/node_modules/@keyv/serialize": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, "license": "MIT" }, "node_modules/kleur": { @@ -39486,8 +39541,7 @@ "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "devOptional": true + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" }, "node_modules/lodash.includes": { "version": "4.3.0", @@ -39497,8 +39551,7 @@ "node_modules/lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "devOptional": true + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" }, "node_modules/lodash.isboolean": { "version": "3.0.3", @@ -40634,7 +40687,6 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/memorystore/-/memorystore-1.6.7.tgz", "integrity": "sha512-OZnmNY/NDrKohPQ+hxp0muBcBKrzKNtHr55DbqSx9hLsYVNnomSAMRAtI7R64t3gf3ID7tHQA7mG4oL3Hu9hdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.0", @@ -40648,7 +40700,6 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "dev": true, "license": "ISC", "dependencies": { "pseudomap": "^1.0.2", @@ -40659,7 +40710,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "dev": true, "license": "ISC" }, "node_modules/merge-descriptors": { @@ -45000,7 +45050,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "dev": true, "license": "ISC" }, "node_modules/psl": { @@ -45155,7 +45204,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.2.0.tgz", "integrity": "sha512-wV450NQyKC24NmPosJb2131RoczLdfIJdKCReNwtVpm5998U8SgKrAZrIHaN/NfQgqOHaan8Uq++B4sa5REwjA==", - "dev": true, + "license": "MIT", "engines": { "node": ">= 16" }, @@ -45784,8 +45833,6 @@ "version": "4.7.0", "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", - "optional": true, - "peer": true, "workspaces": [ "./packages/*" ], @@ -45802,7 +45849,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "devOptional": true, "engines": { "node": ">=4" } @@ -45811,7 +45857,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "devOptional": true, "dependencies": { "redis-errors": "^1.0.0" }, @@ -47638,8 +47683,7 @@ "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "devOptional": true + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" }, "node_modules/static-browser-server": { "version": "1.0.3", @@ -51259,7 +51303,6 @@ "@babel/preset-env": "^7.21.5", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.0", - "@keyv/redis": "^4.3.3", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^25.0.2", "@rollup/plugin-json": "^6.1.0", @@ -51276,16 +51319,10 @@ "@types/node": "^20.3.0", "@types/react": "^18.2.18", "@types/winston": "^2.4.4", - "connect-redis": "^8.1.0", - "ioredis": "^5.3.2", "jest": "^29.5.0", "jest-junit": "^16.0.0", - "keyv": "^5.3.2", - "keyv-file": "^5.1.2", "librechat-data-provider": "*", - "memorystore": "^1.6.7", "mongodb": "^6.14.2", - "rate-limit-redis": "^4.2.0", "rimraf": "^5.0.1", "rollup": "^4.22.4", "rollup-plugin-peer-deps-external": "^2.2.4", @@ -51324,8 +51361,8 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-4.6.0.tgz", "integrity": "sha512-FP3FP42RiQ3j0UC6f4Maf7ISTLAIivm37/SdfG5xvhqceMMq3kabtC6T4a2h5byMnh4S8PjP51DY/9CpyrcfsQ==", - "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@redis/client": "^1.6.0", "cluster-key-slot": "^1.1.2", diff --git a/packages/api/package.json b/packages/api/package.json index 44f5ce9b22..e32fa384e6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -44,7 +44,6 @@ "@babel/preset-env": "^7.21.5", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.0", - "@keyv/redis": "^4.3.3", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^25.0.2", "@rollup/plugin-json": "^6.1.0", @@ -61,16 +60,10 @@ "@types/node": "^20.3.0", "@types/react": "^18.2.18", "@types/winston": "^2.4.4", - "connect-redis": "^8.1.0", - "ioredis": "^5.3.2", "jest": "^29.5.0", "jest-junit": "^16.0.0", - "keyv": "^5.3.2", - "keyv-file": "^5.1.2", "librechat-data-provider": "*", - "memorystore": "^1.6.7", "mongodb": "^6.14.2", - "rate-limit-redis": "^4.2.0", "rimraf": "^5.0.1", "rollup": "^4.22.4", "rollup-plugin-peer-deps-external": "^2.2.4", From 838fb53208057adf4660b1e575df0a41a85d7795 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 5 Oct 2025 06:37:57 -0400 Subject: [PATCH 007/272] =?UTF-8?q?=F0=9F=94=83=20refactor:=20Decouple=20E?= =?UTF-8?q?ffects=20from=20AppService,=20move=20to=20`data-schemas`=20(#99?= =?UTF-8?q?74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: linting for `loadCustomConfig` * refactor: decouple CDN init and variable/health checks from AppService * refactor: move AppService to packages/data-schemas * chore: update AppConfig import path to use data-schemas * chore: update JsonSchemaType import path to use data-schemas * refactor: update UserController to import webSearchKeys and redefine FunctionTool typedef * chore: remove AppService.js * refactor: update AppConfig interface to use Partial and make paths and fileStrategies optional * refactor: update checkConfig function to accept Partial * chore: fix types * refactor: move handleRateLimits to startup checks as is an effect * test: remove outdated rate limit tests from AppService.spec and add new handleRateLimits tests in checks.spec --- api/server/controllers/UserController.js | 3 +- api/server/index.js | 11 +- .../services/AppService.interface.spec.js | 198 ------ api/server/services/Config/app.js | 20 +- .../services/Config/handleRateLimits.js | 48 -- .../services/Config/loadCustomConfig.js | 3 - api/server/services/Files/Azure/crud.js | 10 +- api/server/services/Files/Azure/index.js | 2 - api/server/services/Files/Firebase/crud.js | 2 +- api/server/services/Files/Firebase/index.js | 2 - .../services/Files/Firebase/initialize.js | 39 -- api/server/services/Files/S3/crud.js | 4 +- api/server/services/Files/S3/index.js | 2 - api/server/services/start/modelSpecs.js | 75 -- api/server/services/start/turnstile.js | 44 -- api/typedefs.js | 11 +- package-lock.json | 5 + packages/api/package.json | 5 + packages/api/src/agents/index.ts | 1 - packages/api/src/agents/resources.test.ts | 3 +- packages/api/src/agents/resources.ts | 3 +- .../api/src/app/AppService.interface.spec.ts | 157 +++++ .../api/src/app/AppService.spec.ts | 638 ++++++------------ packages/api/src/app/cdn.ts | 26 + .../api/src/app/checks.spec.ts | 164 ++++- .../api/src/app/checks.ts | 202 ++++-- packages/api/src/app/config.test.ts | 2 +- packages/api/src/app/config.ts | 8 +- packages/api/src/app/index.ts | 3 +- packages/api/src/app/limits.ts | 55 ++ packages/api/src/app/permissions.spec.ts | 4 +- packages/api/src/app/permissions.ts | 3 +- .../api/src/cdn/azure.ts | 29 +- packages/api/src/cdn/firebase.ts | 42 ++ packages/api/src/cdn/index.ts | 3 + .../api/src/cdn/s3.ts | 12 +- packages/api/src/index.ts | 1 + packages/api/src/mcp/MCPServersRegistry.ts | 2 +- packages/api/src/mcp/__tests__/zod.spec.ts | 2 +- packages/api/src/mcp/types/index.ts | 3 +- packages/api/src/mcp/zod.ts | 2 +- packages/api/src/middleware/balance.ts | 4 +- packages/api/src/types/http.ts | 3 +- packages/api/src/types/index.ts | 3 - packages/api/src/types/openai.ts | 2 +- packages/api/src/types/tools.ts | 10 - packages/api/src/types/zod.ts | 15 - .../api/src/utils/tempChatRetention.spec.ts | 2 +- packages/api/src/utils/tempChatRetention.ts | 2 +- packages/api/src/web/web.spec.ts | 5 +- packages/api/src/web/web.ts | 110 +-- packages/data-provider/src/azure.ts | 62 +- packages/data-provider/src/config.ts | 30 +- .../src/app/agents.ts} | 4 +- .../data-schemas/src/app/assistants.ts | 37 +- .../data-schemas/src/app/azure.ts | 36 +- .../data-schemas/src/app/endpoints.ts | 33 +- packages/data-schemas/src/app/index.ts | 6 + .../src/app/interface.ts | 51 +- packages/data-schemas/src/app/memory.ts | 28 + packages/data-schemas/src/app/ocr.ts | 15 + .../data-schemas/src/app/service.ts | 92 ++- packages/data-schemas/src/app/specs.ts | 94 +++ packages/data-schemas/src/app/turnstile.ts | 45 ++ packages/data-schemas/src/app/web.ts | 84 +++ packages/data-schemas/src/index.ts | 1 + .../data-schemas/src/models/pluginAuth.ts | 3 +- packages/data-schemas/src/models/prompt.ts | 3 +- .../data-schemas/src/models/promptGroup.ts | 3 +- .../src/types/app.ts} | 54 +- packages/data-schemas/src/types/index.ts | 3 + packages/data-schemas/src/types/role.ts | 4 +- packages/data-schemas/src/types/web.ts | 16 + 73 files changed, 1383 insertions(+), 1326 deletions(-) delete mode 100644 api/server/services/AppService.interface.spec.js delete mode 100644 api/server/services/Config/handleRateLimits.js delete mode 100644 api/server/services/Files/Firebase/initialize.js delete mode 100644 api/server/services/start/modelSpecs.js delete mode 100644 api/server/services/start/turnstile.js create mode 100644 packages/api/src/app/AppService.interface.spec.ts rename api/server/services/AppService.spec.js => packages/api/src/app/AppService.spec.ts (58%) create mode 100644 packages/api/src/app/cdn.ts rename api/server/services/start/checks.spec.js => packages/api/src/app/checks.spec.ts (52%) rename api/server/services/start/checks.js => packages/api/src/app/checks.ts (53%) create mode 100644 packages/api/src/app/limits.ts rename api/server/services/Files/Azure/initialize.js => packages/api/src/cdn/azure.ts (63%) create mode 100644 packages/api/src/cdn/firebase.ts create mode 100644 packages/api/src/cdn/index.ts rename api/server/services/Files/S3/initialize.js => packages/api/src/cdn/s3.ts (82%) delete mode 100644 packages/api/src/types/tools.ts delete mode 100644 packages/api/src/types/zod.ts rename packages/{api/src/agents/config.ts => data-schemas/src/app/agents.ts} (91%) rename api/server/services/start/assistants.js => packages/data-schemas/src/app/assistants.ts (64%) rename api/server/services/start/azureOpenAI.js => packages/data-schemas/src/app/azure.ts (65%) rename api/server/services/start/endpoints.js => packages/data-schemas/src/app/endpoints.ts (63%) create mode 100644 packages/data-schemas/src/app/index.ts rename packages/{api => data-schemas}/src/app/interface.ts (55%) create mode 100644 packages/data-schemas/src/app/memory.ts create mode 100644 packages/data-schemas/src/app/ocr.ts rename api/server/services/AppService.js => packages/data-schemas/src/app/service.ts (52%) create mode 100644 packages/data-schemas/src/app/specs.ts create mode 100644 packages/data-schemas/src/app/turnstile.ts create mode 100644 packages/data-schemas/src/app/web.ts rename packages/{api/src/types/config.ts => data-schemas/src/types/app.ts} (69%) create mode 100644 packages/data-schemas/src/types/web.ts diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index c7051f4608..dc38c59721 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -1,7 +1,6 @@ -const { logger } = require('@librechat/data-schemas'); +const { logger, webSearchKeys } = require('@librechat/data-schemas'); const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-provider'); const { - webSearchKeys, MCPOAuthHandler, MCPTokenStorage, normalizeHttpError, diff --git a/api/server/index.js b/api/server/index.js index e458b0349e..c084267ad1 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -10,7 +10,12 @@ const compression = require('compression'); const cookieParser = require('cookie-parser'); const { logger } = require('@librechat/data-schemas'); const mongoSanitize = require('express-mongo-sanitize'); -const { isEnabled, ErrorController } = require('@librechat/api'); +const { + isEnabled, + ErrorController, + performStartupChecks, + initializeFileStorage, +} = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); const createValidateImageRequest = require('./middleware/validateImageRequest'); @@ -49,9 +54,11 @@ const startServer = async () => { app.set('trust proxy', trusted_proxy); await seedDatabase(); - const appConfig = await getAppConfig(); + initializeFileStorage(appConfig); + await performStartupChecks(appConfig); await updateInterfacePermissions(appConfig); + const indexPath = path.join(appConfig.paths.dist, 'index.html'); let indexHTML = fs.readFileSync(indexPath, 'utf8'); diff --git a/api/server/services/AppService.interface.spec.js b/api/server/services/AppService.interface.spec.js deleted file mode 100644 index f4b1c67c38..0000000000 --- a/api/server/services/AppService.interface.spec.js +++ /dev/null @@ -1,198 +0,0 @@ -jest.mock('@librechat/data-schemas', () => ({ - logger: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -jest.mock('@librechat/api', () => ({ - ...jest.requireActual('@librechat/api'), - loadDefaultInterface: jest.fn(), -})); -jest.mock('./start/tools', () => ({ - loadAndFormatTools: jest.fn().mockReturnValue({}), -})); -jest.mock('./start/checks', () => ({ - checkVariables: jest.fn(), - checkHealth: jest.fn(), - checkConfig: jest.fn(), - checkAzureVariables: jest.fn(), - checkWebSearchConfig: jest.fn(), -})); - -jest.mock('./Config/loadCustomConfig', () => jest.fn()); - -const AppService = require('./AppService'); -const { loadDefaultInterface } = require('@librechat/api'); - -describe('AppService interface configuration', () => { - let mockLoadCustomConfig; - - beforeEach(() => { - jest.resetModules(); - jest.clearAllMocks(); - mockLoadCustomConfig = require('./Config/loadCustomConfig'); - }); - - it('should set prompts and bookmarks to true when loadDefaultInterface returns true for both', async () => { - mockLoadCustomConfig.mockResolvedValue({}); - loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: true }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - prompts: true, - bookmarks: true, - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should set prompts and bookmarks to false when loadDefaultInterface returns false for both', async () => { - mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: false, bookmarks: false } }); - loadDefaultInterface.mockResolvedValue({ prompts: false, bookmarks: false }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - prompts: false, - bookmarks: false, - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should not set prompts and bookmarks when loadDefaultInterface returns undefined for both', async () => { - mockLoadCustomConfig.mockResolvedValue({}); - loadDefaultInterface.mockResolvedValue({}); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.anything(), - }), - ); - - // Verify that prompts and bookmarks are undefined when not provided - expect(result.interfaceConfig.prompts).toBeUndefined(); - expect(result.interfaceConfig.bookmarks).toBeUndefined(); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should set prompts and bookmarks to different values when loadDefaultInterface returns different values', async () => { - mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: true, bookmarks: false } }); - loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: false }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - prompts: true, - bookmarks: false, - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should correctly configure peoplePicker permissions including roles', async () => { - mockLoadCustomConfig.mockResolvedValue({ - interface: { - peoplePicker: { - users: true, - groups: true, - roles: true, - }, - }, - }); - loadDefaultInterface.mockResolvedValue({ - peoplePicker: { - users: true, - groups: true, - roles: true, - }, - }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - peoplePicker: expect.objectContaining({ - users: true, - groups: true, - roles: true, - }), - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should handle mixed peoplePicker permissions', async () => { - mockLoadCustomConfig.mockResolvedValue({ - interface: { - peoplePicker: { - users: true, - groups: false, - roles: true, - }, - }, - }); - loadDefaultInterface.mockResolvedValue({ - peoplePicker: { - users: true, - groups: false, - roles: true, - }, - }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - peoplePicker: expect.objectContaining({ - users: true, - groups: false, - roles: true, - }), - }), - }), - ); - }); - - it('should set default peoplePicker permissions when not provided', async () => { - mockLoadCustomConfig.mockResolvedValue({}); - loadDefaultInterface.mockResolvedValue({ - peoplePicker: { - users: true, - groups: true, - roles: true, - }, - }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - peoplePicker: expect.objectContaining({ - users: true, - groups: true, - roles: true, - }), - }), - }), - ); - }); -}); diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index ec6af77432..75a5cbe56d 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -1,11 +1,25 @@ -const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); -const AppService = require('~/server/services/AppService'); +const { logger, AppService } = require('@librechat/data-schemas'); +const { loadAndFormatTools } = require('~/server/services/start/tools'); +const loadCustomConfig = require('./loadCustomConfig'); const { setCachedTools } = require('./getCachedTools'); const getLogStores = require('~/cache/getLogStores'); +const paths = require('~/config/paths'); const BASE_CONFIG_KEY = '_BASE_'; +const loadBaseConfig = async () => { + /** @type {TCustomConfig} */ + const config = (await loadCustomConfig()) ?? {}; + /** @type {Record} */ + const systemTools = loadAndFormatTools({ + adminFilter: config.filteredTools, + adminIncluded: config.includedTools, + directory: paths.structuredTools, + }); + return AppService({ config, paths, systemTools }); +}; + /** * Get the app configuration based on user context * @param {Object} [options] @@ -29,7 +43,7 @@ async function getAppConfig(options = {}) { let baseConfig = await cache.get(BASE_CONFIG_KEY); if (!baseConfig) { logger.info('[getAppConfig] App configuration not initialized. Initializing AppService...'); - baseConfig = await AppService(); + baseConfig = await loadBaseConfig(); if (!baseConfig) { throw new Error('Failed to initialize app configuration through AppService.'); diff --git a/api/server/services/Config/handleRateLimits.js b/api/server/services/Config/handleRateLimits.js deleted file mode 100644 index 5e81c5f68d..0000000000 --- a/api/server/services/Config/handleRateLimits.js +++ /dev/null @@ -1,48 +0,0 @@ -const { RateLimitPrefix } = require('librechat-data-provider'); - -/** - * - * @param {TCustomConfig['rateLimits'] | undefined} rateLimits - */ -const handleRateLimits = (rateLimits) => { - if (!rateLimits) { - return; - } - - const rateLimitKeys = { - fileUploads: RateLimitPrefix.FILE_UPLOAD, - conversationsImport: RateLimitPrefix.IMPORT, - tts: RateLimitPrefix.TTS, - stt: RateLimitPrefix.STT, - }; - - Object.entries(rateLimitKeys).forEach(([key, prefix]) => { - const rateLimit = rateLimits[key]; - if (rateLimit) { - setRateLimitEnvVars(prefix, rateLimit); - } - }); -}; - -/** - * Set environment variables for rate limit configurations - * - * @param {string} prefix - Prefix for environment variable names - * @param {object} rateLimit - Rate limit configuration object - */ -const setRateLimitEnvVars = (prefix, rateLimit) => { - const envVarsMapping = { - ipMax: `${prefix}_IP_MAX`, - ipWindowInMinutes: `${prefix}_IP_WINDOW`, - userMax: `${prefix}_USER_MAX`, - userWindowInMinutes: `${prefix}_USER_WINDOW`, - }; - - Object.entries(envVarsMapping).forEach(([key, envVar]) => { - if (rateLimit[key] !== undefined) { - process.env[envVar] = rateLimit[key]; - } - }); -}; - -module.exports = handleRateLimits; diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 479c4bada0..c0415674b9 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -5,14 +5,12 @@ const keyBy = require('lodash/keyBy'); const { loadYaml } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { - CacheKeys, configSchema, paramSettings, EImageOutputType, agentParamSettings, validateSettingDefinitions, } = require('librechat-data-provider'); -const getLogStores = require('~/cache/getLogStores'); const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml'); @@ -119,7 +117,6 @@ https://www.librechat.ai/docs/configuration/stt_tts`); .filter((endpoint) => endpoint.customParams) .forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams)); - if (result.data.modelSpecs) { customConfig.modelSpecs = result.data.modelSpecs; } diff --git a/api/server/services/Files/Azure/crud.js b/api/server/services/Files/Azure/crud.js index 3e091d1031..25bd749276 100644 --- a/api/server/services/Files/Azure/crud.js +++ b/api/server/services/Files/Azure/crud.js @@ -4,7 +4,7 @@ const mime = require('mime'); const axios = require('axios'); const fetch = require('node-fetch'); const { logger } = require('@librechat/data-schemas'); -const { getAzureContainerClient } = require('./initialize'); +const { getAzureContainerClient } = require('@librechat/api'); const defaultBasePath = 'images'; const { AZURE_STORAGE_PUBLIC_ACCESS = 'true', AZURE_CONTAINER_NAME = 'files' } = process.env; @@ -30,7 +30,7 @@ async function saveBufferToAzure({ containerName, }) { try { - const containerClient = getAzureContainerClient(containerName); + const containerClient = await getAzureContainerClient(containerName); const access = AZURE_STORAGE_PUBLIC_ACCESS?.toLowerCase() === 'true' ? 'blob' : undefined; // Create the container if it doesn't exist. This is done per operation. await containerClient.createIfNotExists({ access }); @@ -84,7 +84,7 @@ async function saveURLToAzure({ */ async function getAzureURL({ fileName, basePath = defaultBasePath, userId, containerName }) { try { - const containerClient = getAzureContainerClient(containerName); + const containerClient = await getAzureContainerClient(containerName); const blobPath = userId ? `${basePath}/${userId}/${fileName}` : `${basePath}/${fileName}`; const blockBlobClient = containerClient.getBlockBlobClient(blobPath); return blockBlobClient.url; @@ -103,7 +103,7 @@ async function getAzureURL({ fileName, basePath = defaultBasePath, userId, conta */ async function deleteFileFromAzure(req, file) { try { - const containerClient = getAzureContainerClient(AZURE_CONTAINER_NAME); + const containerClient = await getAzureContainerClient(AZURE_CONTAINER_NAME); const blobPath = file.filepath.split(`${AZURE_CONTAINER_NAME}/`)[1]; if (!blobPath.includes(req.user.id)) { throw new Error('User ID not found in blob path'); @@ -140,7 +140,7 @@ async function streamFileToAzure({ containerName, }) { try { - const containerClient = getAzureContainerClient(containerName); + const containerClient = await getAzureContainerClient(containerName); const access = AZURE_STORAGE_PUBLIC_ACCESS?.toLowerCase() === 'true' ? 'blob' : undefined; // Create the container if it doesn't exist diff --git a/api/server/services/Files/Azure/index.js b/api/server/services/Files/Azure/index.js index 27ad97a852..21e2f2ba7d 100644 --- a/api/server/services/Files/Azure/index.js +++ b/api/server/services/Files/Azure/index.js @@ -1,9 +1,7 @@ const crud = require('./crud'); const images = require('./images'); -const initialize = require('./initialize'); module.exports = { ...crud, ...images, - ...initialize, }; diff --git a/api/server/services/Files/Firebase/crud.js b/api/server/services/Files/Firebase/crud.js index 35e327148c..8e7a191609 100644 --- a/api/server/services/Files/Firebase/crud.js +++ b/api/server/services/Files/Firebase/crud.js @@ -3,9 +3,9 @@ const path = require('path'); const axios = require('axios'); const fetch = require('node-fetch'); const { logger } = require('@librechat/data-schemas'); +const { getFirebaseStorage } = require('@librechat/api'); const { ref, uploadBytes, getDownloadURL, deleteObject } = require('firebase/storage'); const { getBufferMetadata } = require('~/server/utils'); -const { getFirebaseStorage } = require('./initialize'); /** * Deletes a file from Firebase Storage. diff --git a/api/server/services/Files/Firebase/index.js b/api/server/services/Files/Firebase/index.js index 27ad97a852..21e2f2ba7d 100644 --- a/api/server/services/Files/Firebase/index.js +++ b/api/server/services/Files/Firebase/index.js @@ -1,9 +1,7 @@ const crud = require('./crud'); const images = require('./images'); -const initialize = require('./initialize'); module.exports = { ...crud, ...images, - ...initialize, }; diff --git a/api/server/services/Files/Firebase/initialize.js b/api/server/services/Files/Firebase/initialize.js deleted file mode 100644 index efe66be120..0000000000 --- a/api/server/services/Files/Firebase/initialize.js +++ /dev/null @@ -1,39 +0,0 @@ -const firebase = require('firebase/app'); -const { getStorage } = require('firebase/storage'); -const { logger } = require('@librechat/data-schemas'); - -let i = 0; -let firebaseApp = null; - -const initializeFirebase = () => { - // Return existing instance if already initialized - if (firebaseApp) { - return firebaseApp; - } - - const firebaseConfig = { - apiKey: process.env.FIREBASE_API_KEY, - authDomain: process.env.FIREBASE_AUTH_DOMAIN, - projectId: process.env.FIREBASE_PROJECT_ID, - storageBucket: process.env.FIREBASE_STORAGE_BUCKET, - messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, - appId: process.env.FIREBASE_APP_ID, - }; - - if (Object.values(firebaseConfig).some((value) => !value)) { - i === 0 && logger.info('[Optional] CDN not initialized.'); - i++; - return null; - } - - firebaseApp = firebase.initializeApp(firebaseConfig); - logger.info('Firebase CDN initialized'); - return firebaseApp; -}; - -const getFirebaseStorage = () => { - const app = initializeFirebase(); - return app ? getStorage(app) : null; -}; - -module.exports = { initializeFirebase, getFirebaseStorage }; diff --git a/api/server/services/Files/S3/crud.js b/api/server/services/Files/S3/crud.js index 7b31a4d24b..8dac767aa2 100644 --- a/api/server/services/Files/S3/crud.js +++ b/api/server/services/Files/S3/crud.js @@ -1,15 +1,15 @@ const fs = require('fs'); const fetch = require('node-fetch'); +const { initializeS3 } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { FileSources } = require('librechat-data-provider'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand, } = require('@aws-sdk/client-s3'); -const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); -const { initializeS3 } = require('./initialize'); const bucketName = process.env.AWS_BUCKET_NAME; const defaultBasePath = 'images'; diff --git a/api/server/services/Files/S3/index.js b/api/server/services/Files/S3/index.js index 27ad97a852..21e2f2ba7d 100644 --- a/api/server/services/Files/S3/index.js +++ b/api/server/services/Files/S3/index.js @@ -1,9 +1,7 @@ const crud = require('./crud'); const images = require('./images'); -const initialize = require('./initialize'); module.exports = { ...crud, ...images, - ...initialize, }; diff --git a/api/server/services/start/modelSpecs.js b/api/server/services/start/modelSpecs.js deleted file mode 100644 index 057255010f..0000000000 --- a/api/server/services/start/modelSpecs.js +++ /dev/null @@ -1,75 +0,0 @@ -const { logger } = require('@librechat/data-schemas'); -const { normalizeEndpointName } = require('@librechat/api'); -const { EModelEndpoint } = require('librechat-data-provider'); - -/** - * Sets up Model Specs from the config (`librechat.yaml`) file. - * @param {TCustomConfig['endpoints']} [endpoints] - The loaded custom configuration for endpoints. - * @param {TCustomConfig['modelSpecs'] | undefined} [modelSpecs] - The loaded custom configuration for model specs. - * @param {TCustomConfig['interface'] | undefined} [interfaceConfig] - The loaded interface configuration. - * @returns {TCustomConfig['modelSpecs'] | undefined} The processed model specs, if any. - */ -function processModelSpecs(endpoints, _modelSpecs, interfaceConfig) { - if (!_modelSpecs) { - return undefined; - } - - /** @type {TCustomConfig['modelSpecs']['list']} */ - const modelSpecs = []; - /** @type {TCustomConfig['modelSpecs']['list']} */ - const list = _modelSpecs.list; - - const customEndpoints = endpoints?.[EModelEndpoint.custom] ?? []; - - if (interfaceConfig.modelSelect !== true && (_modelSpecs.addedEndpoints?.length ?? 0) > 0) { - logger.warn( - `To utilize \`addedEndpoints\`, which allows provider/model selections alongside model specs, set \`modelSelect: true\` in the interface configuration. - - Example: - \`\`\`yaml - interface: - modelSelect: true - \`\`\` - `, - ); - } - - for (const spec of list) { - if (EModelEndpoint[spec.preset.endpoint] && spec.preset.endpoint !== EModelEndpoint.custom) { - modelSpecs.push(spec); - continue; - } else if (spec.preset.endpoint === EModelEndpoint.custom) { - logger.warn( - `Model Spec with endpoint "${spec.preset.endpoint}" is not supported. You must specify the name of the custom endpoint (case-sensitive, as defined in your config). Skipping model spec...`, - ); - continue; - } - - const normalizedName = normalizeEndpointName(spec.preset.endpoint); - const endpoint = customEndpoints.find( - (customEndpoint) => normalizedName === normalizeEndpointName(customEndpoint.name), - ); - - if (!endpoint) { - logger.warn(`Model spec with endpoint "${spec.preset.endpoint}" was skipped: Endpoint not found in configuration. The \`endpoint\` value must exactly match either a system-defined endpoint or a custom endpoint defined by the user. - -For more information, see the documentation at https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/model_specs#endpoint`); - continue; - } - - modelSpecs.push({ - ...spec, - preset: { - ...spec.preset, - endpoint: normalizedName, - }, - }); - } - - return { - ..._modelSpecs, - list: modelSpecs, - }; -} - -module.exports = { processModelSpecs }; diff --git a/api/server/services/start/turnstile.js b/api/server/services/start/turnstile.js deleted file mode 100644 index 9309e680c7..0000000000 --- a/api/server/services/start/turnstile.js +++ /dev/null @@ -1,44 +0,0 @@ -const { logger } = require('@librechat/data-schemas'); -const { removeNullishValues } = require('librechat-data-provider'); - -/** - * Loads and maps the Cloudflare Turnstile configuration. - * - * Expected config structure: - * - * turnstile: - * siteKey: "your-site-key-here" - * options: - * language: "auto" // "auto" or an ISO 639-1 language code (e.g. en) - * size: "normal" // Options: "normal", "compact", "flexible", or "invisible" - * - * @param {TCustomConfig | undefined} config - The loaded custom configuration. - * @param {TConfigDefaults} configDefaults - The custom configuration default values. - * @returns {TCustomConfig['turnstile']} The mapped Turnstile configuration. - */ -function loadTurnstileConfig(config, configDefaults) { - const { turnstile: customTurnstile = {} } = config ?? {}; - const { turnstile: defaults = {} } = configDefaults; - - /** @type {TCustomConfig['turnstile']} */ - const loadedTurnstile = removeNullishValues({ - siteKey: customTurnstile.siteKey ?? defaults.siteKey, - options: customTurnstile.options ?? defaults.options, - }); - - const enabled = Boolean(loadedTurnstile.siteKey); - - if (enabled) { - logger.info( - 'Turnstile is ENABLED with configuration:\n' + JSON.stringify(loadedTurnstile, null, 2), - ); - } else { - logger.info('Turnstile is DISABLED (no siteKey provided).'); - } - - return loadedTurnstile; -} - -module.exports = { - loadTurnstileConfig, -}; diff --git a/api/typedefs.js b/api/typedefs.js index 6cf87ef61b..260eb84bfe 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -1103,13 +1103,13 @@ /** * @exports AppConfig - * @typedef {import('@librechat/api').AppConfig} AppConfig + * @typedef {import('@librechat/data-schemas').AppConfig} AppConfig * @memberof typedefs */ /** * @exports JsonSchemaType - * @typedef {import('@librechat/api').JsonSchemaType} JsonSchemaType + * @typedef {import('@librechat/data-schemas').JsonSchemaType} JsonSchemaType * @memberof typedefs */ @@ -1371,12 +1371,7 @@ /** * @exports FunctionTool - * @typedef {Object} FunctionTool - * @property {'function'} type - The type of tool, 'function'. - * @property {Object} function - The function definition. - * @property {string} function.description - A description of what the function does. - * @property {string} function.name - The name of the function to be called. - * @property {Object} function.parameters - The parameters the function accepts, described as a JSON Schema object. + * @typedef {import('@librechat/data-schemas').FunctionTool} FunctionTool * @memberof typedefs */ diff --git a/package-lock.json b/package-lock.json index 4a70593821..0a3784fcd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51330,6 +51330,10 @@ "typescript": "^5.0.4" }, "peerDependencies": { + "@aws-sdk/client-s3": "^3.758.0", + "@azure/identity": "^4.7.0", + "@azure/search-documents": "^12.0.0", + "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", "@librechat/agents": "^2.4.82", @@ -51341,6 +51345,7 @@ "eventsource": "^3.0.2", "express": "^4.21.2", "express-session": "^1.18.2", + "firebase": "^11.0.2", "form-data": "^4.0.4", "ioredis": "^5.3.2", "js-yaml": "^4.1.0", diff --git a/packages/api/package.json b/packages/api/package.json index e32fa384e6..2a38366b41 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -74,6 +74,10 @@ "registry": "https://registry.npmjs.org/" }, "peerDependencies": { + "@aws-sdk/client-s3": "^3.758.0", + "@azure/identity": "^4.7.0", + "@azure/search-documents": "^12.0.0", + "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", "@librechat/agents": "^2.4.82", @@ -85,6 +89,7 @@ "eventsource": "^3.0.2", "express": "^4.21.2", "express-session": "^1.18.2", + "firebase": "^11.0.2", "form-data": "^4.0.4", "ioredis": "^5.3.2", "js-yaml": "^4.1.0", diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 0814e80f31..c22dc0fbf4 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -1,4 +1,3 @@ -export * from './config'; export * from './memory'; export * from './migration'; export * from './legacy'; diff --git a/packages/api/src/agents/resources.test.ts b/packages/api/src/agents/resources.test.ts index b594924934..bfd2327764 100644 --- a/packages/api/src/agents/resources.test.ts +++ b/packages/api/src/agents/resources.test.ts @@ -2,10 +2,9 @@ import { primeResources } from './resources'; import { logger } from '@librechat/data-schemas'; import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-data-provider'; import type { TAgentsEndpoint, TFile } from 'librechat-data-provider'; +import type { IUser, AppConfig } from '@librechat/data-schemas'; import type { Request as ServerRequest } from 'express'; -import type { IUser } from '@librechat/data-schemas'; import type { TGetFiles } from './resources'; -import type { AppConfig } from '~/types'; // Mock logger jest.mock('@librechat/data-schemas', () => ({ diff --git a/packages/api/src/agents/resources.ts b/packages/api/src/agents/resources.ts index d746e3c19f..9c32638a9c 100644 --- a/packages/api/src/agents/resources.ts +++ b/packages/api/src/agents/resources.ts @@ -1,10 +1,9 @@ import { logger } from '@librechat/data-schemas'; import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-data-provider'; import type { AgentToolResources, TFile, AgentBaseResource } from 'librechat-data-provider'; +import type { IMongoFile, AppConfig, IUser } from '@librechat/data-schemas'; import type { FilterQuery, QueryOptions, ProjectionType } from 'mongoose'; -import type { IMongoFile, IUser } from '@librechat/data-schemas'; import type { Request as ServerRequest } from 'express'; -import type { AppConfig } from '~/types/'; /** * Function type for retrieving files from the database diff --git a/packages/api/src/app/AppService.interface.spec.ts b/packages/api/src/app/AppService.interface.spec.ts new file mode 100644 index 0000000000..e15d57e329 --- /dev/null +++ b/packages/api/src/app/AppService.interface.spec.ts @@ -0,0 +1,157 @@ +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +import { AppService } from '@librechat/data-schemas'; + +describe('AppService interface configuration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should set prompts to true when config specifies prompts as true', async () => { + const config = { + interface: { + prompts: true, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: true, + }), + }), + ); + }); + + it('should set prompts and bookmarks to false when config specifies them as false', async () => { + const config = { + interface: { + prompts: false, + bookmarks: false, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: false, + bookmarks: false, + }), + }), + ); + }); + + it('should not set prompts and bookmarks when not provided in config', async () => { + const config = {}; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.anything(), + }), + ); + + // Verify that prompts and bookmarks are undefined when not provided + expect(result.interfaceConfig?.prompts).toBeUndefined(); + expect(result.interfaceConfig?.bookmarks).toBeUndefined(); + }); + + it('should set prompts and bookmarks to different values when specified differently in config', async () => { + const config = { + interface: { + prompts: true, + bookmarks: false, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: true, + bookmarks: false, + }), + }), + ); + }); + + it('should correctly configure peoplePicker permissions including roles', async () => { + const config = { + interface: { + peoplePicker: { + users: true, + groups: true, + roles: true, + }, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + peoplePicker: expect.objectContaining({ + users: true, + groups: true, + roles: true, + }), + }), + }), + ); + }); + + it('should handle mixed peoplePicker permissions', async () => { + const config = { + interface: { + peoplePicker: { + users: true, + groups: false, + roles: true, + }, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + peoplePicker: expect.objectContaining({ + users: true, + groups: false, + roles: true, + }), + }), + }), + ); + }); + + it('should not set peoplePicker when not provided in config', async () => { + const config = {}; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.anything(), + }), + ); + + // Verify that peoplePicker is undefined when not provided + expect(result.interfaceConfig?.peoplePicker).toBeUndefined(); + }); +}); diff --git a/api/server/services/AppService.spec.js b/packages/api/src/app/AppService.spec.ts similarity index 58% rename from api/server/services/AppService.spec.js rename to packages/api/src/app/AppService.spec.ts index 1b540c96c0..9c771b4bd6 100644 --- a/api/server/services/AppService.spec.js +++ b/packages/api/src/app/AppService.spec.ts @@ -1,4 +1,5 @@ -const { +import { + OCRStrategy, FileSources, EModelEndpoint, EImageOutputType, @@ -6,9 +7,8 @@ const { defaultSocialLogins, validateAzureGroups, defaultAgentCapabilities, - deprecatedAzureVariables, - conflictingAzureVariables, -} = require('librechat-data-provider'); +} from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/data-schemas'), @@ -20,48 +20,7 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); -const AppService = require('./AppService'); - -jest.mock('./Files/Firebase/initialize', () => ({ - initializeFirebase: jest.fn(), -})); - -jest.mock('./Config/loadCustomConfig', () => - jest.fn(() => - Promise.resolve({ - registration: { socialLogins: ['testLogin'] }, - fileStrategy: 'testStrategy', - balance: { - enabled: true, - }, - }), - ), -); - -jest.mock('./start/tools', () => ({ - loadAndFormatTools: jest.fn().mockReturnValue({ - ExampleTool: { - type: 'function', - function: { - description: 'Example tool function', - name: 'exampleFunction', - parameters: { - type: 'object', - properties: { - param1: { type: 'string', description: 'An example parameter' }, - }, - required: ['param1'], - }, - }, - }, - }), -})); -jest.mock('./start/turnstile', () => ({ - loadTurnstileConfig: jest.fn(() => ({ - siteKey: 'default-site-key', - options: {}, - })), -})); +import { AppService } from '@librechat/data-schemas'; const azureGroups = [ { @@ -97,20 +56,26 @@ const azureGroups = [ models: { 'gpt-4-turbo': true, }, - }, + } as const, ]; -jest.mock('./start/checks', () => ({ - ...jest.requireActual('./start/checks'), - checkHealth: jest.fn(), -})); - describe('AppService', () => { - const mockedTurnstileConfig = { - siteKey: 'default-site-key', - options: {}, + const mockSystemTools = { + ExampleTool: { + type: 'function', + function: { + description: 'Example tool function', + name: 'exampleFunction', + parameters: { + type: 'object', + properties: { + param1: { type: 'string', description: 'An example parameter' }, + }, + required: ['param1'], + }, + }, + }, }; - const loadCustomConfig = require('./Config/loadCustomConfig'); beforeEach(() => { process.env.CDN_PROVIDER = undefined; @@ -118,7 +83,15 @@ describe('AppService', () => { }); it('should correctly assign process.env and initialize app config based on custom config', async () => { - const result = await AppService(); + const config: Partial = { + registration: { socialLogins: ['testLogin'] }, + fileStrategy: 'testStrategy' as FileSources, + balance: { + enabled: true, + }, + }; + + const result = await AppService({ config, systemTools: mockSystemTools }); expect(process.env.CDN_PROVIDER).toEqual('testStrategy'); @@ -139,9 +112,6 @@ describe('AppService', () => { presets: true, }), mcpConfig: null, - turnstileConfig: mockedTurnstileConfig, - modelSpecs: undefined, - paths: expect.anything(), imageOutputType: expect.any(String), fileConfig: undefined, secureImageLinks: undefined, @@ -173,30 +143,13 @@ describe('AppService', () => { ); }); - it('should log a warning if the config version is outdated', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - version: '0.9.0', // An outdated version for this test - registration: { socialLogins: ['testLogin'] }, - fileStrategy: 'testStrategy', - }), - ); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Outdated Config version')); - }); - it('should change the `imageOutputType` based on config value', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - version: '0.10.0', - imageOutputType: EImageOutputType.WEBP, - }), - ); + const config = { + version: '0.10.0', + imageOutputType: EImageOutputType.WEBP, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ imageOutputType: EImageOutputType.WEBP, @@ -205,13 +158,11 @@ describe('AppService', () => { }); it('should default to `PNG` `imageOutputType` with no provided type', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - version: '0.10.0', - }), - ); + const config = { + version: '0.10.0', + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ imageOutputType: EImageOutputType.PNG, @@ -220,9 +171,9 @@ describe('AppService', () => { }); it('should default to `PNG` `imageOutputType` with no provided config', async () => { - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined)); + const config = {}; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ imageOutputType: EImageOutputType.PNG, @@ -230,35 +181,14 @@ describe('AppService', () => { ); }); - it('should initialize Firebase when fileStrategy is firebase', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - fileStrategy: FileSources.firebase, - }), - ); - - await AppService(); - - const { initializeFirebase } = require('./Files/Firebase/initialize'); - expect(initializeFirebase).toHaveBeenCalled(); - - expect(process.env.CDN_PROVIDER).toEqual(FileSources.firebase); - }); - it('should load and format tools accurately with defined structure', async () => { - const { loadAndFormatTools } = require('./start/tools'); + const config = {}; - const result = await AppService(); - - expect(loadAndFormatTools).toHaveBeenCalledWith({ - adminFilter: undefined, - adminIncluded: undefined, - directory: expect.anything(), - }); + const result = await AppService({ config, systemTools: mockSystemTools }); // Verify tools are included in the returned config expect(result.availableTools).toBeDefined(); - expect(result.availableTools.ExampleTool).toEqual({ + expect(result.availableTools?.ExampleTool).toEqual({ type: 'function', function: { description: 'Example tool function', @@ -275,21 +205,19 @@ describe('AppService', () => { }); it('should correctly configure Assistants endpoint based on custom config', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.assistants]: { - disableBuilder: true, - pollIntervalMs: 5000, - timeoutMs: 30000, - supportedIds: ['id1', 'id2'], - privateAssistants: false, - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.assistants]: { + disableBuilder: true, + pollIntervalMs: 5000, + timeoutMs: 30000, + supportedIds: ['id1', 'id2'], + privateAssistants: false, }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -307,21 +235,19 @@ describe('AppService', () => { }); it('should correctly configure Agents endpoint based on custom config', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.agents]: { - disableBuilder: true, - recursionLimit: 10, - maxRecursionLimit: 20, - allowedProviders: ['openai', 'anthropic'], - capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.agents]: { + disableBuilder: true, + recursionLimit: 10, + maxRecursionLimit: 20, + allowedProviders: ['openai', 'anthropic'], + capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -342,9 +268,9 @@ describe('AppService', () => { }); it('should configure Agents endpoint with defaults when no config is provided', async () => { - loadCustomConfig.mockImplementationOnce(() => Promise.resolve({})); + const config = {}; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -359,17 +285,15 @@ describe('AppService', () => { }); it('should configure Agents endpoint with defaults when endpoints exist but agents is not defined', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - }, + const config = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -388,21 +312,19 @@ describe('AppService', () => { it('should correctly configure minimum Azure OpenAI Assistant values', async () => { const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }]; - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: assistantGroups, - assistants: true, - }, + const config = { + endpoints: { + [EModelEndpoint.azureOpenAI]: { + groups: assistantGroups, + assistants: true, }, - }), - ); + }, + }; process.env.WESTUS_API_KEY = 'westus-key'; process.env.EASTUS_API_KEY = 'eastus-key'; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ endpoints: expect.objectContaining({ @@ -419,20 +341,18 @@ describe('AppService', () => { }); it('should correctly configure Azure OpenAI endpoint based on custom config', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.azureOpenAI]: { + groups: azureGroups, }, - }), - ); + }, + }; process.env.WESTUS_API_KEY = 'westus-key'; process.env.EASTUS_API_KEY = 'eastus-key'; - const result = await AppService(); + const result = await AppService({ config }); const { modelNames, modelGroupMap, groupMap } = validateAzureGroups(azureGroups); expect(result).toEqual( @@ -456,8 +376,9 @@ describe('AppService', () => { process.env.FILE_UPLOAD_USER_WINDOW = '20'; const initialEnv = { ...process.env }; + const config = {}; - await AppService(); + await AppService({ config }); // Expect environment variables to remain unchanged expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX); @@ -466,38 +387,15 @@ describe('AppService', () => { expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual(initialEnv.FILE_UPLOAD_USER_WINDOW); }); - it('should correctly set FILE_UPLOAD environment variables based on rate limits', async () => { - // Define and mock a custom configuration with rate limits - const rateLimitsConfig = { - rateLimits: { - fileUploads: { - ipMax: '100', - ipWindowInMinutes: '60', - userMax: '50', - userWindowInMinutes: '30', - }, - }, - }; - - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(rateLimitsConfig)); - - await AppService(); - - // Verify that process.env has been updated according to the rate limits config - expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('100'); - expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual('60'); - expect(process.env.FILE_UPLOAD_USER_MAX).toEqual('50'); - expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual('30'); - }); - it('should fallback to default FILE_UPLOAD environment variables when rate limits are unspecified', async () => { // Setup initial environment variables to non-default values process.env.FILE_UPLOAD_IP_MAX = 'initialMax'; process.env.FILE_UPLOAD_IP_WINDOW = 'initialWindow'; process.env.FILE_UPLOAD_USER_MAX = 'initialUserMax'; process.env.FILE_UPLOAD_USER_WINDOW = 'initialUserWindow'; + const config = {}; - await AppService(); + await AppService({ config }); // Verify that process.env falls back to the initial values expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initialMax'); @@ -514,8 +412,9 @@ describe('AppService', () => { process.env.IMPORT_USER_WINDOW = '20'; const initialEnv = { ...process.env }; + const config = {}; - await AppService(); + await AppService({ config }); // Expect environment variables to remain unchanged expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX); @@ -524,38 +423,15 @@ describe('AppService', () => { expect(process.env.IMPORT_USER_WINDOW).toEqual(initialEnv.IMPORT_USER_WINDOW); }); - it('should correctly set IMPORT environment variables based on rate limits', async () => { - // Define and mock a custom configuration with rate limits - const importLimitsConfig = { - rateLimits: { - conversationsImport: { - ipMax: '150', - ipWindowInMinutes: '60', - userMax: '50', - userWindowInMinutes: '30', - }, - }, - }; - - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(importLimitsConfig)); - - await AppService(); - - // Verify that process.env has been updated according to the rate limits config - expect(process.env.IMPORT_IP_MAX).toEqual('150'); - expect(process.env.IMPORT_IP_WINDOW).toEqual('60'); - expect(process.env.IMPORT_USER_MAX).toEqual('50'); - expect(process.env.IMPORT_USER_WINDOW).toEqual('30'); - }); - it('should fallback to default IMPORT environment variables when rate limits are unspecified', async () => { // Setup initial environment variables to non-default values process.env.IMPORT_IP_MAX = 'initialMax'; process.env.IMPORT_IP_WINDOW = 'initialWindow'; process.env.IMPORT_USER_MAX = 'initialUserMax'; process.env.IMPORT_USER_WINDOW = 'initialUserWindow'; + const config = {}; - await AppService(); + await AppService({ config }); // Verify that process.env falls back to the initial values expect(process.env.IMPORT_IP_MAX).toEqual('initialMax'); @@ -565,34 +441,32 @@ describe('AppService', () => { }); it('should correctly configure endpoint with titlePrompt, titleMethod, and titlePromptTemplate', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - titleMethod: 'structured', - titlePrompt: 'Custom title prompt for conversation', - titlePromptTemplate: 'Summarize this conversation: {{conversation}}', - }, - [EModelEndpoint.assistants]: { - titleMethod: 'functions', - titlePrompt: 'Generate a title for this assistant conversation', - titlePromptTemplate: 'Assistant conversation template: {{messages}}', - }, - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - titleConvo: true, - titleMethod: 'completion', - titleModel: 'gpt-4', - titlePrompt: 'Azure title prompt', - titlePromptTemplate: 'Azure conversation: {{context}}', - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + titleMethod: 'structured', + titlePrompt: 'Custom title prompt for conversation', + titlePromptTemplate: 'Summarize this conversation: {{conversation}}', }, - }), - ); + [EModelEndpoint.assistants]: { + titleMethod: 'functions', + titlePrompt: 'Generate a title for this assistant conversation', + titlePromptTemplate: 'Assistant conversation template: {{messages}}', + }, + [EModelEndpoint.azureOpenAI]: { + groups: azureGroups, + titleConvo: true, + titleMethod: 'completion', + titleModel: 'gpt-4', + titlePrompt: 'Azure title prompt', + titlePromptTemplate: 'Azure conversation: {{context}}', + }, + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -625,24 +499,25 @@ describe('AppService', () => { }); it('should configure Agent endpoint with title generation settings', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.agents]: { - disableBuilder: false, - titleConvo: true, - titleModel: 'gpt-4', - titleMethod: 'structured', - titlePrompt: 'Generate a descriptive title for this agent conversation', - titlePromptTemplate: 'Agent conversation summary: {{content}}', - recursionLimit: 15, - capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.agents]: { + disableBuilder: false, + titleConvo: true, + titleModel: 'gpt-4', + titleMethod: 'structured', + titlePrompt: 'Generate a descriptive title for this agent conversation', + titlePromptTemplate: 'Agent conversation summary: {{content}}', + recursionLimit: 15, + capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], + maxCitations: 30, + maxCitationsPerFile: 7, + minRelevanceScore: 0.45, }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -666,18 +541,16 @@ describe('AppService', () => { }); it('should handle missing title configuration options with defaults', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - // titlePrompt and titlePromptTemplate are not provided - }, + const config = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, + // titlePrompt and titlePromptTemplate are not provided }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -696,24 +569,27 @@ describe('AppService', () => { }); it('should correctly configure titleEndpoint when specified', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - titleEndpoint: EModelEndpoint.anthropic, - titlePrompt: 'Generate a concise title', - }, - [EModelEndpoint.agents]: { - titleEndpoint: 'custom-provider', - titleMethod: 'structured', - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + titleEndpoint: EModelEndpoint.anthropic, + titlePrompt: 'Generate a concise title', }, - }), - ); + [EModelEndpoint.agents]: { + disableBuilder: false, + capabilities: [AgentCapabilities.tools], + maxCitations: 30, + maxCitationsPerFile: 7, + minRelevanceScore: 0.45, + titleEndpoint: 'custom-provider', + titleMethod: 'structured', + }, + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -736,27 +612,25 @@ describe('AppService', () => { }); it('should correctly configure all endpoint when specified', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - all: { - titleConvo: true, - titleModel: 'gpt-4o-mini', - titleMethod: 'structured', - titlePrompt: 'Default title prompt for all endpoints', - titlePromptTemplate: 'Default template: {{conversation}}', - titleEndpoint: EModelEndpoint.anthropic, - streamRate: 50, - }, - [EModelEndpoint.openAI]: { - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - }, + const config: Partial = { + endpoints: { + all: { + titleConvo: true, + titleModel: 'gpt-4o-mini', + titleMethod: 'structured', + titlePrompt: 'Default title prompt for all endpoints', + titlePromptTemplate: 'Default template: {{conversation}}', + titleEndpoint: EModelEndpoint.anthropic, + streamRate: 50, }, - }), - ); + [EModelEndpoint.openAI]: { + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + }, + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -783,8 +657,7 @@ describe('AppService', () => { }); describe('AppService updating app config and issuing warnings', () => { - let initialEnv; - const loadCustomConfig = require('./Config/loadCustomConfig'); + let initialEnv: NodeJS.ProcessEnv; beforeEach(() => { // Store initial environment variables to restore them after each test @@ -799,15 +672,13 @@ describe('AppService updating app config and issuing warnings', () => { process.env = { ...initialEnv }; }); - it('should initialize app config with default values if loadCustomConfig returns undefined', async () => { - // Mock loadCustomConfig to return undefined - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined)); + it('should initialize app config with default values if config is empty', async () => { + const config = {}; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ - paths: expect.anything(), config: {}, fileStrategy: FileSources.local, registration: expect.objectContaining({ @@ -821,10 +692,10 @@ describe('AppService updating app config and issuing warnings', () => { ); }); - it('should initialize app config with values from loadCustomConfig', async () => { + it('should initialize app config with values from config', async () => { // Mock loadCustomConfig to return a specific config object with a complete balance config - const customConfig = { - fileStrategy: 'firebase', + const config: Partial = { + fileStrategy: FileSources.firebase, registration: { socialLogins: ['testLogin'] }, balance: { enabled: false, @@ -835,27 +706,28 @@ describe('AppService updating app config and issuing warnings', () => { refillAmount: 5000, }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(customConfig)); - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ - paths: expect.anything(), - config: customConfig, - fileStrategy: customConfig.fileStrategy, + config, + fileStrategy: config.fileStrategy, registration: expect.objectContaining({ - socialLogins: customConfig.registration.socialLogins, + socialLogins: config.registration?.socialLogins, }), - balance: customConfig.balance, + balance: config.balance, }), ); }); it('should apply the assistants endpoint configuration correctly to app config', async () => { - const mockConfig = { + const config: Partial = { endpoints: { assistants: { + version: 'v2', + retrievalModels: ['gpt-4', 'gpt-3.5-turbo'], + capabilities: [], disableBuilder: true, pollIntervalMs: 5000, timeoutMs: 30000, @@ -863,9 +735,8 @@ describe('AppService updating app config and issuing warnings', () => { }, }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -884,119 +755,22 @@ describe('AppService updating app config and issuing warnings', () => { expect(result.endpoints.assistants.excludedIds).toBeUndefined(); }); - it('should log a warning when both supportedIds and excludedIds are provided', async () => { - const mockConfig = { - endpoints: { - assistants: { - disableBuilder: false, - pollIntervalMs: 3000, - timeoutMs: 20000, - supportedIds: ['id1', 'id2'], - excludedIds: ['id3'], - }, - }, - }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining( - "The 'assistants' endpoint has both 'supportedIds' and 'excludedIds' defined.", - ), - ); - }); - - it('should log a warning when privateAssistants and supportedIds or excludedIds are provided', async () => { - const mockConfig = { - endpoints: { - assistants: { - privateAssistants: true, - supportedIds: ['id1'], - }, - }, - }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining( - "The 'assistants' endpoint has both 'privateAssistants' and 'supportedIds' or 'excludedIds' defined.", - ), - ); - }); - - it('should issue expected warnings when loading Azure Groups with deprecated Environment Variables', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - }, - }, - }), - ); - - deprecatedAzureVariables.forEach((varInfo) => { - process.env[varInfo.key] = 'test'; - }); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - deprecatedAzureVariables.forEach(({ key, description }) => { - expect(logger.warn).toHaveBeenCalledWith( - `The \`${key}\` environment variable (related to ${description}) should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you will experience conflicts and errors.`, - ); - }); - }); - - it('should issue expected warnings when loading conflicting Azure Envrionment Variables', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - }, - }, - }), - ); - - conflictingAzureVariables.forEach((varInfo) => { - process.env[varInfo.key] = 'test'; - }); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - conflictingAzureVariables.forEach(({ key }) => { - expect(logger.warn).toHaveBeenCalledWith( - `The \`${key}\` environment variable should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you may experience with the defined placeholders for mapping to the current model grouping using the same name.`, - ); - }); - }); - it('should not parse environment variable references in OCR config', async () => { // Mock custom configuration with env variable references in OCR config - const mockConfig = { + const config: Partial = { ocr: { apiKey: '${OCR_API_KEY_CUSTOM_VAR_NAME}', baseURL: '${OCR_BASEURL_CUSTOM_VAR_NAME}', - strategy: 'mistral_ocr', + strategy: OCRStrategy.MISTRAL_OCR, mistralModel: 'mistral-medium', }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - // Set actual environment variables with different values process.env.OCR_API_KEY_CUSTOM_VAR_NAME = 'actual-api-key'; process.env.OCR_BASEURL_CUSTOM_VAR_NAME = 'https://actual-ocr-url.com'; - const result = await AppService(); + const result = await AppService({ config }); // Verify that the raw string references were preserved and not interpolated expect(result).toEqual( @@ -1012,7 +786,7 @@ describe('AppService updating app config and issuing warnings', () => { }); it('should correctly configure peoplePicker permissions when specified', async () => { - const mockConfig = { + const config = { interface: { peoplePicker: { users: true, @@ -1022,9 +796,7 @@ describe('AppService updating app config and issuing warnings', () => { }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - - const result = await AppService(); + const result = await AppService({ config }); // Check that interface config includes the permissions expect(result).toEqual( diff --git a/packages/api/src/app/cdn.ts b/packages/api/src/app/cdn.ts new file mode 100644 index 0000000000..451eeb8897 --- /dev/null +++ b/packages/api/src/app/cdn.ts @@ -0,0 +1,26 @@ +import { logger } from '@librechat/data-schemas'; +import { FileSources } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { initializeAzureBlobService } from '~/cdn/azure'; +import { initializeFirebase } from '~/cdn/firebase'; +import { initializeS3 } from '~/cdn/s3'; + +/** + * Initializes file storage clients based on the configured file strategy. + * This should be called after loading the app configuration. + * @param {Object} options + * @param {AppConfig} options.appConfig - The application configuration + */ +export function initializeFileStorage(appConfig: AppConfig) { + const { fileStrategy } = appConfig; + + if (fileStrategy === FileSources.firebase) { + initializeFirebase(); + } else if (fileStrategy === FileSources.azure_blob) { + initializeAzureBlobService().catch((error) => { + logger.error('Error initializing Azure Blob Service:', error); + }); + } else if (fileStrategy === FileSources.s3) { + initializeS3(); + } +} diff --git a/api/server/services/start/checks.spec.js b/packages/api/src/app/checks.spec.ts similarity index 52% rename from api/server/services/start/checks.spec.js rename to packages/api/src/app/checks.spec.ts index 1281331266..cab5b727f9 100644 --- a/api/server/services/start/checks.spec.js +++ b/packages/api/src/app/checks.spec.ts @@ -11,12 +11,15 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); -const { checkWebSearchConfig } = require('./checks'); -const { logger } = require('@librechat/data-schemas'); -const { extractVariableName } = require('librechat-data-provider'); +import { handleRateLimits } from './limits'; +import { checkWebSearchConfig } from './checks'; +import { logger } from '@librechat/data-schemas'; +import { extractVariableName as extract } from 'librechat-data-provider'; + +const extractVariableName = extract as jest.MockedFunction; describe('checkWebSearchConfig', () => { - let originalEnv; + let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { // Clear all mocks @@ -178,6 +181,8 @@ describe('checkWebSearchConfig', () => { anotherKey: '${SOME_VAR}', }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + /** @ts-expect-error */ checkWebSearchConfig(config); expect(extractVariableName).not.toHaveBeenCalled(); @@ -200,3 +205,154 @@ describe('checkWebSearchConfig', () => { }); }); }); + +describe('handleRateLimits', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Store original environment + originalEnv = process.env; + + // Reset process.env + process.env = { ...originalEnv }; + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + }); + + it('should correctly set FILE_UPLOAD environment variables based on rate limits', () => { + const rateLimits = { + fileUploads: { + ipMax: 100, + ipWindowInMinutes: 60, + userMax: 50, + userWindowInMinutes: 30, + }, + }; + + handleRateLimits(rateLimits); + + // Verify that process.env has been updated according to the rate limits config + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('100'); + expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual('60'); + expect(process.env.FILE_UPLOAD_USER_MAX).toEqual('50'); + expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual('30'); + }); + + it('should correctly set IMPORT environment variables based on rate limits', () => { + const rateLimits = { + conversationsImport: { + ipMax: 150, + ipWindowInMinutes: 60, + userMax: 50, + userWindowInMinutes: 30, + }, + }; + + handleRateLimits(rateLimits); + + // Verify that process.env has been updated according to the rate limits config + expect(process.env.IMPORT_IP_MAX).toEqual('150'); + expect(process.env.IMPORT_IP_WINDOW).toEqual('60'); + expect(process.env.IMPORT_USER_MAX).toEqual('50'); + expect(process.env.IMPORT_USER_WINDOW).toEqual('30'); + }); + + it('should not modify FILE_UPLOAD environment variables without rate limits', () => { + // Setup initial environment variables + process.env.FILE_UPLOAD_IP_MAX = '10'; + process.env.FILE_UPLOAD_IP_WINDOW = '15'; + process.env.FILE_UPLOAD_USER_MAX = '5'; + process.env.FILE_UPLOAD_USER_WINDOW = '20'; + + const initialEnv = { ...process.env }; + + handleRateLimits({}); + + // Expect environment variables to remain unchanged + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX); + expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual(initialEnv.FILE_UPLOAD_IP_WINDOW); + expect(process.env.FILE_UPLOAD_USER_MAX).toEqual(initialEnv.FILE_UPLOAD_USER_MAX); + expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual(initialEnv.FILE_UPLOAD_USER_WINDOW); + }); + + it('should not modify IMPORT environment variables without rate limits', () => { + // Setup initial environment variables + process.env.IMPORT_IP_MAX = '10'; + process.env.IMPORT_IP_WINDOW = '15'; + process.env.IMPORT_USER_MAX = '5'; + process.env.IMPORT_USER_WINDOW = '20'; + + const initialEnv = { ...process.env }; + + handleRateLimits({}); + + // Expect environment variables to remain unchanged + expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX); + expect(process.env.IMPORT_IP_WINDOW).toEqual(initialEnv.IMPORT_IP_WINDOW); + expect(process.env.IMPORT_USER_MAX).toEqual(initialEnv.IMPORT_USER_MAX); + expect(process.env.IMPORT_USER_WINDOW).toEqual(initialEnv.IMPORT_USER_WINDOW); + }); + + it('should handle undefined rateLimits parameter', () => { + // Setup initial environment variables + process.env.FILE_UPLOAD_IP_MAX = 'initial'; + process.env.IMPORT_IP_MAX = 'initial'; + + handleRateLimits(undefined); + + // Should not modify any environment variables + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initial'); + expect(process.env.IMPORT_IP_MAX).toEqual('initial'); + }); + + it('should handle partial rate limit configurations', () => { + const rateLimits = { + fileUploads: { + ipMax: 200, + // Only setting ipMax, other properties undefined + }, + }; + + handleRateLimits(rateLimits); + + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('200'); + // Other FILE_UPLOAD env vars should not be set + expect(process.env.FILE_UPLOAD_IP_WINDOW).toBeUndefined(); + expect(process.env.FILE_UPLOAD_USER_MAX).toBeUndefined(); + expect(process.env.FILE_UPLOAD_USER_WINDOW).toBeUndefined(); + }); + + it('should correctly set TTS and STT environment variables based on rate limits', () => { + const rateLimits = { + tts: { + ipMax: 75, + ipWindowInMinutes: 45, + userMax: 25, + userWindowInMinutes: 15, + }, + stt: { + ipMax: 80, + ipWindowInMinutes: 50, + userMax: 30, + userWindowInMinutes: 20, + }, + }; + + handleRateLimits(rateLimits); + + // Verify TTS environment variables + expect(process.env.TTS_IP_MAX).toEqual('75'); + expect(process.env.TTS_IP_WINDOW).toEqual('45'); + expect(process.env.TTS_USER_MAX).toEqual('25'); + expect(process.env.TTS_USER_WINDOW).toEqual('15'); + + // Verify STT environment variables + expect(process.env.STT_IP_MAX).toEqual('80'); + expect(process.env.STT_IP_WINDOW).toEqual('50'); + expect(process.env.STT_USER_MAX).toEqual('30'); + expect(process.env.STT_USER_WINDOW).toEqual('20'); + }); +}); diff --git a/api/server/services/start/checks.js b/packages/api/src/app/checks.ts similarity index 53% rename from api/server/services/start/checks.js rename to packages/api/src/app/checks.ts index 5b13d41d59..66f5b620e6 100644 --- a/api/server/services/start/checks.js +++ b/packages/api/src/app/checks.ts @@ -1,11 +1,9 @@ -const { logger } = require('@librechat/data-schemas'); -const { isEnabled, webSearchKeys, checkEmailConfig } = require('@librechat/api'); -const { - Constants, - extractVariableName, - deprecatedAzureVariables, - conflictingAzureVariables, -} = require('librechat-data-provider'); +import { logger, webSearchKeys } from '@librechat/data-schemas'; +import { Constants, extractVariableName } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { isEnabled, checkEmailConfig } from '~/utils'; +import { handleRateLimits } from './limits'; const secretDefaults = { CREDS_KEY: 'f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0', @@ -32,17 +30,84 @@ const deprecatedVariables = [ }, ]; +export const deprecatedAzureVariables = [ + /* "related to" precedes description text */ + { key: 'AZURE_OPENAI_DEFAULT_MODEL', description: 'setting a default model' }, + { key: 'AZURE_OPENAI_MODELS', description: 'setting models' }, + { + key: 'AZURE_USE_MODEL_AS_DEPLOYMENT_NAME', + description: 'using model names as deployment names', + }, + { key: 'AZURE_API_KEY', description: 'setting a single Azure API key' }, + { key: 'AZURE_OPENAI_API_INSTANCE_NAME', description: 'setting a single Azure instance name' }, + { + key: 'AZURE_OPENAI_API_DEPLOYMENT_NAME', + description: 'setting a single Azure deployment name', + }, + { key: 'AZURE_OPENAI_API_VERSION', description: 'setting a single Azure API version' }, + { + key: 'AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME', + description: 'setting a single Azure completions deployment name', + }, + { + key: 'AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME', + description: 'setting a single Azure embeddings deployment name', + }, + { + key: 'PLUGINS_USE_AZURE', + description: 'using Azure for Plugins', + }, +]; + +export const conflictingAzureVariables = [ + { + key: 'INSTANCE_NAME', + }, + { + key: 'DEPLOYMENT_NAME', + }, +]; + +/** + * Checks the password reset configuration for security issues. + */ +function checkPasswordReset() { + const emailEnabled = checkEmailConfig(); + const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET); + + if (!emailEnabled && passwordResetAllowed) { + logger.warn( + `❗❗❗ + + Password reset is enabled with \`ALLOW_PASSWORD_RESET\` but email service is not configured. + + This setup is insecure as password reset links will be issued with a recognized email. + + Please configure email service for secure password reset functionality. + + https://www.librechat.ai/docs/configuration/authentication/email + + ❗❗❗`, + ); + } +} + /** * Checks environment variables for default secrets and deprecated variables. - * Logs warnings for any default secret values being used and for usage of deprecated `GOOGLE_API_KEY`. + * Logs warnings for any default secret values being used and for usage of deprecated variables. * Advises on replacing default secrets and updating deprecated variables. + * @param {Object} options + * @param {Function} options.isEnabled - Function to check if a feature is enabled + * @param {Function} options.checkEmailConfig - Function to check email configuration */ -function checkVariables() { +export function checkVariables() { let hasDefaultSecrets = false; for (const [key, value] of Object.entries(secretDefaults)) { if (process.env[key] === value) { logger.warn(`Default value for ${key} is being used.`); - !hasDefaultSecrets && (hasDefaultSecrets = true); + if (!hasDefaultSecrets) { + hasDefaultSecrets = true; + } } } @@ -69,7 +134,7 @@ function checkVariables() { * Checks the health of auxiliary API's by attempting a fetch request to their respective `/health` endpoints. * Logs information or warning based on the API's availability and response. */ -async function checkHealth() { +export async function checkHealth() { try { const response = await fetch(`${process.env.RAG_API_URL}/health`); if (response?.ok && response?.status === 200) { @@ -104,11 +169,85 @@ function checkAzureVariables() { }); } +export function checkInterfaceConfig(appConfig: AppConfig) { + const interfaceConfig = appConfig.interfaceConfig; + let i = 0; + const logSettings = () => { + // log interface object and model specs object (without list) for reference + logger.warn(`\`interface\` settings:\n${JSON.stringify(interfaceConfig, null, 2)}`); + logger.warn( + `\`modelSpecs\` settings:\n${JSON.stringify( + { ...(appConfig?.modelSpecs ?? {}), list: undefined }, + null, + 2, + )}`, + ); + }; + + // warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs. + if (appConfig?.modelSpecs?.prioritize && interfaceConfig?.presets) { + logger.warn( + "Note: Prioritizing model specs can conflict with default presets if a default preset is set. It's recommended to disable presets from the interface or disable use of a default preset.", + ); + if (i === 0) i++; + } + + // warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options. + if ( + appConfig?.modelSpecs?.enforce && + (interfaceConfig?.endpointsMenu || + interfaceConfig?.modelSelect || + interfaceConfig?.presets || + interfaceConfig?.parameters) + ) { + logger.warn( + "Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.", + ); + if (i === 0) i++; + } + // warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior. + if (appConfig?.modelSpecs?.enforce && !appConfig?.modelSpecs?.prioritize) { + logger.warn( + "Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It's recommended to enable prioritizing model specs if enforcing them.", + ); + if (i === 0) i++; + } + + if (i > 0) { + logSettings(); + } +} + +/** + * Performs startup checks including environment variable validation and health checks. + * This should be called during application startup before initializing services. + * @param [appConfig] - The application configuration object. + */ +export async function performStartupChecks(appConfig?: AppConfig) { + checkVariables(); + if (appConfig?.endpoints?.azureOpenAI) { + checkAzureVariables(); + } + if (appConfig) { + checkInterfaceConfig(appConfig); + } + if (appConfig?.config) { + checkConfig(appConfig.config); + } + if (appConfig?.config?.webSearch) { + checkWebSearchConfig(appConfig.config.webSearch); + } + if (appConfig?.config?.rateLimits) { + handleRateLimits(appConfig.config.rateLimits); + } + await checkHealth(); +} + /** * Performs basic checks on the loaded config object. - * @param {TCustomConfig} config - The loaded custom configuration. + * @param config - The loaded custom configuration. */ -function checkConfig(config) { +export function checkConfig(config: Partial) { if (config.version !== Constants.CONFIG_VERSION) { logger.info( `\nOutdated Config version: ${config.version} @@ -121,40 +260,19 @@ Latest version: ${Constants.CONFIG_VERSION} } } -function checkPasswordReset() { - const emailEnabled = checkEmailConfig(); - const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET); - - if (!emailEnabled && passwordResetAllowed) { - logger.warn( - `❗❗❗ - - Password reset is enabled with \`ALLOW_PASSWORD_RESET\` but email service is not configured. - - This setup is insecure as password reset links will be issued with a recognized email. - - Please configure email service for secure password reset functionality. - - https://www.librechat.ai/docs/configuration/authentication/email - - ❗❗❗`, - ); - } -} - /** * Checks web search configuration values to ensure they are environment variable references. * Warns if actual API keys or URLs are used instead of environment variable references. * Logs debug information for properly configured environment variable references. - * @param {Object} webSearchConfig - The loaded web search configuration object. + * @param webSearchConfig - The loaded web search configuration object. */ -function checkWebSearchConfig(webSearchConfig) { +export function checkWebSearchConfig(webSearchConfig?: Partial | null) { if (!webSearchConfig) { return; } webSearchKeys.forEach((key) => { - const value = webSearchConfig[key]; + const value = webSearchConfig[key as keyof typeof webSearchConfig]; if (typeof value === 'string') { const varName = extractVariableName(value); @@ -187,11 +305,3 @@ function checkWebSearchConfig(webSearchConfig) { } }); } - -module.exports = { - checkHealth, - checkConfig, - checkVariables, - checkAzureVariables, - checkWebSearchConfig, -}; diff --git a/packages/api/src/app/config.test.ts b/packages/api/src/app/config.test.ts index 82f8b3e8cd..d55f9977fe 100644 --- a/packages/api/src/app/config.test.ts +++ b/packages/api/src/app/config.test.ts @@ -1,8 +1,8 @@ import { getTransactionsConfig, getBalanceConfig } from './config'; import { logger } from '@librechat/data-schemas'; import { FileSources } from 'librechat-data-provider'; -import type { AppConfig } from '~/types'; import type { TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; // Helper function to create a minimal AppConfig for testing const createTestAppConfig = (overrides: Partial = {}): AppConfig => { diff --git a/packages/api/src/app/config.ts b/packages/api/src/app/config.ts index 8bc9c9af26..8a2a681e65 100644 --- a/packages/api/src/app/config.ts +++ b/packages/api/src/app/config.ts @@ -1,8 +1,8 @@ +import { logger } from '@librechat/data-schemas'; import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider'; import type { TCustomConfig, TEndpoint, TTransactionsConfig } from 'librechat-data-provider'; -import type { AppConfig } from '~/types'; +import type { AppConfig } from '@librechat/data-schemas'; import { isEnabled, normalizeEndpointName } from '~/utils'; -import { logger } from '@librechat/data-schemas'; /** * Retrieves the balance configuration object @@ -24,7 +24,7 @@ export function getBalanceConfig(appConfig?: AppConfig): Partial { const defaultConfig: TTransactionsConfig = { enabled: true }; if (!appConfig) { @@ -66,5 +66,5 @@ export const getCustomEndpointConfig = ({ export function hasCustomUserVars(appConfig?: AppConfig): boolean { const mcpServers = appConfig?.mcpConfig; - return Object.values(mcpServers ?? {}).some((server) => server.customUserVars); + return Object.values(mcpServers ?? {}).some((server) => server?.customUserVars); } diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts index 74ae27abf3..b95193e943 100644 --- a/packages/api/src/app/index.ts +++ b/packages/api/src/app/index.ts @@ -1,3 +1,4 @@ export * from './config'; -export * from './interface'; export * from './permissions'; +export * from './cdn'; +export * from './checks'; diff --git a/packages/api/src/app/limits.ts b/packages/api/src/app/limits.ts new file mode 100644 index 0000000000..a38a3c6310 --- /dev/null +++ b/packages/api/src/app/limits.ts @@ -0,0 +1,55 @@ +import { RateLimitPrefix } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; + +/** + * + * @param rateLimits + */ +export const handleRateLimits = (rateLimits?: TCustomConfig['rateLimits']) => { + if (!rateLimits) { + return; + } + + const rateLimitKeys = { + fileUploads: RateLimitPrefix.FILE_UPLOAD, + conversationsImport: RateLimitPrefix.IMPORT, + tts: RateLimitPrefix.TTS, + stt: RateLimitPrefix.STT, + }; + + Object.entries(rateLimitKeys).forEach(([key, prefix]) => { + const rateLimit = rateLimits[key as keyof typeof rateLimitKeys]; + if (rateLimit) { + setRateLimitEnvVars(prefix, rateLimit); + } + }); +}; + +type RateLimitConfig = { + ipMax?: number | undefined; + ipWindowInMinutes?: number | undefined; + userMax?: number | undefined; + userWindowInMinutes?: number | undefined; +}; + +/** + * Set environment variables for rate limit configurations + * + * @param prefix - Prefix for environment variable names + * @param rateLimit - Rate limit configuration object + */ +const setRateLimitEnvVars = (prefix: string, rateLimit: RateLimitConfig) => { + const envVarsMapping = { + ipMax: `${prefix}_IP_MAX`, + ipWindowInMinutes: `${prefix}_IP_WINDOW`, + userMax: `${prefix}_USER_MAX`, + userWindowInMinutes: `${prefix}_USER_WINDOW`, + }; + + Object.entries(envVarsMapping).forEach(([key, envVar]) => { + const value = rateLimit[key as keyof RateLimitConfig]; + if (value !== undefined) { + process.env[envVar] = value.toString(); + } + }); +}; diff --git a/packages/api/src/app/permissions.spec.ts b/packages/api/src/app/permissions.spec.ts index e3816d4013..6eeb70d7d7 100644 --- a/packages/api/src/app/permissions.spec.ts +++ b/packages/api/src/app/permissions.spec.ts @@ -1,8 +1,8 @@ +import { loadDefaultInterface } from '@librechat/data-schemas'; import { SystemRoles, Permissions, PermissionTypes, roleDefaults } from 'librechat-data-provider'; import type { TConfigDefaults, TCustomConfig } from 'librechat-data-provider'; -import type { AppConfig } from '~/types/config'; +import type { AppConfig } from '@librechat/data-schemas'; import { updateInterfacePermissions } from './permissions'; -import { loadDefaultInterface } from './interface'; const mockUpdateAccessPermissions = jest.fn(); const mockGetRoleByName = jest.fn(); diff --git a/packages/api/src/app/permissions.ts b/packages/api/src/app/permissions.ts index a039a1503e..eaaa5c9705 100644 --- a/packages/api/src/app/permissions.ts +++ b/packages/api/src/app/permissions.ts @@ -6,8 +6,7 @@ import { PermissionTypes, getConfigDefaults, } from 'librechat-data-provider'; -import type { IRole } from '@librechat/data-schemas'; -import type { AppConfig } from '~/types/config'; +import type { IRole, AppConfig } from '@librechat/data-schemas'; import { isMemoryEnabled } from '~/memory/config'; /** diff --git a/api/server/services/Files/Azure/initialize.js b/packages/api/src/cdn/azure.ts similarity index 63% rename from api/server/services/Files/Azure/initialize.js rename to packages/api/src/cdn/azure.ts index 8c63b48122..247530286c 100644 --- a/api/server/services/Files/Azure/initialize.js +++ b/packages/api/src/cdn/azure.ts @@ -1,7 +1,8 @@ -const { logger } = require('@librechat/data-schemas'); -const { BlobServiceClient } = require('@azure/storage-blob'); +import { logger } from '@librechat/data-schemas'; +import { DefaultAzureCredential } from '@azure/identity'; +import type { ContainerClient, BlobServiceClient } from '@azure/storage-blob'; -let blobServiceClient = null; +let blobServiceClient: BlobServiceClient | null = null; let azureWarningLogged = false; /** @@ -9,18 +10,18 @@ let azureWarningLogged = false; * This function establishes a connection by checking if a connection string is provided. * If available, the connection string is used; otherwise, Managed Identity (via DefaultAzureCredential) is utilized. * Note: Container creation (and its public access settings) is handled later in the CRUD functions. - * @returns {BlobServiceClient|null} The initialized client, or null if the required configuration is missing. + * @returns The initialized client, or null if the required configuration is missing. */ -const initializeAzureBlobService = () => { +export const initializeAzureBlobService = async (): Promise => { if (blobServiceClient) { return blobServiceClient; } const connectionString = process.env.AZURE_STORAGE_CONNECTION_STRING; if (connectionString) { + const { BlobServiceClient } = await import('@azure/storage-blob'); blobServiceClient = BlobServiceClient.fromConnectionString(connectionString); logger.info('Azure Blob Service initialized using connection string'); } else { - const { DefaultAzureCredential } = require('@azure/identity'); const accountName = process.env.AZURE_STORAGE_ACCOUNT_NAME; if (!accountName) { if (!azureWarningLogged) { @@ -33,6 +34,7 @@ const initializeAzureBlobService = () => { } const url = `https://${accountName}.blob.core.windows.net`; const credential = new DefaultAzureCredential(); + const { BlobServiceClient } = await import('@azure/storage-blob'); blobServiceClient = new BlobServiceClient(url, credential); logger.info('Azure Blob Service initialized using Managed Identity'); } @@ -41,15 +43,12 @@ const initializeAzureBlobService = () => { /** * Retrieves the Azure ContainerClient for the given container name. - * @param {string} [containerName=process.env.AZURE_CONTAINER_NAME || 'files'] - The container name. - * @returns {ContainerClient|null} The Azure ContainerClient. + * @param [containerName=process.env.AZURE_CONTAINER_NAME || 'files'] - The container name. + * @returns The Azure ContainerClient. */ -const getAzureContainerClient = (containerName = process.env.AZURE_CONTAINER_NAME || 'files') => { - const serviceClient = initializeAzureBlobService(); +export const getAzureContainerClient = async ( + containerName = process.env.AZURE_CONTAINER_NAME || 'files', +): Promise => { + const serviceClient = await initializeAzureBlobService(); return serviceClient ? serviceClient.getContainerClient(containerName) : null; }; - -module.exports = { - initializeAzureBlobService, - getAzureContainerClient, -}; diff --git a/packages/api/src/cdn/firebase.ts b/packages/api/src/cdn/firebase.ts new file mode 100644 index 0000000000..30fca65b1b --- /dev/null +++ b/packages/api/src/cdn/firebase.ts @@ -0,0 +1,42 @@ +import firebase from 'firebase/app'; +import { getStorage } from 'firebase/storage'; +import { logger } from '@librechat/data-schemas'; +import type { FirebaseStorage } from 'firebase/storage'; +import type { FirebaseApp } from 'firebase/app'; + +let firebaseInitCount = 0; +let firebaseApp: FirebaseApp | null = null; + +export const initializeFirebase = () => { + if (firebaseApp) { + return firebaseApp; + } + + const firebaseConfig = { + apiKey: process.env.FIREBASE_API_KEY, + authDomain: process.env.FIREBASE_AUTH_DOMAIN, + projectId: process.env.FIREBASE_PROJECT_ID, + storageBucket: process.env.FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.FIREBASE_APP_ID, + }; + + if (Object.values(firebaseConfig).some((value) => !value)) { + if (firebaseInitCount === 0) { + logger.info( + '[Optional] Firebase CDN not initialized. To enable, set FIREBASE_API_KEY, FIREBASE_AUTH_DOMAIN, FIREBASE_PROJECT_ID, FIREBASE_STORAGE_BUCKET, FIREBASE_MESSAGING_SENDER_ID, and FIREBASE_APP_ID environment variables.', + ); + } + firebaseInitCount++; + return null; + } + + firebaseApp = firebase.initializeApp(firebaseConfig); + logger.info('Firebase CDN initialized'); + return firebaseApp; +}; + +export const getFirebaseStorage = (): FirebaseStorage | null => { + const app = initializeFirebase(); + return app ? getStorage(app) : null; +}; diff --git a/packages/api/src/cdn/index.ts b/packages/api/src/cdn/index.ts new file mode 100644 index 0000000000..04be850216 --- /dev/null +++ b/packages/api/src/cdn/index.ts @@ -0,0 +1,3 @@ +export * from './azure'; +export * from './firebase'; +export * from './s3'; diff --git a/api/server/services/Files/S3/initialize.js b/packages/api/src/cdn/s3.ts similarity index 82% rename from api/server/services/Files/S3/initialize.js rename to packages/api/src/cdn/s3.ts index 59a2568b47..683a7887fa 100644 --- a/api/server/services/Files/S3/initialize.js +++ b/packages/api/src/cdn/s3.ts @@ -1,7 +1,7 @@ -const { S3Client } = require('@aws-sdk/client-s3'); -const { logger } = require('@librechat/data-schemas'); +import { S3Client } from '@aws-sdk/client-s3'; +import { logger } from '@librechat/data-schemas'; -let s3 = null; +let s3: S3Client | null = null; /** * Initializes and returns an instance of the AWS S3 client. @@ -11,9 +11,9 @@ let s3 = null; * * If AWS_ENDPOINT_URL is provided, it will be used as the endpoint. * - * @returns {S3Client|null} An instance of S3Client if the region is provided; otherwise, null. + * @returns An instance of S3Client if the region is provided; otherwise, null. */ -const initializeS3 = () => { +export const initializeS3 = (): S3Client | null => { if (s3) { return s3; } @@ -49,5 +49,3 @@ const initializeS3 = () => { return s3; }; - -module.exports = { initializeS3 }; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index bc52c02229..e839a335a4 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,4 +1,5 @@ export * from './app'; +export * from './cdn'; /* Auth */ export * from './auth'; /* MCP */ diff --git a/packages/api/src/mcp/MCPServersRegistry.ts b/packages/api/src/mcp/MCPServersRegistry.ts index b6eaa09f01..668ad7d2c0 100644 --- a/packages/api/src/mcp/MCPServersRegistry.ts +++ b/packages/api/src/mcp/MCPServersRegistry.ts @@ -1,8 +1,8 @@ import mapValues from 'lodash/mapValues'; import { logger } from '@librechat/data-schemas'; import { Constants } from 'librechat-data-provider'; +import type { JsonSchemaType } from '@librechat/data-schemas'; import type { MCPConnection } from '~/mcp/connection'; -import type { JsonSchemaType } from '~/types'; import type * as t from '~/mcp/types'; import { ConnectionsRepository } from '~/mcp/ConnectionsRepository'; import { detectOAuthRequirement } from '~/mcp/oauth'; diff --git a/packages/api/src/mcp/__tests__/zod.spec.ts b/packages/api/src/mcp/__tests__/zod.spec.ts index bc579f0166..07e62cf5ae 100644 --- a/packages/api/src/mcp/__tests__/zod.spec.ts +++ b/packages/api/src/mcp/__tests__/zod.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // zod.spec.ts import { z } from 'zod'; -import type { JsonSchemaType } from '~/types'; +import type { JsonSchemaType } from '@librechat/data-schemas'; import { resolveJsonSchemaRefs, convertJsonSchemaToZod, convertWithResolvedRefs } from '../zod'; describe('convertJsonSchemaToZod', () => { diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 7d137afd0b..5cf003b9f5 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -10,9 +10,8 @@ import { } from 'librechat-data-provider'; import type { SearchResultData, UIResource, TPlugin, TUser } from 'librechat-data-provider'; import type * as t from '@modelcontextprotocol/sdk/types.js'; -import type { TokenMethods } from '@librechat/data-schemas'; +import type { TokenMethods, JsonSchemaType } from '@librechat/data-schemas'; import type { FlowStateManager } from '~/flow/manager'; -import type { JsonSchemaType } from '~/types/zod'; import type { RequestBody } from '~/types/http'; import type * as o from '~/mcp/oauth/types'; diff --git a/packages/api/src/mcp/zod.ts b/packages/api/src/mcp/zod.ts index 305765dfa6..ea9d17c0b2 100644 --- a/packages/api/src/mcp/zod.ts +++ b/packages/api/src/mcp/zod.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { JsonSchemaType, ConvertJsonSchemaToZodOptions } from '~/types'; +import type { JsonSchemaType, ConvertJsonSchemaToZodOptions } from '@librechat/data-schemas'; function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean { return ( diff --git a/packages/api/src/middleware/balance.ts b/packages/api/src/middleware/balance.ts index f51666a155..e3eb1e7ae1 100644 --- a/packages/api/src/middleware/balance.ts +++ b/packages/api/src/middleware/balance.ts @@ -1,8 +1,8 @@ import { logger } from '@librechat/data-schemas'; import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express'; -import type { IBalance, IUser, BalanceConfig, ObjectId } from '@librechat/data-schemas'; +import type { IBalance, IUser, BalanceConfig, ObjectId, AppConfig } from '@librechat/data-schemas'; import type { Model } from 'mongoose'; -import type { AppConfig, BalanceUpdateFields } from '~/types'; +import type { BalanceUpdateFields } from '~/types'; import { getBalanceConfig } from '~/app/config'; export interface BalanceMiddlewareOptions { diff --git a/packages/api/src/types/http.ts b/packages/api/src/types/http.ts index 585797fe9c..f57e4674f9 100644 --- a/packages/api/src/types/http.ts +++ b/packages/api/src/types/http.ts @@ -1,6 +1,5 @@ import type { Request } from 'express'; -import type { IUser } from '@librechat/data-schemas'; -import type { AppConfig } from './config'; +import type { IUser, AppConfig } from '@librechat/data-schemas'; /** * LibreChat-specific request body type that extends Express Request body diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts index 5603c09a56..92a46bb064 100644 --- a/packages/api/src/types/index.ts +++ b/packages/api/src/types/index.ts @@ -1,4 +1,3 @@ -export * from './config'; export * from './azure'; export * from './balance'; export * from './endpoints'; @@ -11,6 +10,4 @@ export * from './mistral'; export * from './openai'; export * from './prompts'; export * from './run'; -export * from './tools'; -export * from './zod'; export * from './anthropic'; diff --git a/packages/api/src/types/openai.ts b/packages/api/src/types/openai.ts index 3389314937..d0bbffe194 100644 --- a/packages/api/src/types/openai.ts +++ b/packages/api/src/types/openai.ts @@ -3,8 +3,8 @@ import { openAISchema, EModelEndpoint } from 'librechat-data-provider'; import type { TEndpointOption, TAzureConfig, TEndpoint, TConfig } from 'librechat-data-provider'; import type { BindToolsInput } from '@langchain/core/language_models/chat_models'; import type { OpenAIClientOptions, Providers } from '@librechat/agents'; +import type { AppConfig } from '@librechat/data-schemas'; import type { AzureOptions } from './azure'; -import type { AppConfig } from './config'; export type OpenAIParameters = z.infer; diff --git a/packages/api/src/types/tools.ts b/packages/api/src/types/tools.ts deleted file mode 100644 index 591c10da8a..0000000000 --- a/packages/api/src/types/tools.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { JsonSchemaType } from './zod'; - -export interface FunctionTool { - type: 'function'; - function: { - description: string; - name: string; - parameters: JsonSchemaType; - }; -} diff --git a/packages/api/src/types/zod.ts b/packages/api/src/types/zod.ts deleted file mode 100644 index 75d1a0b4e6..0000000000 --- a/packages/api/src/types/zod.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type JsonSchemaType = { - type: 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'array' | 'object'; - enum?: string[]; - items?: JsonSchemaType; - properties?: Record; - required?: string[]; - description?: string; - additionalProperties?: boolean | JsonSchemaType; -}; - -export type ConvertJsonSchemaToZodOptions = { - allowEmptyObject?: boolean; - dropFields?: string[]; - transformOneOfAnyOf?: boolean; -}; diff --git a/packages/api/src/utils/tempChatRetention.spec.ts b/packages/api/src/utils/tempChatRetention.spec.ts index 847088ab7c..ef029cdde5 100644 --- a/packages/api/src/utils/tempChatRetention.spec.ts +++ b/packages/api/src/utils/tempChatRetention.spec.ts @@ -1,4 +1,4 @@ -import type { AppConfig } from '~/types'; +import type { AppConfig } from '@librechat/data-schemas'; import { createTempChatExpirationDate, getTempChatRetentionHours, diff --git a/packages/api/src/utils/tempChatRetention.ts b/packages/api/src/utils/tempChatRetention.ts index 3505a51a1b..eaa6ad2029 100644 --- a/packages/api/src/utils/tempChatRetention.ts +++ b/packages/api/src/utils/tempChatRetention.ts @@ -1,5 +1,5 @@ import { logger } from '@librechat/data-schemas'; -import type { AppConfig } from '~/types'; +import type { AppConfig } from '@librechat/data-schemas'; /** * Default retention period for temporary chats in hours diff --git a/packages/api/src/web/web.spec.ts b/packages/api/src/web/web.spec.ts index b91b534908..e0c66047c1 100644 --- a/packages/api/src/web/web.spec.ts +++ b/packages/api/src/web/web.spec.ts @@ -1,3 +1,5 @@ +import { webSearchAuth } from '@librechat/data-schemas'; +import { SafeSearchTypes, AuthType } from 'librechat-data-provider'; import type { ScraperTypes, TCustomConfig, @@ -5,8 +7,7 @@ import type { SearchProviders, TWebSearchConfig, } from 'librechat-data-provider'; -import { webSearchAuth, loadWebSearchAuth, extractWebSearchEnvVars } from './web'; -import { SafeSearchTypes, AuthType } from 'librechat-data-provider'; +import { loadWebSearchAuth, extractWebSearchEnvVars } from './web'; // Mock the extractVariableName function jest.mock('../utils', () => ({ diff --git a/packages/api/src/web/web.ts b/packages/api/src/web/web.ts index 681a42e34b..da34114bc5 100644 --- a/packages/api/src/web/web.ts +++ b/packages/api/src/web/web.ts @@ -1,3 +1,9 @@ +import { + AuthType, + SafeSearchTypes, + SearchCategories, + extractVariableName, +} from 'librechat-data-provider'; import type { ScraperTypes, RerankerTypes, @@ -5,108 +11,8 @@ import type { SearchProviders, TWebSearchConfig, } from 'librechat-data-provider'; -import { - SearchCategories, - SafeSearchTypes, - extractVariableName, - AuthType, -} from 'librechat-data-provider'; - -export function loadWebSearchConfig( - config: TCustomConfig['webSearch'], -): TCustomConfig['webSearch'] { - const serperApiKey = config?.serperApiKey ?? '${SERPER_API_KEY}'; - const searxngInstanceUrl = config?.searxngInstanceUrl ?? '${SEARXNG_INSTANCE_URL}'; - const searxngApiKey = config?.searxngApiKey ?? '${SEARXNG_API_KEY}'; - const firecrawlApiKey = config?.firecrawlApiKey ?? '${FIRECRAWL_API_KEY}'; - const firecrawlApiUrl = config?.firecrawlApiUrl ?? '${FIRECRAWL_API_URL}'; - const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}'; - const jinaApiUrl = config?.jinaApiUrl ?? '${JINA_API_URL}'; - const cohereApiKey = config?.cohereApiKey ?? '${COHERE_API_KEY}'; - const safeSearch = config?.safeSearch ?? SafeSearchTypes.MODERATE; - - return { - ...config, - safeSearch, - jinaApiKey, - jinaApiUrl, - cohereApiKey, - serperApiKey, - searxngInstanceUrl, - searxngApiKey, - firecrawlApiKey, - firecrawlApiUrl, - }; -} - -export type TWebSearchKeys = - | 'serperApiKey' - | 'searxngInstanceUrl' - | 'searxngApiKey' - | 'firecrawlApiKey' - | 'firecrawlApiUrl' - | 'jinaApiKey' - | 'jinaApiUrl' - | 'cohereApiKey'; - -export type TWebSearchCategories = - | SearchCategories.PROVIDERS - | SearchCategories.SCRAPERS - | SearchCategories.RERANKERS; - -export const webSearchAuth = { - providers: { - serper: { - serperApiKey: 1 as const, - }, - searxng: { - searxngInstanceUrl: 1 as const, - /** Optional (0) */ - searxngApiKey: 0 as const, - }, - }, - scrapers: { - firecrawl: { - firecrawlApiKey: 1 as const, - /** Optional (0) */ - firecrawlApiUrl: 0 as const, - }, - }, - rerankers: { - jina: { - jinaApiKey: 1 as const, - /** Optional (0) */ - jinaApiUrl: 0 as const, - }, - cohere: { cohereApiKey: 1 as const }, - }, -}; - -/** - * Extracts all API keys from the webSearchAuth configuration object - */ -export function getWebSearchKeys(): TWebSearchKeys[] { - const keys: TWebSearchKeys[] = []; - - // Iterate through each category (providers, scrapers, rerankers) - for (const category of Object.keys(webSearchAuth)) { - const categoryObj = webSearchAuth[category as TWebSearchCategories]; - - // Iterate through each service within the category - for (const service of Object.keys(categoryObj)) { - const serviceObj = categoryObj[service as keyof typeof categoryObj]; - - // Extract the API keys from the service - for (const key of Object.keys(serviceObj)) { - keys.push(key as TWebSearchKeys); - } - } - } - - return keys; -} - -export const webSearchKeys: TWebSearchKeys[] = getWebSearchKeys(); +import { webSearchAuth } from '@librechat/data-schemas'; +import type { TWebSearchKeys, TWebSearchCategories } from '@librechat/data-schemas'; export function extractWebSearchEnvVars({ keys, diff --git a/packages/data-provider/src/azure.ts b/packages/data-provider/src/azure.ts index 17188ec551..99d7032177 100644 --- a/packages/data-provider/src/azure.ts +++ b/packages/data-provider/src/azure.ts @@ -10,44 +10,6 @@ import { extractEnvVariable, envVarRegex } from '../src/utils'; import { azureGroupConfigsSchema } from '../src/config'; import { errorsToString } from '../src/parsers'; -export const deprecatedAzureVariables = [ - /* "related to" precedes description text */ - { key: 'AZURE_OPENAI_DEFAULT_MODEL', description: 'setting a default model' }, - { key: 'AZURE_OPENAI_MODELS', description: 'setting models' }, - { - key: 'AZURE_USE_MODEL_AS_DEPLOYMENT_NAME', - description: 'using model names as deployment names', - }, - { key: 'AZURE_API_KEY', description: 'setting a single Azure API key' }, - { key: 'AZURE_OPENAI_API_INSTANCE_NAME', description: 'setting a single Azure instance name' }, - { - key: 'AZURE_OPENAI_API_DEPLOYMENT_NAME', - description: 'setting a single Azure deployment name', - }, - { key: 'AZURE_OPENAI_API_VERSION', description: 'setting a single Azure API version' }, - { - key: 'AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME', - description: 'setting a single Azure completions deployment name', - }, - { - key: 'AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME', - description: 'setting a single Azure embeddings deployment name', - }, - { - key: 'PLUGINS_USE_AZURE', - description: 'using Azure for Plugins', - }, -]; - -export const conflictingAzureVariables = [ - { - key: 'INSTANCE_NAME', - }, - { - key: 'DEPLOYMENT_NAME', - }, -]; - export function validateAzureGroups(configs: TAzureGroups): TAzureConfigValidationResult { let isValid = true; const modelNames: string[] = []; @@ -239,13 +201,13 @@ export function mapModelToAzureConfig({ const { deploymentName = '', version = '' } = typeof modelDetails === 'object' ? { - deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, - version: modelDetails.version ?? groupConfig.version, - } + deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, + version: modelDetails.version ?? groupConfig.version, + } : { - deploymentName: groupConfig.deploymentName, - version: groupConfig.version, - }; + deploymentName: groupConfig.deploymentName, + version: groupConfig.version, + }; if (!deploymentName || !version) { throw new Error( @@ -335,13 +297,13 @@ export function mapGroupToAzureConfig({ const { deploymentName = '', version = '' } = typeof modelDetails === 'object' ? { - deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, - version: modelDetails.version ?? groupConfig.version, - } + deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, + version: modelDetails.version ?? groupConfig.version, + } : { - deploymentName: groupConfig.deploymentName, - version: groupConfig.version, - }; + deploymentName: groupConfig.deploymentName, + version: groupConfig.version, + }; if (!deploymentName || !version) { throw new Error( diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 1747cd7511..4e318dc709 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -155,8 +155,10 @@ export type TAzureGroupMap = Record< export type TValidatedAzureConfig = { modelNames: string[]; - modelGroupMap: TAzureModelGroupMap; groupMap: TAzureGroupMap; + assistantModels?: string[]; + assistantGroups?: string[]; + modelGroupMap: TAzureModelGroupMap; }; export type TAzureConfigValidationResult = TValidatedAzureConfig & { @@ -752,7 +754,7 @@ export const webSearchSchema = z.object({ .optional(), }); -export type TWebSearchConfig = z.infer; +export type TWebSearchConfig = DeepPartial>; export const ocrSchema = z.object({ mistralModel: z.string().optional(), @@ -799,7 +801,7 @@ export const memorySchema = z.object({ .optional(), }); -export type TMemoryConfig = z.infer; +export type TMemoryConfig = DeepPartial>; const customEndpointsSchema = z.array(endpointSchema.partial()).optional(); @@ -862,9 +864,27 @@ export const configSchema = z.object({ .optional(), }); -export const getConfigDefaults = () => getSchemaDefaults(configSchema); +/** + * Recursively makes all properties of T optional, including nested objects. + * Handles arrays, primitives, functions, and Date objects correctly. + */ +export type DeepPartial = T extends (infer U)[] + ? DeepPartial[] + : T extends ReadonlyArray + ? ReadonlyArray> + : // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + T extends Function + ? T + : T extends Date + ? T + : T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; -export type TCustomConfig = z.infer; +export const getConfigDefaults = () => getSchemaDefaults(configSchema); +export type TCustomConfig = DeepPartial>; export type TCustomEndpoints = z.infer; export type TProviderSchema = diff --git a/packages/api/src/agents/config.ts b/packages/data-schemas/src/app/agents.ts similarity index 91% rename from packages/api/src/agents/config.ts rename to packages/data-schemas/src/app/agents.ts index 36b1f600eb..c75546f588 100644 --- a/packages/api/src/agents/config.ts +++ b/packages/data-schemas/src/app/agents.ts @@ -10,8 +10,8 @@ import type { TCustomConfig, TAgentsEndpoint } from 'librechat-data-provider'; * @returns The Agents endpoint configuration. */ export function agentsConfigSetup( - config: TCustomConfig, - defaultConfig: Partial, + config: Partial, + defaultConfig?: Partial, ): Partial { const agentsConfig = config?.endpoints?.[EModelEndpoint.agents]; diff --git a/api/server/services/start/assistants.js b/packages/data-schemas/src/app/assistants.ts similarity index 64% rename from api/server/services/start/assistants.js rename to packages/data-schemas/src/app/assistants.ts index febc170a95..c41a8d603e 100644 --- a/api/server/services/start/assistants.js +++ b/packages/data-schemas/src/app/assistants.ts @@ -1,15 +1,20 @@ -const { logger } = require('@librechat/data-schemas'); -const { +import logger from '~/config/winston'; +import { Capabilities, + EModelEndpoint, assistantEndpointSchema, defaultAssistantsVersion, -} = require('librechat-data-provider'); +} from 'librechat-data-provider'; +import type { TCustomConfig, TAssistantEndpoint } from 'librechat-data-provider'; /** * Sets up the minimum, default Assistants configuration if Azure OpenAI Assistants option is enabled. - * @returns {Partial} The Assistants endpoint configuration. + * @returns The Assistants endpoint configuration. */ -function azureAssistantsDefaults() { +export function azureAssistantsDefaults(): { + capabilities: TAssistantEndpoint['capabilities']; + version: TAssistantEndpoint['version']; +} { return { capabilities: [Capabilities.tools, Capabilities.actions, Capabilities.code_interpreter], version: defaultAssistantsVersion.azureAssistants, @@ -18,22 +23,26 @@ function azureAssistantsDefaults() { /** * Sets up the Assistants configuration from the config (`librechat.yaml`) file. - * @param {TCustomConfig} config - The loaded custom configuration. - * @param {EModelEndpoint.assistants|EModelEndpoint.azureAssistants} assistantsEndpoint - The Assistants endpoint name. + * @param config - The loaded custom configuration. + * @param assistantsEndpoint - The Assistants endpoint name. * - The previously loaded assistants configuration from Azure OpenAI Assistants option. - * @param {Partial} [prevConfig] - * @returns {Partial} The Assistants endpoint configuration. + * @param [prevConfig] + * @returns The Assistants endpoint configuration. */ -function assistantsConfigSetup(config, assistantsEndpoint, prevConfig = {}) { - const assistantsConfig = config.endpoints[assistantsEndpoint]; +export function assistantsConfigSetup( + config: Partial, + assistantsEndpoint: EModelEndpoint.assistants | EModelEndpoint.azureAssistants, + prevConfig: Partial = {}, +): Partial { + const assistantsConfig = config.endpoints?.[assistantsEndpoint]; const parsedConfig = assistantEndpointSchema.parse(assistantsConfig); - if (assistantsConfig.supportedIds?.length && assistantsConfig.excludedIds?.length) { + if (assistantsConfig?.supportedIds?.length && assistantsConfig.excludedIds?.length) { logger.warn( `Configuration conflict: The '${assistantsEndpoint}' endpoint has both 'supportedIds' and 'excludedIds' defined. The 'excludedIds' will be ignored.`, ); } if ( - assistantsConfig.privateAssistants && + assistantsConfig?.privateAssistants && (assistantsConfig.supportedIds?.length || assistantsConfig.excludedIds?.length) ) { logger.warn( @@ -59,5 +68,3 @@ function assistantsConfigSetup(config, assistantsEndpoint, prevConfig = {}) { titlePromptTemplate: parsedConfig.titlePromptTemplate, }; } - -module.exports = { azureAssistantsDefaults, assistantsConfigSetup }; diff --git a/api/server/services/start/azureOpenAI.js b/packages/data-schemas/src/app/azure.ts similarity index 65% rename from api/server/services/start/azureOpenAI.js rename to packages/data-schemas/src/app/azure.ts index 1598b28ba9..35d855ba43 100644 --- a/api/server/services/start/azureOpenAI.js +++ b/packages/data-schemas/src/app/azure.ts @@ -1,18 +1,22 @@ -const { logger } = require('@librechat/data-schemas'); -const { +import logger from '~/config/winston'; +import { EModelEndpoint, validateAzureGroups, mapModelToAzureConfig, -} = require('librechat-data-provider'); +} from 'librechat-data-provider'; +import type { TCustomConfig, TAzureConfig } from 'librechat-data-provider'; /** * Sets up the Azure OpenAI configuration from the config (`librechat.yaml`) file. - * @param {TCustomConfig} config - The loaded custom configuration. - * @returns {TAzureConfig} The Azure OpenAI configuration. + * @param config - The loaded custom configuration. + * @returns The Azure OpenAI configuration. */ -function azureConfigSetup(config) { - const { groups, ...azureConfiguration } = config.endpoints[EModelEndpoint.azureOpenAI]; - /** @type {TAzureConfigValidationResult} */ +export function azureConfigSetup(config: Partial): TAzureConfig { + const azureConfig = config.endpoints?.[EModelEndpoint.azureOpenAI]; + if (!azureConfig) { + throw new Error('Azure OpenAI configuration is missing.'); + } + const { groups, ...azureConfiguration } = azureConfig; const { isValid, modelNames, modelGroupMap, groupMap, errors } = validateAzureGroups(groups); if (!isValid) { @@ -22,16 +26,18 @@ function azureConfigSetup(config) { throw new Error(errorMessage); } - const assistantModels = []; - const assistantGroups = new Set(); + const assistantModels: string[] = []; + const assistantGroups = new Set(); for (const modelName of modelNames) { mapModelToAzureConfig({ modelName, modelGroupMap, groupMap }); const groupName = modelGroupMap?.[modelName]?.group; const modelGroup = groupMap?.[groupName]; - let supportsAssistants = modelGroup?.assistants || modelGroup?.[modelName]?.assistants; + const supportsAssistants = modelGroup?.assistants || modelGroup?.[modelName]?.assistants; if (supportsAssistants) { assistantModels.push(modelName); - !assistantGroups.has(groupName) && assistantGroups.add(groupName); + if (!assistantGroups.has(groupName)) { + assistantGroups.add(groupName); + } } } @@ -53,13 +59,13 @@ function azureConfigSetup(config) { } return { + errors, + isValid, + groupMap, modelNames, modelGroupMap, - groupMap, assistantModels, assistantGroups: Array.from(assistantGroups), ...azureConfiguration, }; } - -module.exports = { azureConfigSetup }; diff --git a/api/server/services/start/endpoints.js b/packages/data-schemas/src/app/endpoints.ts similarity index 63% rename from api/server/services/start/endpoints.js rename to packages/data-schemas/src/app/endpoints.ts index 3e9bd0df82..b4bdf2985b 100644 --- a/api/server/services/start/endpoints.js +++ b/packages/data-schemas/src/app/endpoints.ts @@ -1,22 +1,24 @@ -const { agentsConfigSetup } = require('@librechat/api'); -const { EModelEndpoint } = require('librechat-data-provider'); -const { azureAssistantsDefaults, assistantsConfigSetup } = require('./assistants'); -const { azureConfigSetup } = require('./azureOpenAI'); -const { checkAzureVariables } = require('./checks'); +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TCustomConfig, TAgentsEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '~/types'; +import { azureAssistantsDefaults, assistantsConfigSetup } from './assistants'; +import { agentsConfigSetup } from './agents'; +import { azureConfigSetup } from './azure'; /** * Loads custom config endpoints - * @param {TCustomConfig} [config] - * @param {TCustomConfig['endpoints']['agents']} [agentsDefaults] + * @param [config] + * @param [agentsDefaults] */ -const loadEndpoints = (config, agentsDefaults) => { - /** @type {AppConfig['endpoints']} */ - const loadedEndpoints = {}; +export const loadEndpoints = ( + config: Partial, + agentsDefaults?: Partial, +) => { + const loadedEndpoints: AppConfig['endpoints'] = {}; const endpoints = config?.endpoints; if (endpoints?.[EModelEndpoint.azureOpenAI]) { loadedEndpoints[EModelEndpoint.azureOpenAI] = azureConfigSetup(config); - checkAzureVariables(); } if (endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) { @@ -50,8 +52,9 @@ const loadEndpoints = (config, agentsDefaults) => { ]; endpointKeys.forEach((key) => { - if (endpoints?.[key]) { - loadedEndpoints[key] = endpoints[key]; + const currentKey = key as keyof typeof endpoints; + if (endpoints?.[currentKey]) { + loadedEndpoints[currentKey] = endpoints[currentKey]; } }); @@ -61,7 +64,3 @@ const loadEndpoints = (config, agentsDefaults) => { return loadedEndpoints; }; - -module.exports = { - loadEndpoints, -}; diff --git a/packages/data-schemas/src/app/index.ts b/packages/data-schemas/src/app/index.ts new file mode 100644 index 0000000000..4912946329 --- /dev/null +++ b/packages/data-schemas/src/app/index.ts @@ -0,0 +1,6 @@ +export * from './agents'; +export * from './interface'; +export * from './service'; +export * from './specs'; +export * from './turnstile'; +export * from './web'; diff --git a/packages/api/src/app/interface.ts b/packages/data-schemas/src/app/interface.ts similarity index 55% rename from packages/api/src/app/interface.ts rename to packages/data-schemas/src/app/interface.ts index 3a03d09434..f8afdefd33 100644 --- a/packages/api/src/app/interface.ts +++ b/packages/data-schemas/src/app/interface.ts @@ -1,8 +1,7 @@ -import { logger } from '@librechat/data-schemas'; import { removeNullishValues } from 'librechat-data-provider'; import type { TCustomConfig, TConfigDefaults } from 'librechat-data-provider'; -import type { AppConfig } from '~/types/config'; -import { isMemoryEnabled } from '~/memory/config'; +import type { AppConfig } from '~/types/app'; +import { isMemoryEnabled } from './memory'; /** * Loads the default interface object. @@ -58,51 +57,5 @@ export async function loadDefaultInterface({ marketplace: interfaceConfig?.marketplace, }); - let i = 0; - const logSettings = () => { - // log interface object and model specs object (without list) for reference - logger.warn(`\`interface\` settings:\n${JSON.stringify(loadedInterface, null, 2)}`); - logger.warn( - `\`modelSpecs\` settings:\n${JSON.stringify( - { ...(config?.modelSpecs ?? {}), list: undefined }, - null, - 2, - )}`, - ); - }; - - // warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs. - if (config?.modelSpecs?.prioritize && loadedInterface.presets) { - logger.warn( - "Note: Prioritizing model specs can conflict with default presets if a default preset is set. It's recommended to disable presets from the interface or disable use of a default preset.", - ); - if (i === 0) i++; - } - - // warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options. - if ( - config?.modelSpecs?.enforce && - (loadedInterface.endpointsMenu || - loadedInterface.modelSelect || - loadedInterface.presets || - loadedInterface.parameters) - ) { - logger.warn( - "Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.", - ); - if (i === 0) i++; - } - // warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior. - if (config?.modelSpecs?.enforce && !config?.modelSpecs?.prioritize) { - logger.warn( - "Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It's recommended to enable prioritizing model specs if enforcing them.", - ); - if (i === 0) i++; - } - - if (i > 0) { - logSettings(); - } - return loadedInterface; } diff --git a/packages/data-schemas/src/app/memory.ts b/packages/data-schemas/src/app/memory.ts new file mode 100644 index 0000000000..8c27047cf6 --- /dev/null +++ b/packages/data-schemas/src/app/memory.ts @@ -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); +} diff --git a/packages/data-schemas/src/app/ocr.ts b/packages/data-schemas/src/app/ocr.ts new file mode 100644 index 0000000000..02060a858f --- /dev/null +++ b/packages/data-schemas/src/app/ocr.ts @@ -0,0 +1,15 @@ +import { OCRStrategy } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; + +export function loadOCRConfig(config?: TCustomConfig['ocr']): TCustomConfig['ocr'] | undefined { + if (!config) return; + const baseURL = config?.baseURL ?? ''; + const apiKey = config?.apiKey ?? ''; + const mistralModel = config?.mistralModel ?? ''; + return { + apiKey, + baseURL, + mistralModel, + strategy: config?.strategy ?? OCRStrategy.MISTRAL_OCR, + }; +} diff --git a/api/server/services/AppService.js b/packages/data-schemas/src/app/service.ts similarity index 52% rename from api/server/services/AppService.js rename to packages/data-schemas/src/app/service.ts index 49f9e324fb..aef2472d5f 100644 --- a/api/server/services/AppService.js +++ b/packages/data-schemas/src/app/service.ts @@ -1,48 +1,56 @@ -const { FileSources, EModelEndpoint, getConfigDefaults } = require('librechat-data-provider'); -const { - isEnabled, - loadOCRConfig, - loadMemoryConfig, - agentsConfigSetup, - loadWebSearchConfig, - loadDefaultInterface, -} = require('@librechat/api'); -const { - checkWebSearchConfig, - checkVariables, - checkHealth, - checkConfig, -} = require('./start/checks'); -const { initializeAzureBlobService } = require('./Files/Azure/initialize'); -const { initializeFirebase } = require('./Files/Firebase/initialize'); -const handleRateLimits = require('./Config/handleRateLimits'); -const loadCustomConfig = require('./Config/loadCustomConfig'); -const { loadTurnstileConfig } = require('./start/turnstile'); -const { processModelSpecs } = require('./start/modelSpecs'); -const { initializeS3 } = require('./Files/S3/initialize'); -const { loadAndFormatTools } = require('./start/tools'); -const { loadEndpoints } = require('./start/endpoints'); -const paths = require('~/config/paths'); +import { EModelEndpoint, getConfigDefaults } from 'librechat-data-provider'; +import type { TCustomConfig, FileSources, DeepPartial } from 'librechat-data-provider'; +import type { AppConfig, FunctionTool } from '~/types/app'; +import { loadDefaultInterface } from './interface'; +import { loadTurnstileConfig } from './turnstile'; +import { agentsConfigSetup } from './agents'; +import { loadWebSearchConfig } from './web'; +import { processModelSpecs } from './specs'; +import { loadMemoryConfig } from './memory'; +import { loadEndpoints } from './endpoints'; +import { loadOCRConfig } from './ocr'; + +export type Paths = { + root: string; + uploads: string; + clientPath: string; + dist: string; + publicPath: string; + fonts: string; + assets: string; + imageOutput: string; + structuredTools: string; + pluginManifest: string; +}; /** * Loads custom config and initializes app-wide variables. * @function AppService */ -const AppService = async () => { - /** @type {TCustomConfig} */ - const config = (await loadCustomConfig()) ?? {}; +export const AppService = async (params?: { + config: DeepPartial; + paths?: Paths; + systemTools?: Record; +}): Promise => { + const { config, paths, systemTools } = params || {}; + if (!config) { + throw new Error('Config is required'); + } const configDefaults = getConfigDefaults(); const ocr = loadOCRConfig(config.ocr); const webSearch = loadWebSearchConfig(config.webSearch); - checkWebSearchConfig(webSearch); const memory = loadMemoryConfig(config.memory); const filteredTools = config.filteredTools; const includedTools = config.includedTools; - const fileStrategy = config.fileStrategy ?? configDefaults.fileStrategy; + const fileStrategy = (config.fileStrategy ?? configDefaults.fileStrategy) as + | FileSources.local + | FileSources.s3 + | FileSources.firebase + | FileSources.azure_blob; const startBalance = process.env.START_BALANCE; const balance = config.balance ?? { - enabled: isEnabled(process.env.CHECK_BALANCE), + enabled: process.env.CHECK_BALANCE?.toLowerCase().trim() === 'true', startBalance: startBalance ? parseInt(startBalance, 10) : undefined, }; const transactions = config.transactions ?? configDefaults.transactions; @@ -50,23 +58,7 @@ const AppService = async () => { process.env.CDN_PROVIDER = fileStrategy; - checkVariables(); - await checkHealth(); - - if (fileStrategy === FileSources.firebase) { - initializeFirebase(); - } else if (fileStrategy === FileSources.azure_blob) { - initializeAzureBlobService(); - } else if (fileStrategy === FileSources.s3) { - initializeS3(); - } - - /** @type {Record} */ - const availableTools = loadAndFormatTools({ - adminFilter: filteredTools, - adminIncluded: includedTools, - directory: paths.structuredTools, - }); + const availableTools = systemTools; const mcpConfig = config.mcpServers || null; const registration = config.registration ?? configDefaults.registration; @@ -107,8 +99,6 @@ const AppService = async () => { return appConfig; } - checkConfig(config); - handleRateLimits(config?.rateLimits); const loadedEndpoints = loadEndpoints(config, agentsDefaults); const appConfig = { @@ -121,5 +111,3 @@ const AppService = async () => { return appConfig; }; - -module.exports = AppService; diff --git a/packages/data-schemas/src/app/specs.ts b/packages/data-schemas/src/app/specs.ts new file mode 100644 index 0000000000..4fa82d37a4 --- /dev/null +++ b/packages/data-schemas/src/app/specs.ts @@ -0,0 +1,94 @@ +import logger from '~/config/winston'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; + +/** + * Normalize the endpoint name to system-expected value. + * @param name + */ +function normalizeEndpointName(name = ''): string { + return name.toLowerCase() === 'ollama' ? 'ollama' : name; +} + +/** + * Sets up Model Specs from the config (`librechat.yaml`) file. + * @param [endpoints] - The loaded custom configuration for endpoints. + * @param [modelSpecs] - The loaded custom configuration for model specs. + * @param [interfaceConfig] - The loaded interface configuration. + * @returns The processed model specs, if any. + */ +export function processModelSpecs( + endpoints?: TCustomConfig['endpoints'], + _modelSpecs?: TCustomConfig['modelSpecs'], + interfaceConfig?: TCustomConfig['interface'], +): TCustomConfig['modelSpecs'] | undefined { + if (!_modelSpecs) { + return undefined; + } + + const list = _modelSpecs.list; + const modelSpecs: typeof list = []; + + const customEndpoints = endpoints?.[EModelEndpoint.custom] ?? []; + + if (interfaceConfig?.modelSelect !== true && (_modelSpecs.addedEndpoints?.length ?? 0) > 0) { + logger.warn( + `To utilize \`addedEndpoints\`, which allows provider/model selections alongside model specs, set \`modelSelect: true\` in the interface configuration. + + Example: + \`\`\`yaml + interface: + modelSelect: true + \`\`\` + `, + ); + } + + if (!list || list.length === 0) { + return undefined; + } + + for (const spec of list) { + const currentEndpoint = spec.preset?.endpoint as EModelEndpoint | undefined; + if (!currentEndpoint) { + logger.warn( + 'A model spec is missing the `endpoint` field within its `preset`. Skipping model spec...', + ); + continue; + } + if (EModelEndpoint[currentEndpoint] && currentEndpoint !== EModelEndpoint.custom) { + modelSpecs.push(spec); + continue; + } else if (currentEndpoint === EModelEndpoint.custom) { + logger.warn( + `Model Spec with endpoint "${currentEndpoint}" is not supported. You must specify the name of the custom endpoint (case-sensitive, as defined in your config). Skipping model spec...`, + ); + continue; + } + + const normalizedName = normalizeEndpointName(currentEndpoint); + const endpoint = customEndpoints.find( + (customEndpoint) => normalizedName === normalizeEndpointName(customEndpoint.name), + ); + + if (!endpoint) { + logger.warn(`Model spec with endpoint "${currentEndpoint}" was skipped: Endpoint not found in configuration. The \`endpoint\` value must exactly match either a system-defined endpoint or a custom endpoint defined by the user. + +For more information, see the documentation at https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/model_specs#endpoint`); + continue; + } + + modelSpecs.push({ + ...spec, + preset: { + ...spec.preset, + endpoint: normalizedName, + }, + }); + } + + return { + ..._modelSpecs, + list: modelSpecs, + }; +} diff --git a/packages/data-schemas/src/app/turnstile.ts b/packages/data-schemas/src/app/turnstile.ts new file mode 100644 index 0000000000..df0ae5a998 --- /dev/null +++ b/packages/data-schemas/src/app/turnstile.ts @@ -0,0 +1,45 @@ +import logger from '~/config/winston'; +import { removeNullishValues } from 'librechat-data-provider'; +import type { TCustomConfig, TConfigDefaults } from 'librechat-data-provider'; + +/** + * Loads and maps the Cloudflare Turnstile configuration. + * + * Expected config structure: + * + * turnstile: + * siteKey: "your-site-key-here" + * options: + * language: "auto" // "auto" or an ISO 639-1 language code (e.g. en) + * size: "normal" // Options: "normal", "compact", "flexible", or "invisible" + * + * @param config - The loaded custom configuration. + * @param configDefaults - The custom configuration default values. + * @returns The mapped Turnstile configuration. + */ +export function loadTurnstileConfig( + config: Partial | undefined, + configDefaults: TConfigDefaults, +): Partial { + const { turnstile: customTurnstile } = config ?? {}; + const { turnstile: defaults } = configDefaults; + + const loadedTurnstile = removeNullishValues({ + siteKey: + customTurnstile?.siteKey ?? (defaults as TCustomConfig['turnstile'] | undefined)?.siteKey, + options: + customTurnstile?.options ?? (defaults as TCustomConfig['turnstile'] | undefined)?.options, + }); + + const enabled = Boolean(loadedTurnstile.siteKey); + + if (enabled) { + logger.debug( + 'Turnstile is ENABLED with configuration:\n' + JSON.stringify(loadedTurnstile, null, 2), + ); + } else { + logger.debug('Turnstile is DISABLED (no siteKey provided).'); + } + + return loadedTurnstile; +} diff --git a/packages/data-schemas/src/app/web.ts b/packages/data-schemas/src/app/web.ts new file mode 100644 index 0000000000..5fbf2c674e --- /dev/null +++ b/packages/data-schemas/src/app/web.ts @@ -0,0 +1,84 @@ +import { SafeSearchTypes } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; +import type { TWebSearchKeys, TWebSearchCategories } from '~/types/web'; + +export const webSearchAuth = { + providers: { + serper: { + serperApiKey: 1 as const, + }, + searxng: { + searxngInstanceUrl: 1 as const, + /** Optional (0) */ + searxngApiKey: 0 as const, + }, + }, + scrapers: { + firecrawl: { + firecrawlApiKey: 1 as const, + /** Optional (0) */ + firecrawlApiUrl: 0 as const, + }, + }, + rerankers: { + jina: { + jinaApiKey: 1 as const, + /** Optional (0) */ + jinaApiUrl: 0 as const, + }, + cohere: { cohereApiKey: 1 as const }, + }, +}; + +/** + * Extracts all API keys from the webSearchAuth configuration object + */ +export function getWebSearchKeys(): TWebSearchKeys[] { + const keys: TWebSearchKeys[] = []; + + // Iterate through each category (providers, scrapers, rerankers) + for (const category of Object.keys(webSearchAuth)) { + const categoryObj = webSearchAuth[category as TWebSearchCategories]; + + // Iterate through each service within the category + for (const service of Object.keys(categoryObj)) { + const serviceObj = categoryObj[service as keyof typeof categoryObj]; + + // Extract the API keys from the service + for (const key of Object.keys(serviceObj)) { + keys.push(key as TWebSearchKeys); + } + } + } + + return keys; +} + +export const webSearchKeys: TWebSearchKeys[] = getWebSearchKeys(); + +export function loadWebSearchConfig( + config: TCustomConfig['webSearch'], +): TCustomConfig['webSearch'] { + const serperApiKey = config?.serperApiKey ?? '${SERPER_API_KEY}'; + const searxngInstanceUrl = config?.searxngInstanceUrl ?? '${SEARXNG_INSTANCE_URL}'; + const searxngApiKey = config?.searxngApiKey ?? '${SEARXNG_API_KEY}'; + const firecrawlApiKey = config?.firecrawlApiKey ?? '${FIRECRAWL_API_KEY}'; + const firecrawlApiUrl = config?.firecrawlApiUrl ?? '${FIRECRAWL_API_URL}'; + const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}'; + const jinaApiUrl = config?.jinaApiUrl ?? '${JINA_API_URL}'; + const cohereApiKey = config?.cohereApiKey ?? '${COHERE_API_KEY}'; + const safeSearch = config?.safeSearch ?? SafeSearchTypes.MODERATE; + + return { + ...config, + safeSearch, + jinaApiKey, + jinaApiUrl, + cohereApiKey, + serperApiKey, + searxngInstanceUrl, + searxngApiKey, + firecrawlApiKey, + firecrawlApiUrl, + }; +} diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 503c99385f..0754dfe258 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -1,3 +1,4 @@ +export * from './app'; export * from './common'; export * from './crypto'; export * from './schema'; diff --git a/packages/data-schemas/src/models/pluginAuth.ts b/packages/data-schemas/src/models/pluginAuth.ts index cf466a145f..5075fe6f43 100644 --- a/packages/data-schemas/src/models/pluginAuth.ts +++ b/packages/data-schemas/src/models/pluginAuth.ts @@ -1,4 +1,5 @@ -import pluginAuthSchema, { IPluginAuth } from '~/schema/pluginAuth'; +import pluginAuthSchema from '~/schema/pluginAuth'; +import type { IPluginAuth } from '~/types/pluginAuth'; /** * Creates or returns the PluginAuth model using the provided mongoose instance and schema diff --git a/packages/data-schemas/src/models/prompt.ts b/packages/data-schemas/src/models/prompt.ts index 74cc4ea2da..87edfa1ef8 100644 --- a/packages/data-schemas/src/models/prompt.ts +++ b/packages/data-schemas/src/models/prompt.ts @@ -1,4 +1,5 @@ -import promptSchema, { IPrompt } from '~/schema/prompt'; +import promptSchema from '~/schema/prompt'; +import type { IPrompt } from '~/types/prompts'; /** * Creates or returns the Prompt model using the provided mongoose instance and schema diff --git a/packages/data-schemas/src/models/promptGroup.ts b/packages/data-schemas/src/models/promptGroup.ts index 41e3d2e347..8de3dc9e16 100644 --- a/packages/data-schemas/src/models/promptGroup.ts +++ b/packages/data-schemas/src/models/promptGroup.ts @@ -1,4 +1,5 @@ -import promptGroupSchema, { IPromptGroupDocument } from '~/schema/promptGroup'; +import promptGroupSchema from '~/schema/promptGroup'; +import type { IPromptGroupDocument } from '~/types/prompts'; /** * Creates or returns the PromptGroup model using the provided mongoose instance and schema diff --git a/packages/api/src/types/config.ts b/packages/data-schemas/src/types/app.ts similarity index 69% rename from packages/api/src/types/config.ts rename to packages/data-schemas/src/types/app.ts index ff38cbd8d1..1078cb3f92 100644 --- a/packages/api/src/types/config.ts +++ b/packages/data-schemas/src/types/app.ts @@ -9,7 +9,31 @@ import type { TCustomEndpoints, TAssistantEndpoint, } from 'librechat-data-provider'; -import type { FunctionTool } from './tools'; + +export type JsonSchemaType = { + type: 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'array' | 'object'; + enum?: string[]; + items?: JsonSchemaType; + properties?: Record; + required?: string[]; + description?: string; + additionalProperties?: boolean | JsonSchemaType; +}; + +export type ConvertJsonSchemaToZodOptions = { + allowEmptyObject?: boolean; + dropFields?: string[]; + transformOneOfAnyOf?: boolean; +}; + +export interface FunctionTool { + type: 'function'; + function: { + description: string; + name: string; + parameters: JsonSchemaType; + }; +} /** * Application configuration object @@ -17,11 +41,11 @@ import type { FunctionTool } from './tools'; */ export interface AppConfig { /** The main custom configuration */ - config: TCustomConfig; + config: Partial; /** OCR configuration */ ocr?: TCustomConfig['ocr']; /** File paths configuration */ - paths: { + paths?: { uploads: string; imageOutput: string; publicPath: string; @@ -34,7 +58,7 @@ export interface AppConfig { /** File storage strategy ('local', 's3', 'firebase', 'azure_blob') */ fileStrategy: FileSources.local | FileSources.s3 | FileSources.firebase | FileSources.azure_blob; /** File strategies configuration */ - fileStrategies: TCustomConfig['fileStrategies']; + fileStrategies?: TCustomConfig['fileStrategies']; /** Registration configurations */ registration?: TCustomConfig['registration']; /** Actions configurations */ @@ -48,9 +72,9 @@ export interface AppConfig { /** Interface configuration */ interfaceConfig?: TCustomConfig['interface']; /** Turnstile configuration */ - turnstileConfig?: TCustomConfig['turnstile']; + turnstileConfig?: Partial; /** Balance configuration */ - balance?: TCustomConfig['balance']; + balance?: Partial; /** Transactions configuration */ transactions?: TCustomConfig['transactions']; /** Speech configuration */ @@ -67,26 +91,26 @@ export interface AppConfig { availableTools?: Record; endpoints?: { /** OpenAI endpoint configuration */ - openAI?: TEndpoint; + openAI?: Partial; /** Google endpoint configuration */ - google?: TEndpoint; + google?: Partial; /** Bedrock endpoint configuration */ - bedrock?: TEndpoint; + bedrock?: Partial; /** Anthropic endpoint configuration */ - anthropic?: TEndpoint; + anthropic?: Partial; /** GPT plugins endpoint configuration */ - gptPlugins?: TEndpoint; + gptPlugins?: Partial; /** Azure OpenAI endpoint configuration */ azureOpenAI?: TAzureConfig; /** Assistants endpoint configuration */ - assistants?: TAssistantEndpoint; + assistants?: Partial; /** Azure assistants endpoint configuration */ - azureAssistants?: TAssistantEndpoint; + azureAssistants?: Partial; /** Agents endpoint configuration */ - [EModelEndpoint.agents]?: TAgentsEndpoint; + [EModelEndpoint.agents]?: Partial; /** Custom endpoints configuration */ [EModelEndpoint.custom]?: TCustomEndpoints; /** Global endpoint configuration */ - all?: TEndpoint; + all?: Partial; }; } diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 4a755beda7..58122cbc55 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -1,6 +1,7 @@ import type { Types } from 'mongoose'; export type ObjectId = Types.ObjectId; +export * from './app'; export * from './user'; export * from './token'; export * from './convo'; @@ -24,3 +25,5 @@ export * from './prompts'; export * from './accessRole'; export * from './aclEntry'; export * from './group'; +/* Web */ +export * from './web'; diff --git a/packages/data-schemas/src/types/role.ts b/packages/data-schemas/src/types/role.ts index b1cb4b87c3..a672e3fae2 100644 --- a/packages/data-schemas/src/types/role.ts +++ b/packages/data-schemas/src/types/role.ts @@ -1,4 +1,5 @@ import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import type { DeepPartial } from 'librechat-data-provider'; import type { Document } from 'mongoose'; import { CursorPaginationParams } from '~/common'; @@ -54,9 +55,6 @@ export interface IRole extends Document { } export type RolePermissions = IRole['permissions']; -type DeepPartial = { - [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]; -}; export type RolePermissionsInput = DeepPartial; export interface CreateRoleRequest { diff --git a/packages/data-schemas/src/types/web.ts b/packages/data-schemas/src/types/web.ts new file mode 100644 index 0000000000..c424be48d2 --- /dev/null +++ b/packages/data-schemas/src/types/web.ts @@ -0,0 +1,16 @@ +import type { SearchCategories } from 'librechat-data-provider'; + +export type TWebSearchKeys = + | 'serperApiKey' + | 'searxngInstanceUrl' + | 'searxngApiKey' + | 'firecrawlApiKey' + | 'firecrawlApiUrl' + | 'jinaApiKey' + | 'jinaApiUrl' + | 'cohereApiKey'; + +export type TWebSearchCategories = + | SearchCategories.PROVIDERS + | SearchCategories.SCRAPERS + | SearchCategories.RERANKERS; From 7897801fbc9fe8834552d14f6195c226099b92e2 Mon Sep 17 00:00:00 2001 From: alfo-dev <210219969+alfo-dev@users.noreply.github.com> Date: Sun, 5 Oct 2025 12:56:21 +0200 Subject: [PATCH 008/272] =?UTF-8?q?=F0=9F=A7=B1=20fix:=20DALL-E=20Proxy=20?= =?UTF-8?q?Bypass=20(#9971)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/clients/tools/structured/DALLE3.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index db6b4e63e4..d92388b320 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -1,9 +1,8 @@ const { z } = require('zod'); const path = require('path'); const OpenAI = require('openai'); -const fetch = require('node-fetch'); const { v4: uuidv4 } = require('uuid'); -const { ProxyAgent } = require('undici'); +const { ProxyAgent, fetch } = require('undici'); const { Tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); const { getImageBasename } = require('@librechat/api'); From 72884490116662ed9eb41015d3665da2c932b417 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 5 Oct 2025 07:02:09 -0400 Subject: [PATCH 009/272] =?UTF-8?q?=F0=9F=AB=B4=20refactor:=20Add=20Broade?= =?UTF-8?q?r=20Support=20for=20GPT-OSS=20Naming=20(#9978)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/models/tx.js | 3 +++ api/models/tx.spec.js | 10 ++++++++++ api/utils/tokens.spec.js | 11 +++++++++-- packages/api/src/utils/tokens.ts | 3 +++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/api/models/tx.js b/api/models/tx.js index 769492a643..062ebecf26 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -152,7 +152,10 @@ const tokenValues = Object.assign( 'ministral-8b': { prompt: 0.1, completion: 0.1 }, 'ministral-3b': { prompt: 0.04, completion: 0.04 }, // GPT-OSS models + 'gpt-oss': { prompt: 0.05, completion: 0.2 }, + 'gpt-oss:20b': { prompt: 0.05, completion: 0.2 }, 'gpt-oss-20b': { prompt: 0.05, completion: 0.2 }, + 'gpt-oss:120b': { prompt: 0.15, completion: 0.6 }, 'gpt-oss-120b': { prompt: 0.15, completion: 0.6 }, }, bedrockValues, diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js index dd3c543417..7594ce3295 100644 --- a/api/models/tx.spec.js +++ b/api/models/tx.spec.js @@ -184,6 +184,16 @@ describe('getValueKey', () => { expect(getValueKey('claude-3.5-haiku-turbo')).toBe('claude-3.5-haiku'); expect(getValueKey('claude-3.5-haiku-0125')).toBe('claude-3.5-haiku'); }); + + it('should return expected value keys for "gpt-oss" models', () => { + expect(getValueKey('openai/gpt-oss-120b')).toBe('gpt-oss-120b'); + expect(getValueKey('openai/gpt-oss:120b')).toBe('gpt-oss:120b'); + expect(getValueKey('openai/gpt-oss-570b')).toBe('gpt-oss'); + expect(getValueKey('gpt-oss-570b')).toBe('gpt-oss'); + expect(getValueKey('groq/gpt-oss-1080b')).toBe('gpt-oss'); + expect(getValueKey('gpt-oss-20b')).toBe('gpt-oss-20b'); + expect(getValueKey('oai/gpt-oss:20b')).toBe('gpt-oss:20b'); + }); }); describe('getMultiplier', () => { diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 527129ec8c..20dad79894 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -396,8 +396,15 @@ describe('getModelMaxTokens', () => { }); test('should return correct tokens for GPT-OSS models', () => { - const expected = maxTokensMap[EModelEndpoint.openAI]['gpt-oss-20b']; - ['gpt-oss-20b', 'gpt-oss-120b', 'openai/gpt-oss-20b', 'openai/gpt-oss-120b'].forEach((name) => { + const expected = maxTokensMap[EModelEndpoint.openAI]['gpt-oss']; + [ + 'gpt-oss:20b', + 'gpt-oss-20b', + 'gpt-oss-120b', + 'openai/gpt-oss-20b', + 'openai/gpt-oss-120b', + 'openai/gpt-oss:120b', + ].forEach((name) => { expect(getModelMaxTokens(name)).toBe(expected); }); }); diff --git a/packages/api/src/utils/tokens.ts b/packages/api/src/utils/tokens.ts index b53305278d..f75a5b9de2 100644 --- a/packages/api/src/utils/tokens.ts +++ b/packages/api/src/utils/tokens.ts @@ -257,7 +257,10 @@ const aggregateModels = { // misc. kimi: 131000, // GPT-OSS + 'gpt-oss': 131000, + 'gpt-oss:20b': 131000, 'gpt-oss-20b': 131000, + 'gpt-oss:120b': 131000, 'gpt-oss-120b': 131000, }; From c9103a17083d200fa36555e050c9ed83afdf075e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 5 Oct 2025 09:08:29 -0400 Subject: [PATCH 010/272] =?UTF-8?q?=F0=9F=A4=96=20feat:=20Add=20Z.AI=20GLM?= =?UTF-8?q?=20Context=20Window=20&=20Pricing=20(#9979)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: update @librechat/agents to v2.4.83 to handle reasoning edge case encountered with GLM models * feat: GLM Context Window & Pricing Support * feat: Add support for glm4 model in token values and tests --- api/models/tx.js | 8 ++ api/models/tx.spec.js | 116 ++++++++++++++++++++++++ api/package.json | 2 +- api/utils/tokens.spec.js | 146 +++++++++++++++++++++++++++++++ package-lock.json | 10 +-- packages/api/package.json | 2 +- packages/api/src/utils/tokens.ts | 11 ++- 7 files changed, 287 insertions(+), 8 deletions(-) diff --git a/api/models/tx.js b/api/models/tx.js index 062ebecf26..282d58c8fc 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -157,6 +157,14 @@ const tokenValues = Object.assign( 'gpt-oss-20b': { prompt: 0.05, completion: 0.2 }, 'gpt-oss:120b': { prompt: 0.15, completion: 0.6 }, 'gpt-oss-120b': { prompt: 0.15, completion: 0.6 }, + // GLM models (Zhipu AI) + glm4: { prompt: 0.1, completion: 0.1 }, + 'glm-4': { prompt: 0.1, completion: 0.1 }, + 'glm-4-32b': { prompt: 0.1, completion: 0.1 }, + 'glm-4.5': { prompt: 0.35, completion: 1.55 }, + 'glm-4.5v': { prompt: 0.6, completion: 1.8 }, + 'glm-4.5-air': { prompt: 0.14, completion: 0.86 }, + 'glm-4.6': { prompt: 0.5, completion: 1.75 }, }, bedrockValues, ); diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js index 7594ce3295..3cbce34295 100644 --- a/api/models/tx.spec.js +++ b/api/models/tx.spec.js @@ -404,6 +404,18 @@ describe('getMultiplier', () => { expect(getMultiplier({ model: key, tokenType: 'completion' })).toBe(expectedCompletion); }); }); + + it('should return correct multipliers for GLM models', () => { + const models = ['glm-4.6', 'glm-4.5v', 'glm-4.5-air', 'glm-4.5', 'glm-4-32b', 'glm-4', 'glm4']; + models.forEach((key) => { + const expectedPrompt = tokenValues[key].prompt; + const expectedCompletion = tokenValues[key].completion; + expect(getMultiplier({ valueKey: key, tokenType: 'prompt' })).toBe(expectedPrompt); + expect(getMultiplier({ valueKey: key, tokenType: 'completion' })).toBe(expectedCompletion); + expect(getMultiplier({ model: key, tokenType: 'prompt' })).toBe(expectedPrompt); + expect(getMultiplier({ model: key, tokenType: 'completion' })).toBe(expectedCompletion); + }); + }); }); describe('AWS Bedrock Model Tests', () => { @@ -782,6 +794,110 @@ describe('Grok Model Tests - Pricing', () => { }); }); +describe('GLM Model Tests', () => { + it('should return expected value keys for GLM models', () => { + expect(getValueKey('glm-4.6')).toBe('glm-4.6'); + expect(getValueKey('glm-4.5')).toBe('glm-4.5'); + expect(getValueKey('glm-4.5v')).toBe('glm-4.5v'); + expect(getValueKey('glm-4.5-air')).toBe('glm-4.5-air'); + expect(getValueKey('glm-4-32b')).toBe('glm-4-32b'); + expect(getValueKey('glm-4')).toBe('glm-4'); + expect(getValueKey('glm4')).toBe('glm4'); + }); + + it('should match GLM model variations with provider prefixes', () => { + expect(getValueKey('z-ai/glm-4.6')).toBe('glm-4.6'); + expect(getValueKey('z-ai/glm-4.5')).toBe('glm-4.5'); + expect(getValueKey('z-ai/glm-4.5-air')).toBe('glm-4.5-air'); + expect(getValueKey('z-ai/glm-4.5v')).toBe('glm-4.5v'); + expect(getValueKey('z-ai/glm-4-32b')).toBe('glm-4-32b'); + + expect(getValueKey('zai/glm-4.6')).toBe('glm-4.6'); + expect(getValueKey('zai/glm-4.5')).toBe('glm-4.5'); + expect(getValueKey('zai/glm-4.5-air')).toBe('glm-4.5-air'); + expect(getValueKey('zai/glm-4.5v')).toBe('glm-4.5v'); + + expect(getValueKey('zai-org/GLM-4.6')).toBe('glm-4.6'); + expect(getValueKey('zai-org/GLM-4.5')).toBe('glm-4.5'); + expect(getValueKey('zai-org/GLM-4.5-Air')).toBe('glm-4.5-air'); + expect(getValueKey('zai-org/GLM-4.5V')).toBe('glm-4.5v'); + expect(getValueKey('zai-org/GLM-4-32B-0414')).toBe('glm-4-32b'); + }); + + it('should match GLM model variations with suffixes', () => { + expect(getValueKey('glm-4.6-fp8')).toBe('glm-4.6'); + expect(getValueKey('zai-org/GLM-4.6-FP8')).toBe('glm-4.6'); + expect(getValueKey('zai-org/GLM-4.5-Air-FP8')).toBe('glm-4.5-air'); + }); + + it('should prioritize more specific GLM model patterns', () => { + expect(getValueKey('glm-4.5-air-something')).toBe('glm-4.5-air'); + expect(getValueKey('glm-4.5-something')).toBe('glm-4.5'); + expect(getValueKey('glm-4.5v-something')).toBe('glm-4.5v'); + }); + + it('should return correct multipliers for all GLM models', () => { + expect(getMultiplier({ model: 'glm-4.6', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.6'].prompt, + ); + expect(getMultiplier({ model: 'glm-4.6', tokenType: 'completion' })).toBe( + tokenValues['glm-4.6'].completion, + ); + + expect(getMultiplier({ model: 'glm-4.5v', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.5v'].prompt, + ); + expect(getMultiplier({ model: 'glm-4.5v', tokenType: 'completion' })).toBe( + tokenValues['glm-4.5v'].completion, + ); + + expect(getMultiplier({ model: 'glm-4.5-air', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.5-air'].prompt, + ); + expect(getMultiplier({ model: 'glm-4.5-air', tokenType: 'completion' })).toBe( + tokenValues['glm-4.5-air'].completion, + ); + + expect(getMultiplier({ model: 'glm-4.5', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.5'].prompt, + ); + expect(getMultiplier({ model: 'glm-4.5', tokenType: 'completion' })).toBe( + tokenValues['glm-4.5'].completion, + ); + + expect(getMultiplier({ model: 'glm-4-32b', tokenType: 'prompt' })).toBe( + tokenValues['glm-4-32b'].prompt, + ); + expect(getMultiplier({ model: 'glm-4-32b', tokenType: 'completion' })).toBe( + tokenValues['glm-4-32b'].completion, + ); + + expect(getMultiplier({ model: 'glm-4', tokenType: 'prompt' })).toBe( + tokenValues['glm-4'].prompt, + ); + expect(getMultiplier({ model: 'glm-4', tokenType: 'completion' })).toBe( + tokenValues['glm-4'].completion, + ); + + expect(getMultiplier({ model: 'glm4', tokenType: 'prompt' })).toBe(tokenValues['glm4'].prompt); + expect(getMultiplier({ model: 'glm4', tokenType: 'completion' })).toBe( + tokenValues['glm4'].completion, + ); + }); + + it('should return correct multipliers for GLM models with provider prefixes', () => { + expect(getMultiplier({ model: 'z-ai/glm-4.6', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.6'].prompt, + ); + expect(getMultiplier({ model: 'zai/glm-4.5-air', tokenType: 'completion' })).toBe( + tokenValues['glm-4.5-air'].completion, + ); + expect(getMultiplier({ model: 'zai-org/GLM-4.5V', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.5v'].prompt, + ); + }); +}); + describe('Claude Model Tests', () => { it('should return correct prompt and completion rates for Claude 4 models', () => { expect(getMultiplier({ model: 'claude-sonnet-4', tokenType: 'prompt' })).toBe( diff --git a/api/package.json b/api/package.json index 98964f5cb0..b856fc0925 100644 --- a/api/package.json +++ b/api/package.json @@ -48,7 +48,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.82", + "@librechat/agents": "^2.4.83", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 20dad79894..162827767f 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -409,6 +409,64 @@ describe('getModelMaxTokens', () => { }); }); + test('should return correct tokens for GLM models', () => { + expect(getModelMaxTokens('glm-4.6')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']); + expect(getModelMaxTokens('glm-4.5v')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5v']); + expect(getModelMaxTokens('glm-4.5-air')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'], + ); + expect(getModelMaxTokens('glm-4.5')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5']); + expect(getModelMaxTokens('glm-4-32b')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4-32b']); + expect(getModelMaxTokens('glm-4')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4']); + expect(getModelMaxTokens('glm4')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm4']); + }); + + test('should return correct tokens for GLM models with provider prefixes', () => { + expect(getModelMaxTokens('z-ai/glm-4.6')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']); + expect(getModelMaxTokens('z-ai/glm-4.5')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5']); + expect(getModelMaxTokens('z-ai/glm-4.5-air')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'], + ); + expect(getModelMaxTokens('z-ai/glm-4.5v')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5v'], + ); + expect(getModelMaxTokens('z-ai/glm-4-32b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4-32b'], + ); + + expect(getModelMaxTokens('zai/glm-4.6')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']); + expect(getModelMaxTokens('zai/glm-4.5-air')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'], + ); + expect(getModelMaxTokens('zai/glm-4.5v')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5v']); + + expect(getModelMaxTokens('zai-org/GLM-4.6')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.6'], + ); + expect(getModelMaxTokens('zai-org/GLM-4.5')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5'], + ); + expect(getModelMaxTokens('zai-org/GLM-4.5-Air')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'], + ); + expect(getModelMaxTokens('zai-org/GLM-4.5V')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5v'], + ); + expect(getModelMaxTokens('zai-org/GLM-4-32B-0414')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4-32b'], + ); + }); + + test('should return correct tokens for GLM models with suffixes', () => { + expect(getModelMaxTokens('glm-4.6-fp8')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']); + expect(getModelMaxTokens('zai-org/GLM-4.6-FP8')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.6'], + ); + expect(getModelMaxTokens('zai-org/GLM-4.5-Air-FP8')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'], + ); + }); + test('should return correct max output tokens for GPT-5 models', () => { const { getModelMaxOutputTokens } = require('@librechat/api'); ['gpt-5', 'gpt-5-mini', 'gpt-5-nano'].forEach((model) => { @@ -865,3 +923,91 @@ describe('Kimi Model Tests', () => { }); }); }); + +describe('GLM Model Tests (Zhipu AI)', () => { + describe('getModelMaxTokens', () => { + test('should return correct tokens for GLM models', () => { + expect(getModelMaxTokens('glm-4.6')).toBe(200000); + expect(getModelMaxTokens('glm-4.5v')).toBe(66000); + expect(getModelMaxTokens('glm-4.5-air')).toBe(131000); + expect(getModelMaxTokens('glm-4.5')).toBe(131000); + expect(getModelMaxTokens('glm-4-32b')).toBe(128000); + expect(getModelMaxTokens('glm-4')).toBe(128000); + expect(getModelMaxTokens('glm4')).toBe(128000); + }); + + test('should handle partial matches for GLM models with provider prefixes', () => { + expect(getModelMaxTokens('z-ai/glm-4.6')).toBe(200000); + expect(getModelMaxTokens('z-ai/glm-4.5')).toBe(131000); + expect(getModelMaxTokens('z-ai/glm-4.5-air')).toBe(131000); + expect(getModelMaxTokens('z-ai/glm-4.5v')).toBe(66000); + expect(getModelMaxTokens('z-ai/glm-4-32b')).toBe(128000); + + expect(getModelMaxTokens('zai/glm-4.6')).toBe(200000); + expect(getModelMaxTokens('zai/glm-4.5')).toBe(131000); + expect(getModelMaxTokens('zai/glm-4.5-air')).toBe(131000); + expect(getModelMaxTokens('zai/glm-4.5v')).toBe(66000); + + expect(getModelMaxTokens('zai-org/GLM-4.6')).toBe(200000); + expect(getModelMaxTokens('zai-org/GLM-4.5')).toBe(131000); + expect(getModelMaxTokens('zai-org/GLM-4.5-Air')).toBe(131000); + expect(getModelMaxTokens('zai-org/GLM-4.5V')).toBe(66000); + expect(getModelMaxTokens('zai-org/GLM-4-32B-0414')).toBe(128000); + }); + + test('should handle GLM model variations with suffixes', () => { + expect(getModelMaxTokens('glm-4.6-fp8')).toBe(200000); + expect(getModelMaxTokens('zai-org/GLM-4.6-FP8')).toBe(200000); + expect(getModelMaxTokens('zai-org/GLM-4.5-Air-FP8')).toBe(131000); + }); + + test('should prioritize more specific GLM patterns', () => { + expect(getModelMaxTokens('glm-4.5-air-custom')).toBe(131000); + expect(getModelMaxTokens('glm-4.5-custom')).toBe(131000); + expect(getModelMaxTokens('glm-4.5v-custom')).toBe(66000); + }); + }); + + describe('matchModelName', () => { + test('should match exact GLM model names', () => { + expect(matchModelName('glm-4.6')).toBe('glm-4.6'); + expect(matchModelName('glm-4.5v')).toBe('glm-4.5v'); + expect(matchModelName('glm-4.5-air')).toBe('glm-4.5-air'); + expect(matchModelName('glm-4.5')).toBe('glm-4.5'); + expect(matchModelName('glm-4-32b')).toBe('glm-4-32b'); + expect(matchModelName('glm-4')).toBe('glm-4'); + expect(matchModelName('glm4')).toBe('glm4'); + }); + + test('should match GLM model variations with provider prefixes', () => { + expect(matchModelName('z-ai/glm-4.6')).toBe('glm-4.6'); + expect(matchModelName('z-ai/glm-4.5')).toBe('glm-4.5'); + expect(matchModelName('z-ai/glm-4.5-air')).toBe('glm-4.5-air'); + expect(matchModelName('z-ai/glm-4.5v')).toBe('glm-4.5v'); + expect(matchModelName('z-ai/glm-4-32b')).toBe('glm-4-32b'); + + expect(matchModelName('zai/glm-4.6')).toBe('glm-4.6'); + expect(matchModelName('zai/glm-4.5')).toBe('glm-4.5'); + expect(matchModelName('zai/glm-4.5-air')).toBe('glm-4.5-air'); + expect(matchModelName('zai/glm-4.5v')).toBe('glm-4.5v'); + + expect(matchModelName('zai-org/GLM-4.6')).toBe('glm-4.6'); + expect(matchModelName('zai-org/GLM-4.5')).toBe('glm-4.5'); + expect(matchModelName('zai-org/GLM-4.5-Air')).toBe('glm-4.5-air'); + expect(matchModelName('zai-org/GLM-4.5V')).toBe('glm-4.5v'); + expect(matchModelName('zai-org/GLM-4-32B-0414')).toBe('glm-4-32b'); + }); + + test('should match GLM model variations with suffixes', () => { + expect(matchModelName('glm-4.6-fp8')).toBe('glm-4.6'); + expect(matchModelName('zai-org/GLM-4.6-FP8')).toBe('glm-4.6'); + expect(matchModelName('zai-org/GLM-4.5-Air-FP8')).toBe('glm-4.5-air'); + }); + + test('should handle case-insensitive matching for GLM models', () => { + expect(matchModelName('zai-org/GLM-4.6')).toBe('glm-4.6'); + expect(matchModelName('zai-org/GLM-4.5V')).toBe('glm-4.5v'); + expect(matchModelName('zai-org/GLM-4-32B-0414')).toBe('glm-4-32b'); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 0a3784fcd7..06ad223183 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.82", + "@librechat/agents": "^2.4.83", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -21522,9 +21522,9 @@ } }, "node_modules/@librechat/agents": { - "version": "2.4.82", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.82.tgz", - "integrity": "sha512-KNz8L1H/IXE3hnOU27ElsGy+oWpZ7oYnrLXIoJUyoy/qWlAUzKkzbOHp4hkLIK3xB21ncVuSqKS0542W6MQkKQ==", + "version": "2.4.83", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.83.tgz", + "integrity": "sha512-xOspD4jegd7wpjWQhOieOso2LrXsNRyHNYEIIuCuk2eUZkxJU+1Rny0XzukhenTOG8P4bpne9XoVxxxZ0W5duA==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -51336,7 +51336,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", - "@librechat/agents": "^2.4.82", + "@librechat/agents": "^2.4.83", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", diff --git a/packages/api/package.json b/packages/api/package.json index 2a38366b41..a983aca87a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -80,7 +80,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", - "@librechat/agents": "^2.4.82", + "@librechat/agents": "^2.4.83", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", diff --git a/packages/api/src/utils/tokens.ts b/packages/api/src/utils/tokens.ts index f75a5b9de2..d527836642 100644 --- a/packages/api/src/utils/tokens.ts +++ b/packages/api/src/utils/tokens.ts @@ -262,6 +262,14 @@ const aggregateModels = { 'gpt-oss-20b': 131000, 'gpt-oss:120b': 131000, 'gpt-oss-120b': 131000, + // GLM models (Zhipu AI) + glm4: 128000, + 'glm-4': 128000, + 'glm-4-32b': 128000, + 'glm-4.5': 131000, + 'glm-4.5-air': 131000, + 'glm-4.5v': 66000, + 'glm-4.6': 200000, }; export const maxTokensMap = { @@ -317,9 +325,10 @@ export function findMatchingPattern( tokensMap: Record | EndpointTokenConfig, ): string | null { const keys = Object.keys(tokensMap); + const lowerModelName = modelName.toLowerCase(); for (let i = keys.length - 1; i >= 0; i--) { const modelKey = keys[i]; - if (modelName.includes(modelKey)) { + if (lowerModelName.includes(modelKey)) { return modelKey; } } From 857c054a9a40abf85ae974b37593ecfb72d20ded Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 5 Oct 2025 13:48:41 -0400 Subject: [PATCH 011/272] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20feat:=20Cleanup?= =?UTF-8?q?=20for=20Orphaned=20MeiliSearch=20Documents=20(#9980)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added a new function `deleteDocumentsWithoutUserField` to remove documents lacking the user field from the specified MeiliSearch index. - Integrated this function into the `ensureFilterableAttributes` method to clean up orphaned documents when existing messages or conversations are found without a user field. 🔧 feat: Enhance Index Synchronization Logic - Updated `ensureFilterableAttributes` to return an object indicating whether settings were updated and if orphaned documents were found. - Integrated orphaned document cleanup directly into the index synchronization process without forcing a full re-sync unless settings were updated. - Improved logging for clarity on index configuration updates and orphaned document handling. 🔧 feat: Improve Flow State Management in Index Synchronization - Refactored flow state management logic to ensure cleanup occurs after synchronization, regardless of success or error. - Enhanced logging for flow state cleanup to provide better visibility into the synchronization process. - Streamlined the structure of the index synchronization function for improved readability. --- api/db/indexSync.js | 154 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 123 insertions(+), 31 deletions(-) diff --git a/api/db/indexSync.js b/api/db/indexSync.js index 9a9ed9507a..090c32cca8 100644 --- a/api/db/indexSync.js +++ b/api/db/indexSync.js @@ -29,12 +29,64 @@ class MeiliSearchClient { } } +/** + * Deletes documents from MeiliSearch index that are missing the user field + * @param {import('meilisearch').Index} index - MeiliSearch index instance + * @param {string} indexName - Name of the index for logging + * @returns {Promise} - Number of documents deleted + */ +async function deleteDocumentsWithoutUserField(index, indexName) { + let deletedCount = 0; + let offset = 0; + const batchSize = 1000; + + try { + while (true) { + const searchResult = await index.search('', { + limit: batchSize, + offset: offset, + }); + + if (searchResult.hits.length === 0) { + break; + } + + const idsToDelete = searchResult.hits.filter((hit) => !hit.user).map((hit) => hit.id); + + if (idsToDelete.length > 0) { + logger.info( + `[indexSync] Deleting ${idsToDelete.length} documents without user field from ${indexName} index`, + ); + await index.deleteDocuments(idsToDelete); + deletedCount += idsToDelete.length; + } + + if (searchResult.hits.length < batchSize) { + break; + } + + offset += batchSize; + } + + if (deletedCount > 0) { + logger.info(`[indexSync] Deleted ${deletedCount} orphaned documents from ${indexName} index`); + } + } catch (error) { + logger.error(`[indexSync] Error deleting documents from ${indexName}:`, error); + } + + return deletedCount; +} + /** * Ensures indexes have proper filterable attributes configured and checks if documents have user field * @param {MeiliSearch} client - MeiliSearch client instance - * @returns {Promise} - true if configuration was updated or re-sync is needed + * @returns {Promise<{settingsUpdated: boolean, orphanedDocsFound: boolean}>} - Status of what was done */ async function ensureFilterableAttributes(client) { + let settingsUpdated = false; + let hasOrphanedDocs = false; + try { // Check and update messages index try { @@ -47,16 +99,17 @@ async function ensureFilterableAttributes(client) { filterableAttributes: ['user'], }); logger.info('[indexSync] Messages index configured for user filtering'); - logger.info('[indexSync] Index configuration updated. Full re-sync will be triggered.'); - return true; + settingsUpdated = true; } // Check if existing documents have user field indexed try { const searchResult = await messagesIndex.search('', { limit: 1 }); if (searchResult.hits.length > 0 && !searchResult.hits[0].user) { - logger.info('[indexSync] Existing messages missing user field, re-sync needed'); - return true; + logger.info( + '[indexSync] Existing messages missing user field, will clean up orphaned documents...', + ); + hasOrphanedDocs = true; } } catch (searchError) { logger.debug('[indexSync] Could not check message documents:', searchError.message); @@ -78,16 +131,17 @@ async function ensureFilterableAttributes(client) { filterableAttributes: ['user'], }); logger.info('[indexSync] Convos index configured for user filtering'); - logger.info('[indexSync] Index configuration updated. Full re-sync will be triggered.'); - return true; + settingsUpdated = true; } // Check if existing documents have user field indexed try { const searchResult = await convosIndex.search('', { limit: 1 }); if (searchResult.hits.length > 0 && !searchResult.hits[0].user) { - logger.info('[indexSync] Existing conversations missing user field, re-sync needed'); - return true; + logger.info( + '[indexSync] Existing conversations missing user field, will clean up orphaned documents...', + ); + hasOrphanedDocs = true; } } catch (searchError) { logger.debug('[indexSync] Could not check conversation documents:', searchError.message); @@ -97,11 +151,34 @@ async function ensureFilterableAttributes(client) { logger.warn('[indexSync] Could not check/update convos index settings:', error.message); } } + + // If either index has orphaned documents, clean them up (but don't force resync) + if (hasOrphanedDocs) { + try { + const messagesIndex = client.index('messages'); + await deleteDocumentsWithoutUserField(messagesIndex, 'messages'); + } catch (error) { + logger.debug('[indexSync] Could not clean up messages:', error.message); + } + + try { + const convosIndex = client.index('convos'); + await deleteDocumentsWithoutUserField(convosIndex, 'convos'); + } catch (error) { + logger.debug('[indexSync] Could not clean up convos:', error.message); + } + + logger.info('[indexSync] Orphaned documents cleaned up without forcing resync.'); + } + + if (settingsUpdated) { + logger.info('[indexSync] Index settings updated. Full re-sync will be triggered.'); + } } catch (error) { logger.error('[indexSync] Error ensuring filterable attributes:', error); } - return false; + return { settingsUpdated, orphanedDocsFound: hasOrphanedDocs }; } /** @@ -121,14 +198,17 @@ async function performSync() { } /** Ensures indexes have proper filterable attributes configured */ - const configUpdated = await ensureFilterableAttributes(client); + const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } = + await ensureFilterableAttributes(client); let messagesSync = false; let convosSync = false; - // If configuration was just updated or documents are missing user field, force a full re-sync - if (configUpdated) { - logger.info('[indexSync] Forcing full re-sync to ensure user field is properly indexed...'); + // Only reset flags if settings were actually updated (not just for orphaned doc cleanup) + if (settingsUpdated) { + logger.info( + '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...', + ); // Reset sync flags to force full re-sync await Message.collection.updateMany({ _meiliIndex: true }, { $set: { _meiliIndex: false } }); @@ -140,7 +220,7 @@ async function performSync() { // Check if we need to sync messages const messageProgress = await Message.getSyncProgress(); - if (!messageProgress.isComplete || configUpdated) { + if (!messageProgress.isComplete || settingsUpdated) { logger.info( `[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`, ); @@ -167,7 +247,7 @@ async function performSync() { // Check if we need to sync conversations const convoProgress = await Conversation.getSyncProgress(); - if (!convoProgress.isComplete || configUpdated) { + if (!convoProgress.isComplete || settingsUpdated) { logger.info( `[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`, ); @@ -204,22 +284,22 @@ async function indexSync() { logger.info('[indexSync] Starting index synchronization check...'); + // Get or create FlowStateManager instance + const flowsCache = getLogStores(CacheKeys.FLOWS); + if (!flowsCache) { + logger.warn('[indexSync] Flows cache not available, falling back to direct sync'); + return await performSync(); + } + + const flowManager = new FlowStateManager(flowsCache, { + ttl: 60000 * 10, // 10 minutes TTL for sync operations + }); + + // Use a unique flow ID for the sync operation + const flowId = 'meili-index-sync'; + const flowType = 'MEILI_SYNC'; + try { - // Get or create FlowStateManager instance - const flowsCache = getLogStores(CacheKeys.FLOWS); - if (!flowsCache) { - logger.warn('[indexSync] Flows cache not available, falling back to direct sync'); - return await performSync(); - } - - const flowManager = new FlowStateManager(flowsCache, { - ttl: 60000 * 10, // 10 minutes TTL for sync operations - }); - - // Use a unique flow ID for the sync operation - const flowId = 'meili-index-sync'; - const flowType = 'MEILI_SYNC'; - // This will only execute the handler if no other instance is running the sync const result = await flowManager.createFlowWithHandler(flowId, flowType, performSync); @@ -251,6 +331,18 @@ async function indexSync() { } else { logger.error('[indexSync] error', err); } + } finally { + // Always clean up flow state after completion or error + // Skip cleanup only if flow already exists (another instance is handling it) + try { + const flowState = await flowManager.getFlowState(flowId, flowType); + if (flowState) { + await flowManager.deleteFlow(flowId, flowType); + logger.debug('[indexSync] Flow state cleaned up'); + } + } catch (cleanupErr) { + logger.debug('[indexSync] Could not clean up flow state:', cleanupErr.message); + } } } From 31a283a4fe8fb45a92a4e83205b88268c759d2f2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 5 Oct 2025 20:34:05 -0400 Subject: [PATCH 012/272] =?UTF-8?q?=F0=9F=94=8D=20feat:=20Add=20Serper=20a?= =?UTF-8?q?s=20Scraper=20Provider=20and=20Firecrawl=20Version=20Support=20?= =?UTF-8?q?(#9984)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 chore: Update @librechat/agents to v2.4.84 in package.json and package-lock.json * feat: Serper as new scraperProvider for Web Search and add firecrawlVersion support * fix: TWebSearchKeys and ensure unique API keys extraction * chore: Add build:packages script to streamline package builds --- api/package.json | 2 +- api/server/routes/config.js | 6 +- api/typedefs.js | 2 +- .../SidePanel/Agents/Search/ApiKeyDialog.tsx | 28 ++- client/src/locales/en/translation.json | 2 + package-lock.json | 10 +- package.json | 1 + packages/api/package.json | 2 +- packages/api/src/web/web.spec.ts | 32 ++-- packages/api/src/web/web.ts | 10 +- packages/data-provider/src/config.ts | 7 +- packages/data-schemas/src/app/web.spec.ts | 173 ++++++++++++++++++ packages/data-schemas/src/app/web.ts | 18 +- packages/data-schemas/src/types/web.ts | 1 + 14 files changed, 247 insertions(+), 47 deletions(-) create mode 100644 packages/data-schemas/src/app/web.spec.ts diff --git a/api/package.json b/api/package.json index b856fc0925..01c931a652 100644 --- a/api/package.json +++ b/api/package.json @@ -48,7 +48,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.83", + "@librechat/agents": "^2.4.84", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/routes/config.js b/api/server/routes/config.js index ec38593fe8..8674769643 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -156,7 +156,7 @@ router.get('/', async function (req, res) { if ( webSearchConfig != null && (webSearchConfig.searchProvider || - webSearchConfig.scraperType || + webSearchConfig.scraperProvider || webSearchConfig.rerankerType) ) { payload.webSearch = {}; @@ -165,8 +165,8 @@ router.get('/', async function (req, res) { if (webSearchConfig?.searchProvider) { payload.webSearch.searchProvider = webSearchConfig.searchProvider; } - if (webSearchConfig?.scraperType) { - payload.webSearch.scraperType = webSearchConfig.scraperType; + if (webSearchConfig?.scraperProvider) { + payload.webSearch.scraperProvider = webSearchConfig.scraperProvider; } if (webSearchConfig?.rerankerType) { payload.webSearch.rerankerType = webSearchConfig.rerankerType; diff --git a/api/typedefs.js b/api/typedefs.js index 260eb84bfe..a51e0ebaff 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -1054,7 +1054,7 @@ /** * @exports TWebSearchKeys - * @typedef {import('librechat-data-provider').TWebSearchKeys} TWebSearchKeys + * @typedef {import('@librechat/data-schemas').TWebSearchKeys} TWebSearchKeys * @memberof typedefs */ diff --git a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx index 84d0a30a9b..63b7e9af7b 100644 --- a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx +++ b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx @@ -2,10 +2,10 @@ import { useState } from 'react'; import { Button, OGDialog, OGDialogTemplate } from '@librechat/client'; import { AuthType, - SearchCategories, RerankerTypes, SearchProviders, - ScraperTypes, + ScraperProviders, + SearchCategories, } from 'librechat-data-provider'; import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool'; import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form'; @@ -45,7 +45,9 @@ export default function ApiKeyDialog({ const [selectedReranker, setSelectedReranker] = useState( config?.webSearch?.rerankerType || RerankerTypes.JINA, ); - const [selectedScraper, setSelectedScraper] = useState(ScraperTypes.FIRECRAWL); + const [selectedScraper, setSelectedScraper] = useState( + config?.webSearch?.scraperProvider || ScraperProviders.FIRECRAWL, + ); const providerOptions: DropdownOption[] = [ { @@ -119,7 +121,7 @@ export default function ApiKeyDialog({ const scraperOptions: DropdownOption[] = [ { - key: ScraperTypes.FIRECRAWL, + key: ScraperProviders.FIRECRAWL, label: localize('com_ui_web_search_scraper_firecrawl'), inputs: { firecrawlApiUrl: { @@ -136,6 +138,20 @@ export default function ApiKeyDialog({ }, }, }, + { + key: ScraperProviders.SERPER, + label: localize('com_ui_web_search_scraper_serper'), + inputs: { + serperApiKey: { + placeholder: localize('com_ui_enter_api_key'), + type: 'password' as const, + link: { + url: 'https://serper.dev/api-keys', + text: localize('com_ui_web_search_scraper_serper_key'), + }, + }, + }, + }, ]; const [dropdownOpen, setDropdownOpen] = useState({ @@ -157,7 +173,7 @@ export default function ApiKeyDialog({ }; const handleScraperChange = (key: string) => { - setSelectedScraper(key as ScraperTypes); + setSelectedScraper(key as ScraperProviders); }; return ( @@ -198,7 +214,7 @@ export default function ApiKeyDialog({ selectedKey={selectedScraper} onSelectionChange={handleScraperChange} dropdownOptions={scraperOptions} - showDropdown={!config?.webSearch?.scraperType} + showDropdown={!config?.webSearch?.scraperProvider} register={register} dropdownOpen={dropdownOpen.scraper} setDropdownOpen={(open) => diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index b1bc9ba96e..0facdb493a 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1268,6 +1268,8 @@ "com_ui_web_search_scraper": "Scraper", "com_ui_web_search_scraper_firecrawl": "Firecrawl API", "com_ui_web_search_scraper_firecrawl_key": "Get your Firecrawl API key", + "com_ui_web_search_scraper_serper": "Serper Scrape API", + "com_ui_web_search_scraper_serper_key": "Get your Serper API key", "com_ui_web_search_searxng_api_key": "Enter SearXNG API Key (optional)", "com_ui_web_search_searxng_instance_url": "SearXNG Instance URL", "com_ui_web_searching": "Searching the web", diff --git a/package-lock.json b/package-lock.json index 06ad223183..019317a2ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.83", + "@librechat/agents": "^2.4.84", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -21522,9 +21522,9 @@ } }, "node_modules/@librechat/agents": { - "version": "2.4.83", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.83.tgz", - "integrity": "sha512-xOspD4jegd7wpjWQhOieOso2LrXsNRyHNYEIIuCuk2eUZkxJU+1Rny0XzukhenTOG8P4bpne9XoVxxxZ0W5duA==", + "version": "2.4.84", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.84.tgz", + "integrity": "sha512-wOPqv5yQfhkuBZ29FrJGUdDMCIvcnqUAigFeoPU8QOeqi+S9rRobx2+2D3+JbbgSsDL5yO7SyxGEHkQ7A6xZDQ==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -51336,7 +51336,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", - "@librechat/agents": "^2.4.83", + "@librechat/agents": "^2.4.84", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", diff --git a/package.json b/package.json index 693de111de..458f4d2985 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "build:api": "cd packages/api && npm run build", "build:data-schemas": "cd packages/data-schemas && npm run build", "build:client-package": "cd packages/client && npm run build", + "build:packages": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && npm run build:client-package", "frontend": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && npm run build:client-package && cd client && npm run build", "frontend:ci": "npm run build:data-provider && npm run build:client-package && cd client && npm run build:ci", "frontend:dev": "cd client && npm run dev", diff --git a/packages/api/package.json b/packages/api/package.json index a983aca87a..3ea7d343e4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -80,7 +80,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", - "@librechat/agents": "^2.4.83", + "@librechat/agents": "^2.4.84", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", diff --git a/packages/api/src/web/web.spec.ts b/packages/api/src/web/web.spec.ts index e0c66047c1..c7bb3f4962 100644 --- a/packages/api/src/web/web.spec.ts +++ b/packages/api/src/web/web.spec.ts @@ -1,11 +1,11 @@ import { webSearchAuth } from '@librechat/data-schemas'; import { SafeSearchTypes, AuthType } from 'librechat-data-provider'; import type { - ScraperTypes, + ScraperProviders, + TWebSearchConfig, + SearchProviders, TCustomConfig, RerankerTypes, - SearchProviders, - TWebSearchConfig, } from 'librechat-data-provider'; import { loadWebSearchAuth, extractWebSearchEnvVars } from './web'; @@ -119,7 +119,7 @@ describe('web.ts', () => { } expect(result.authResult).toHaveProperty('searchProvider', 'serper'); - expect(result.authResult).toHaveProperty('scraperType', 'firecrawl'); + expect(result.authResult).toHaveProperty('scraperProvider', 'firecrawl'); expect(['jina', 'cohere']).toContain(result.authResult.rerankerType as string); }); @@ -288,7 +288,7 @@ describe('web.ts', () => { // Check that the correct service types are set expect(result.authResult.searchProvider).toBe('serper' as SearchProviders); - expect(result.authResult.scraperType).toBe('firecrawl' as ScraperTypes); + expect(result.authResult.scraperProvider).toBe('firecrawl' as ScraperProviders); // One of the rerankers should be set expect(['jina', 'cohere']).toContain(result.authResult.rerankerType as string); }); @@ -330,7 +330,7 @@ describe('web.ts', () => { // Should have set values for all categories expect(result.authResult.searchProvider).toBeDefined(); - expect(result.authResult.scraperType).toBeDefined(); + expect(result.authResult.scraperProvider).toBeDefined(); expect(result.authResult.rerankerType).toBeDefined(); }); @@ -359,7 +359,7 @@ describe('web.ts', () => { safeSearch: SafeSearchTypes.MODERATE, // Specify which services to use searchProvider: 'serper' as SearchProviders, - scraperType: 'firecrawl' as ScraperTypes, + scraperProvider: 'firecrawl' as ScraperProviders, rerankerType: 'jina' as RerankerTypes, }; @@ -394,7 +394,7 @@ describe('web.ts', () => { expect(result.authResult).toHaveProperty('firecrawlApiUrl'); expect(result.authResult).toHaveProperty('jinaApiKey'); expect(result.authResult).toHaveProperty('searchProvider'); - expect(result.authResult).toHaveProperty('scraperType'); + expect(result.authResult).toHaveProperty('scraperProvider'); expect(result.authResult).toHaveProperty('rerankerType'); expect(result.authenticated).toBe(true); @@ -419,7 +419,7 @@ describe('web.ts', () => { expect(result.authResult).toHaveProperty('firecrawlApiUrl', 'https://api.firecrawl.dev'); expect(result.authResult).toHaveProperty('jinaApiKey', 'system-jina-key'); expect(result.authResult).toHaveProperty('searchProvider', 'serper'); - expect(result.authResult).toHaveProperty('scraperType', 'firecrawl'); + expect(result.authResult).toHaveProperty('scraperProvider', 'firecrawl'); expect(result.authResult).toHaveProperty('rerankerType', 'jina'); // Restore original env @@ -452,7 +452,7 @@ describe('web.ts', () => { safeSearch: SafeSearchTypes.MODERATE, // Specify which services to use searchProvider: 'serper' as SearchProviders, - scraperType: 'firecrawl' as ScraperTypes, + scraperProvider: 'firecrawl' as ScraperProviders, rerankerType: 'jina' as RerankerTypes, // Only Jina will be checked }; @@ -492,7 +492,7 @@ describe('web.ts', () => { // Verify the service types are set correctly expect(result.authResult).toHaveProperty('searchProvider', 'serper'); - expect(result.authResult).toHaveProperty('scraperType', 'firecrawl'); + expect(result.authResult).toHaveProperty('scraperProvider', 'firecrawl'); expect(result.authResult).toHaveProperty('rerankerType', 'jina'); // Restore original env @@ -722,8 +722,8 @@ describe('web.ts', () => { expect(providerCalls.length).toBe(1); }); - it('should only check the specified scraperType', async () => { - // Initialize a webSearchConfig with a specific scraperType + it('should only check the specified scraperProvider', async () => { + // Initialize a webSearchConfig with a specific scraperProvider const webSearchConfig: TCustomConfig['webSearch'] = { serperApiKey: '${SERPER_API_KEY}', searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}', @@ -734,7 +734,7 @@ describe('web.ts', () => { jinaApiUrl: '${JINA_API_URL}', cohereApiKey: '${COHERE_API_KEY}', safeSearch: SafeSearchTypes.MODERATE, - scraperType: 'firecrawl' as ScraperTypes, + scraperProvider: 'firecrawl' as ScraperProviders, }; // Mock successful authentication @@ -754,7 +754,7 @@ describe('web.ts', () => { }); expect(result.authenticated).toBe(true); - expect(result.authResult.scraperType).toBe('firecrawl'); + expect(result.authResult.scraperProvider).toBe('firecrawl'); // Verify that only FIRECRAWL_API_KEY and FIRECRAWL_API_URL were requested for the scrapers category const scraperCalls = mockLoadAuthValues.mock.calls.filter((call) => @@ -933,7 +933,7 @@ describe('web.ts', () => { // Should have set values for all categories expect(result.authResult.searchProvider).toBeDefined(); - expect(result.authResult.scraperType).toBeDefined(); + expect(result.authResult.scraperProvider).toBeDefined(); expect(result.authResult.rerankerType).toBeDefined(); }); diff --git a/packages/api/src/web/web.ts b/packages/api/src/web/web.ts index da34114bc5..ad172e187f 100644 --- a/packages/api/src/web/web.ts +++ b/packages/api/src/web/web.ts @@ -4,14 +4,14 @@ import { SearchCategories, extractVariableName, } from 'librechat-data-provider'; +import { webSearchAuth } from '@librechat/data-schemas'; import type { - ScraperTypes, RerankerTypes, TCustomConfig, SearchProviders, + ScraperProviders, TWebSearchConfig, } from 'librechat-data-provider'; -import { webSearchAuth } from '@librechat/data-schemas'; import type { TWebSearchKeys, TWebSearchCategories } from '@librechat/data-schemas'; export function extractWebSearchEnvVars({ @@ -88,8 +88,8 @@ export async function loadWebSearchAuth({ let specificService: ServiceType | undefined; if (category === SearchCategories.PROVIDERS && webSearchConfig?.searchProvider) { specificService = webSearchConfig.searchProvider as unknown as ServiceType; - } else if (category === SearchCategories.SCRAPERS && webSearchConfig?.scraperType) { - specificService = webSearchConfig.scraperType as unknown as ServiceType; + } else if (category === SearchCategories.SCRAPERS && webSearchConfig?.scraperProvider) { + specificService = webSearchConfig.scraperProvider as unknown as ServiceType; } else if (category === SearchCategories.RERANKERS && webSearchConfig?.rerankerType) { specificService = webSearchConfig.rerankerType as unknown as ServiceType; } @@ -165,7 +165,7 @@ export async function loadWebSearchAuth({ if (category === SearchCategories.PROVIDERS) { authResult.searchProvider = service as SearchProviders; } else if (category === SearchCategories.SCRAPERS) { - authResult.scraperType = service as ScraperTypes; + authResult.scraperProvider = service as ScraperProviders; } else if (category === SearchCategories.RERANKERS) { authResult.rerankerType = service as RerankerTypes; } diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 4e318dc709..d212f559fe 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -650,7 +650,7 @@ export type TStartupConfig = { minPasswordLength?: number; webSearch?: { searchProvider?: SearchProviders; - scraperType?: ScraperTypes; + scraperProvider?: ScraperProviders; rerankerType?: RerankerTypes; }; mcpServers?: Record< @@ -689,7 +689,7 @@ export enum SearchProviders { SEARXNG = 'searxng', } -export enum ScraperTypes { +export enum ScraperProviders { FIRECRAWL = 'firecrawl', SERPER = 'serper', } @@ -711,11 +711,12 @@ export const webSearchSchema = z.object({ searxngApiKey: z.string().optional().default('${SEARXNG_API_KEY}'), firecrawlApiKey: z.string().optional().default('${FIRECRAWL_API_KEY}'), firecrawlApiUrl: z.string().optional().default('${FIRECRAWL_API_URL}'), + firecrawlVersion: z.string().optional().default('${FIRECRAWL_VERSION}'), jinaApiKey: z.string().optional().default('${JINA_API_KEY}'), jinaApiUrl: z.string().optional().default('${JINA_API_URL}'), cohereApiKey: z.string().optional().default('${COHERE_API_KEY}'), searchProvider: z.nativeEnum(SearchProviders).optional(), - scraperType: z.nativeEnum(ScraperTypes).optional(), + scraperProvider: z.nativeEnum(ScraperProviders).optional(), rerankerType: z.nativeEnum(RerankerTypes).optional(), scraperTimeout: z.number().optional(), safeSearch: z.nativeEnum(SafeSearchTypes).default(SafeSearchTypes.MODERATE), diff --git a/packages/data-schemas/src/app/web.spec.ts b/packages/data-schemas/src/app/web.spec.ts new file mode 100644 index 0000000000..787d7809a3 --- /dev/null +++ b/packages/data-schemas/src/app/web.spec.ts @@ -0,0 +1,173 @@ +import { SafeSearchTypes, SearchProviders, ScraperProviders } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; +import { loadWebSearchConfig } from './web'; + +describe('loadWebSearchConfig', () => { + describe('firecrawlVersion', () => { + it('should use provided firecrawlVersion when specified', () => { + const config: TCustomConfig['webSearch'] = { + firecrawlVersion: 'v2', + }; + + const result = loadWebSearchConfig(config); + + expect(result?.firecrawlVersion).toBe('v2'); + }); + + it('should default to ${FIRECRAWL_VERSION} when not provided', () => { + const config: TCustomConfig['webSearch'] = {}; + + const result = loadWebSearchConfig(config); + + expect(result?.firecrawlVersion).toBe('${FIRECRAWL_VERSION}'); + }); + + it('should default to ${FIRECRAWL_VERSION} when config is undefined', () => { + const result = loadWebSearchConfig(undefined); + + expect(result?.firecrawlVersion).toBe('${FIRECRAWL_VERSION}'); + }); + + it('should preserve custom firecrawlVersion value', () => { + const config: TCustomConfig['webSearch'] = { + firecrawlVersion: 'v1', + }; + + const result = loadWebSearchConfig(config); + + expect(result?.firecrawlVersion).toBe('v1'); + }); + }); + + describe('all config fields', () => { + it('should apply defaults for all fields when config is empty', () => { + const config: TCustomConfig['webSearch'] = {}; + + const result = loadWebSearchConfig(config); + + expect(result).toEqual({ + serperApiKey: '${SERPER_API_KEY}', + searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}', + searxngApiKey: '${SEARXNG_API_KEY}', + firecrawlApiKey: '${FIRECRAWL_API_KEY}', + firecrawlApiUrl: '${FIRECRAWL_API_URL}', + firecrawlVersion: '${FIRECRAWL_VERSION}', + jinaApiKey: '${JINA_API_KEY}', + jinaApiUrl: '${JINA_API_URL}', + cohereApiKey: '${COHERE_API_KEY}', + safeSearch: SafeSearchTypes.MODERATE, + }); + }); + + it('should preserve provided config values and merge with defaults', () => { + const config: TCustomConfig['webSearch'] = { + serperApiKey: 'custom-serper-key', + firecrawlApiKey: 'custom-firecrawl-key', + firecrawlVersion: 'v2', + safeSearch: SafeSearchTypes.STRICT, + }; + + const result = loadWebSearchConfig(config); + + expect(result?.serperApiKey).toBe('custom-serper-key'); + expect(result?.firecrawlApiKey).toBe('custom-firecrawl-key'); + expect(result?.firecrawlVersion).toBe('v2'); + expect(result?.safeSearch).toBe(SafeSearchTypes.STRICT); + expect(result?.jinaApiKey).toBe('${JINA_API_KEY}'); + }); + + it('should preserve additional fields from input config', () => { + const config: TCustomConfig['webSearch'] = { + serperApiKey: 'test-key', + scraperProvider: ScraperProviders.SERPER, + searchProvider: SearchProviders.SERPER, + }; + + const result = loadWebSearchConfig(config); + + expect(result?.scraperProvider).toBe('serper'); + expect(result?.searchProvider).toBe('serper'); + expect(result?.serperApiKey).toBe('test-key'); + }); + }); + + describe('safeSearch', () => { + it('should default to MODERATE when not provided', () => { + const config: TCustomConfig['webSearch'] = {}; + + const result = loadWebSearchConfig(config); + + expect(result?.safeSearch).toBe(SafeSearchTypes.MODERATE); + }); + + it('should preserve OFF value', () => { + const config: TCustomConfig['webSearch'] = { + safeSearch: SafeSearchTypes.OFF, + }; + + const result = loadWebSearchConfig(config); + + expect(result?.safeSearch).toBe(SafeSearchTypes.OFF); + }); + + it('should preserve STRICT value', () => { + const config: TCustomConfig['webSearch'] = { + safeSearch: SafeSearchTypes.STRICT, + }; + + const result = loadWebSearchConfig(config); + + expect(result?.safeSearch).toBe(SafeSearchTypes.STRICT); + }); + }); + + describe('API keys', () => { + it('should apply default placeholders for all API keys', () => { + const result = loadWebSearchConfig({}); + + expect(result?.serperApiKey).toBe('${SERPER_API_KEY}'); + expect(result?.searxngApiKey).toBe('${SEARXNG_API_KEY}'); + expect(result?.firecrawlApiKey).toBe('${FIRECRAWL_API_KEY}'); + expect(result?.jinaApiKey).toBe('${JINA_API_KEY}'); + expect(result?.cohereApiKey).toBe('${COHERE_API_KEY}'); + }); + + it('should preserve custom API keys', () => { + const config: TCustomConfig['webSearch'] = { + serperApiKey: 'actual-serper-key', + jinaApiKey: 'actual-jina-key', + cohereApiKey: 'actual-cohere-key', + }; + + const result = loadWebSearchConfig(config); + + expect(result?.serperApiKey).toBe('actual-serper-key'); + expect(result?.jinaApiKey).toBe('actual-jina-key'); + expect(result?.cohereApiKey).toBe('actual-cohere-key'); + }); + }); + + describe('URLs', () => { + it('should apply default placeholders for URLs', () => { + const result = loadWebSearchConfig({}); + + expect(result?.searxngInstanceUrl).toBe('${SEARXNG_INSTANCE_URL}'); + expect(result?.firecrawlApiUrl).toBe('${FIRECRAWL_API_URL}'); + expect(result?.jinaApiUrl).toBe('${JINA_API_URL}'); + }); + + it('should preserve custom URLs', () => { + const config: TCustomConfig['webSearch'] = { + searxngInstanceUrl: 'https://custom-searxng.com', + firecrawlApiUrl: 'https://custom-firecrawl.com', + jinaApiUrl: 'https://custom-jina.com', + }; + + const result = loadWebSearchConfig(config); + + expect(result?.searxngInstanceUrl).toBe('https://custom-searxng.com'); + expect(result?.firecrawlApiUrl).toBe('https://custom-firecrawl.com'); + expect(result?.jinaApiUrl).toBe('https://custom-jina.com'); + }); + }); +}); diff --git a/packages/data-schemas/src/app/web.ts b/packages/data-schemas/src/app/web.ts index 5fbf2c674e..a61e1f1611 100644 --- a/packages/data-schemas/src/app/web.ts +++ b/packages/data-schemas/src/app/web.ts @@ -18,6 +18,10 @@ export const webSearchAuth = { firecrawlApiKey: 1 as const, /** Optional (0) */ firecrawlApiUrl: 0 as const, + firecrawlVersion: 0 as const, + }, + serper: { + serperApiKey: 1 as const, }, }, rerankers: { @@ -31,10 +35,10 @@ export const webSearchAuth = { }; /** - * Extracts all API keys from the webSearchAuth configuration object + * Extracts all unique API keys from the webSearchAuth configuration object */ export function getWebSearchKeys(): TWebSearchKeys[] { - const keys: TWebSearchKeys[] = []; + const keysSet = new Set(); // Iterate through each category (providers, scrapers, rerankers) for (const category of Object.keys(webSearchAuth)) { @@ -44,14 +48,14 @@ export function getWebSearchKeys(): TWebSearchKeys[] { for (const service of Object.keys(categoryObj)) { const serviceObj = categoryObj[service as keyof typeof categoryObj]; - // Extract the API keys from the service + // Extract the API keys from the service and add to set for deduplication for (const key of Object.keys(serviceObj)) { - keys.push(key as TWebSearchKeys); + keysSet.add(key as TWebSearchKeys); } } } - return keys; + return Array.from(keysSet); } export const webSearchKeys: TWebSearchKeys[] = getWebSearchKeys(); @@ -64,6 +68,7 @@ export function loadWebSearchConfig( const searxngApiKey = config?.searxngApiKey ?? '${SEARXNG_API_KEY}'; const firecrawlApiKey = config?.firecrawlApiKey ?? '${FIRECRAWL_API_KEY}'; const firecrawlApiUrl = config?.firecrawlApiUrl ?? '${FIRECRAWL_API_URL}'; + const firecrawlVersion = config?.firecrawlVersion ?? '${FIRECRAWL_VERSION}'; const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}'; const jinaApiUrl = config?.jinaApiUrl ?? '${JINA_API_URL}'; const cohereApiKey = config?.cohereApiKey ?? '${COHERE_API_KEY}'; @@ -76,9 +81,10 @@ export function loadWebSearchConfig( jinaApiUrl, cohereApiKey, serperApiKey, - searxngInstanceUrl, searxngApiKey, firecrawlApiKey, firecrawlApiUrl, + firecrawlVersion, + searxngInstanceUrl, }; } diff --git a/packages/data-schemas/src/types/web.ts b/packages/data-schemas/src/types/web.ts index c424be48d2..a9cc1f0cc6 100644 --- a/packages/data-schemas/src/types/web.ts +++ b/packages/data-schemas/src/types/web.ts @@ -6,6 +6,7 @@ export type TWebSearchKeys = | 'searxngApiKey' | 'firecrawlApiKey' | 'firecrawlApiUrl' + | 'firecrawlVersion' | 'jinaApiKey' | 'jinaApiUrl' | 'cohereApiKey'; From 9c77f53454f943e6036ab703912c29215b282826 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 5 Oct 2025 22:41:40 -0400 Subject: [PATCH 013/272] =?UTF-8?q?=F0=9F=94=80=20refactor:=20Only=20Clean?= =?UTF-8?q?up=20Meili=20Sync=20if=20actually=20Synced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/db/indexSync.js | 200 +++++++++++++++++++++++--------------------- 1 file changed, 103 insertions(+), 97 deletions(-) diff --git a/api/db/indexSync.js b/api/db/indexSync.js index 090c32cca8..c86598d108 100644 --- a/api/db/indexSync.js +++ b/api/db/indexSync.js @@ -183,95 +183,111 @@ async function ensureFilterableAttributes(client) { /** * Performs the actual sync operations for messages and conversations + * @param {FlowStateManager} flowManager - Flow state manager instance + * @param {string} flowId - Flow identifier + * @param {string} flowType - Flow type */ -async function performSync() { - const client = MeiliSearchClient.getInstance(); +async function performSync(flowManager, flowId, flowType) { + try { + const client = MeiliSearchClient.getInstance(); - const { status } = await client.health(); - if (status !== 'available') { - throw new Error('Meilisearch not available'); - } - - if (indexingDisabled === true) { - logger.info('[indexSync] Indexing is disabled, skipping...'); - return { messagesSync: false, convosSync: false }; - } - - /** Ensures indexes have proper filterable attributes configured */ - const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } = - await ensureFilterableAttributes(client); - - let messagesSync = false; - let convosSync = false; - - // Only reset flags if settings were actually updated (not just for orphaned doc cleanup) - if (settingsUpdated) { - logger.info( - '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...', - ); - - // Reset sync flags to force full re-sync - await Message.collection.updateMany({ _meiliIndex: true }, { $set: { _meiliIndex: false } }); - await Conversation.collection.updateMany( - { _meiliIndex: true }, - { $set: { _meiliIndex: false } }, - ); - } - - // Check if we need to sync messages - const messageProgress = await Message.getSyncProgress(); - if (!messageProgress.isComplete || settingsUpdated) { - logger.info( - `[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`, - ); - - // Check if we should do a full sync or incremental - const messageCount = await Message.countDocuments(); - const messagesIndexed = messageProgress.totalProcessed; - const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); - - if (messageCount - messagesIndexed > syncThreshold) { - logger.info('[indexSync] Starting full message sync due to large difference'); - await Message.syncWithMeili(); - messagesSync = true; - } else if (messageCount !== messagesIndexed) { - logger.warn('[indexSync] Messages out of sync, performing incremental sync'); - await Message.syncWithMeili(); - messagesSync = true; + const { status } = await client.health(); + if (status !== 'available') { + throw new Error('Meilisearch not available'); } - } else { - logger.info( - `[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`, - ); - } - // Check if we need to sync conversations - const convoProgress = await Conversation.getSyncProgress(); - if (!convoProgress.isComplete || settingsUpdated) { - logger.info( - `[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`, - ); - - const convoCount = await Conversation.countDocuments(); - const convosIndexed = convoProgress.totalProcessed; - const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); - - if (convoCount - convosIndexed > syncThreshold) { - logger.info('[indexSync] Starting full conversation sync due to large difference'); - await Conversation.syncWithMeili(); - convosSync = true; - } else if (convoCount !== convosIndexed) { - logger.warn('[indexSync] Convos out of sync, performing incremental sync'); - await Conversation.syncWithMeili(); - convosSync = true; + if (indexingDisabled === true) { + logger.info('[indexSync] Indexing is disabled, skipping...'); + return { messagesSync: false, convosSync: false }; } - } else { - logger.info( - `[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`, - ); - } - return { messagesSync, convosSync }; + /** Ensures indexes have proper filterable attributes configured */ + const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } = + await ensureFilterableAttributes(client); + + let messagesSync = false; + let convosSync = false; + + // Only reset flags if settings were actually updated (not just for orphaned doc cleanup) + if (settingsUpdated) { + logger.info( + '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...', + ); + + // Reset sync flags to force full re-sync + await Message.collection.updateMany({ _meiliIndex: true }, { $set: { _meiliIndex: false } }); + await Conversation.collection.updateMany( + { _meiliIndex: true }, + { $set: { _meiliIndex: false } }, + ); + } + + // Check if we need to sync messages + const messageProgress = await Message.getSyncProgress(); + if (!messageProgress.isComplete || settingsUpdated) { + logger.info( + `[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`, + ); + + // Check if we should do a full sync or incremental + const messageCount = await Message.countDocuments(); + const messagesIndexed = messageProgress.totalProcessed; + const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); + + if (messageCount - messagesIndexed > syncThreshold) { + logger.info('[indexSync] Starting full message sync due to large difference'); + await Message.syncWithMeili(); + messagesSync = true; + } else if (messageCount !== messagesIndexed) { + logger.warn('[indexSync] Messages out of sync, performing incremental sync'); + await Message.syncWithMeili(); + messagesSync = true; + } + } else { + logger.info( + `[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`, + ); + } + + // Check if we need to sync conversations + const convoProgress = await Conversation.getSyncProgress(); + if (!convoProgress.isComplete || settingsUpdated) { + logger.info( + `[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`, + ); + + const convoCount = await Conversation.countDocuments(); + const convosIndexed = convoProgress.totalProcessed; + const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); + + if (convoCount - convosIndexed > syncThreshold) { + logger.info('[indexSync] Starting full conversation sync due to large difference'); + await Conversation.syncWithMeili(); + convosSync = true; + } else if (convoCount !== convosIndexed) { + logger.warn('[indexSync] Convos out of sync, performing incremental sync'); + await Conversation.syncWithMeili(); + convosSync = true; + } + } else { + logger.info( + `[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`, + ); + } + + return { messagesSync, convosSync }; + } finally { + if (indexingDisabled === true) { + logger.info('[indexSync] Indexing is disabled, skipping cleanup...'); + } else if (flowManager && flowId && flowType) { + try { + await flowManager.deleteFlow(flowId, flowType); + logger.debug('[indexSync] Flow state cleaned up'); + } catch (cleanupErr) { + logger.debug('[indexSync] Could not clean up flow state:', cleanupErr.message); + } + } + } } /** @@ -288,7 +304,7 @@ async function indexSync() { const flowsCache = getLogStores(CacheKeys.FLOWS); if (!flowsCache) { logger.warn('[indexSync] Flows cache not available, falling back to direct sync'); - return await performSync(); + return await performSync(null, null, null); } const flowManager = new FlowStateManager(flowsCache, { @@ -301,7 +317,9 @@ async function indexSync() { try { // This will only execute the handler if no other instance is running the sync - const result = await flowManager.createFlowWithHandler(flowId, flowType, performSync); + const result = await flowManager.createFlowWithHandler(flowId, flowType, () => + performSync(flowManager, flowId, flowType), + ); if (result.messagesSync || result.convosSync) { logger.info('[indexSync] Sync completed successfully'); @@ -331,18 +349,6 @@ async function indexSync() { } else { logger.error('[indexSync] error', err); } - } finally { - // Always clean up flow state after completion or error - // Skip cleanup only if flow already exists (another instance is handling it) - try { - const flowState = await flowManager.getFlowState(flowId, flowType); - if (flowState) { - await flowManager.deleteFlow(flowId, flowType); - logger.debug('[indexSync] Flow state cleaned up'); - } - } catch (cleanupErr) { - logger.debug('[indexSync] Could not clean up flow state:', cleanupErr.message); - } } } From bcd97aad2ff9c60840bba7239ebd764e925eb4d5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 6 Oct 2025 17:30:16 -0400 Subject: [PATCH 014/272] =?UTF-8?q?=F0=9F=93=8E=20feat:=20Direct=20Provide?= =?UTF-8?q?r=20Attachment=20Support=20for=20Multimodal=20Content=20(#9994)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📎 feat: Direct Provider Attachment Support for Multimodal Content * 📑 feat: Anthropic Direct Provider Upload (#9072) * feat: implement Anthropic native PDF support with document preservation - Add comprehensive debug logging throughout PDF processing pipeline - Refactor attachment processing to separate image and document handling - Create distinct addImageURLs(), addDocuments(), and processAttachments() methods - Fix critical bugs in stream handling and parameter passing - Add streamToBuffer utility for proper stream-to-buffer conversion - Remove api/agents submodule from repository 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * chore: remove out of scope formatting changes * fix: stop duplication of file in chat on end of response stream * chore: bring back file search and ocr options * chore: localize upload to provider string in file menu * refactor: change createMenuItems args to fit new pattern introduced by anthropic-native-pdf-support * feat: add cache point for pdfs processed by anthropic endpoint since they are unlikely to change and should benefit from caching * feat: combine Upload Image into Upload to Provider since they both perform direct upload and change provider upload icon to reflect multimodal upload * feat: add citations support according to docs * refactor: remove redundant 'document' check since documents are handled properly by formatMessage in the agents repo now * refactor: change upload logic so anthropic endpoint isn't exempted from normal upload path using Agents for consistency with the rest of the upload logic * fix: include width and height in return from uploadLocalFile so images are correctly identified when going through an AgentUpload in addImageURLs * chore: remove client specific handling since the direct provider stuff is handled by the agent client * feat: handle documents in AgentClient so no need for change to agents repo * chore: removed unused changes * chore: remove auto generated comments from OG commit * feat: add logic for agents to use direct to provider uploads if supported (currently just anthropic) * fix: reintroduce role check to fix render error because of undefined value for Content Part * fix: actually fix render bug by using proper isCreatedByUser check and making sure our mutation of formattedMessage.content is consistent --------- Co-authored-by: Andres Restrepo Co-authored-by: Claude 📁 feat: Send Attachments Directly to Provider (OpenAI) (#9098) * refactor: change references from direct upload to direct attach to better reflect functionality since we are just using base64 encoding strategy now rather than Files/File API for sending our attachments directly to the provider, the upload nomenclature no longer makes sense. direct_attach better describes the different methods of sending attachments to providers anyways even if we later introduce direct upload support * feat: add upload to provider option for openai (and agent) ui * chore: move anthropic pdf validator over to packages/api * feat: simple pdf validation according to openai docs * feat: add provider agnostic validatePdf logic to start handling multiple endpoints * feat: add handling for openai specific documentPart formatting * refactor: move require statement to proper place at top of file * chore: add in openAI endpoint for the rest of the document handling logic * feat: add direct attach support for azureOpenAI endpoint and agents * feat: add pdf validation for azureOpenAI endpoint * refactor: unify all the endpoint checks with isDocumentSupportedEndpoint * refactor: consolidate Upload to Provider vs Upload image logic for clarity * refactor: remove anthropic from anthropic_multimodal fileType since we support multiple providers now 🗂️ feat: Send Attachments Directly to Provider (Google) (#9100) * feat: add validation for google PDFs and add google endpoint as a document supporting endpoint * feat: add proper pdf formatting for google endpoints (requires PR #14 in agents) * feat: add multimodal support for google endpoint attachments * feat: add audio file svg * fix: refactor attachments logic so multi-attachment messages work properly * feat: add video file svg * fix: allows for followup questions of uploaded multimodal attachments * fix: remove incorrect final message filtering that was breaking Attachment component rendering fix: manualy rename 'documents' to 'Documents' in git since it wasn't picked up due to case insensitivity in dir name fix: add logic so filepicker for a google agent has proper filetype filtering 🛫 refactor: Move Encoding Logic to packages/api (#9182) * refactor: move audio encode over to TS * refactor: audio encoding now functional in LC again * refactor: move video encode over to TS * refactor: move document encode over to TS * refactor: video encoding now functional in LC again * refactor: document encoding now functional in LC again * fix: extend file type options in AttachFileMenu to include 'google_multimodal' and update dependency array to include agent?.provider * feat: only accept pdfs if responses api is enabled for openai convos chore: address ESLint comments chore: add missing audio mimetype * fix: type safety for message content parts and improve null handling * chore: reorder AttachFileMenuProps for consistency and clarity * chore: import order in AttachFileMenu * fix: improve null handling for text parts in parseTextParts function * fix: remove no longer used unsupported capability error message for file uploads * fix: OpenAI Direct File Attachment Format * fix: update encodeAndFormatDocuments to support OpenAI responses API and enhance document result types * refactor: broaden providers supported for documents * feat: enhance DragDrop context and modal to support document uploads based on provider capabilities * fix: reorder import statements for consistency in video encoding module --------- Co-authored-by: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> --- api/app/clients/BaseClient.js | 115 ++++++++++- api/package.json | 2 +- api/server/controllers/agents/client.js | 2 +- api/server/services/Files/Local/crud.js | 14 +- api/server/services/Files/process.js | 5 - client/src/Providers/DragDropContext.tsx | 17 +- .../Chat/Input/Files/AttachFileChat.tsx | 17 +- .../Chat/Input/Files/AttachFileMenu.tsx | 79 ++++++-- .../Chat/Input/Files/DragDropModal.tsx | 49 ++++- .../components/Chat/Messages/Content/Part.tsx | 4 +- .../Messages/Content/Parts/LogContent.tsx | 14 +- .../hooks/Agents/useAgentToolPermissions.ts | 4 + client/src/hooks/Files/useFileHandling.ts | 7 - client/src/locales/en/translation.json | 2 +- client/src/utils/files.ts | 23 ++- client/src/utils/messages.ts | 9 +- package-lock.json | 10 +- packages/api/package.json | 2 +- packages/api/src/files/encode/audio.ts | 74 +++++++ packages/api/src/files/encode/document.ts | 108 ++++++++++ packages/api/src/files/encode/index.ts | 3 + packages/api/src/files/encode/utils.ts | 46 +++++ packages/api/src/files/encode/video.ts | 74 +++++++ packages/api/src/files/index.ts | 2 + packages/api/src/files/validation.ts | 186 ++++++++++++++++++ packages/api/src/types/files.ts | 85 ++++++++ packages/client/src/svgs/AudioPaths.tsx | 41 ++++ packages/client/src/svgs/VideoPaths.tsx | 10 + packages/client/src/svgs/index.ts | 2 + packages/data-provider/src/file-config.ts | 35 +++- packages/data-provider/src/parsers.ts | 2 +- packages/data-provider/src/schemas.ts | 55 ++++++ .../data-provider/src/types/assistants.ts | 16 +- 33 files changed, 1040 insertions(+), 74 deletions(-) create mode 100644 packages/api/src/files/encode/audio.ts create mode 100644 packages/api/src/files/encode/document.ts create mode 100644 packages/api/src/files/encode/index.ts create mode 100644 packages/api/src/files/encode/utils.ts create mode 100644 packages/api/src/files/encode/video.ts create mode 100644 packages/api/src/files/validation.ts create mode 100644 packages/client/src/svgs/AudioPaths.tsx create mode 100644 packages/client/src/svgs/VideoPaths.tsx diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 2458dc0ab3..32c76523f7 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -1,18 +1,24 @@ const crypto = require('crypto'); const fetch = require('node-fetch'); const { logger } = require('@librechat/data-schemas'); -const { getBalanceConfig } = require('@librechat/api'); const { - supportsBalanceCheck, - isAgentsEndpoint, - isParamEndpoint, - EModelEndpoint, + getBalanceConfig, + encodeAndFormatAudios, + encodeAndFormatVideos, + encodeAndFormatDocuments, +} = require('@librechat/api'); +const { + Constants, + ErrorTypes, ContentTypes, excludedKeys, - ErrorTypes, - Constants, + EModelEndpoint, + isParamEndpoint, + isAgentsEndpoint, + supportsBalanceCheck, } = require('librechat-data-provider'); const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { checkBalance } = require('~/models/balanceMethods'); const { truncateToolCallOutputs } = require('./prompts'); const { getFiles } = require('~/models/File'); @@ -1198,8 +1204,99 @@ class BaseClient { return await this.sendCompletion(payload, opts); } + async addDocuments(message, attachments) { + const documentResult = await encodeAndFormatDocuments( + this.options.req, + attachments, + { + provider: this.options.agent?.provider, + useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi, + }, + getStrategyFunctions, + ); + message.documents = + documentResult.documents && documentResult.documents.length + ? documentResult.documents + : undefined; + return documentResult.files; + } + + async addVideos(message, attachments) { + const videoResult = await encodeAndFormatVideos( + this.options.req, + attachments, + this.options.agent.provider, + getStrategyFunctions, + ); + message.videos = + videoResult.videos && videoResult.videos.length ? videoResult.videos : undefined; + return videoResult.files; + } + + async addAudios(message, attachments) { + const audioResult = await encodeAndFormatAudios( + this.options.req, + attachments, + this.options.agent.provider, + getStrategyFunctions, + ); + message.audios = + audioResult.audios && audioResult.audios.length ? audioResult.audios : undefined; + return audioResult.files; + } + + async processAttachments(message, attachments) { + const categorizedAttachments = { + images: [], + documents: [], + videos: [], + audios: [], + }; + + for (const file of attachments) { + if (file.type.startsWith('image/')) { + categorizedAttachments.images.push(file); + } else if (file.type === 'application/pdf') { + categorizedAttachments.documents.push(file); + } else if (file.type.startsWith('video/')) { + categorizedAttachments.videos.push(file); + } else if (file.type.startsWith('audio/')) { + categorizedAttachments.audios.push(file); + } + } + + const [imageFiles, documentFiles, videoFiles, audioFiles] = await Promise.all([ + categorizedAttachments.images.length > 0 + ? this.addImageURLs(message, categorizedAttachments.images) + : Promise.resolve([]), + categorizedAttachments.documents.length > 0 + ? this.addDocuments(message, categorizedAttachments.documents) + : Promise.resolve([]), + categorizedAttachments.videos.length > 0 + ? this.addVideos(message, categorizedAttachments.videos) + : Promise.resolve([]), + categorizedAttachments.audios.length > 0 + ? this.addAudios(message, categorizedAttachments.audios) + : Promise.resolve([]), + ]); + + const allFiles = [...imageFiles, ...documentFiles, ...videoFiles, ...audioFiles]; + 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; + } + /** - * * @param {TMessage[]} _messages * @returns {Promise} */ @@ -1248,7 +1345,7 @@ class BaseClient { {}, ); - await this.addImageURLs(message, files, this.visionMode); + await this.processAttachments(message, files); this.message_file_map[message.messageId] = files; return message; diff --git a/api/package.json b/api/package.json index 01c931a652..73cb0633e9 100644 --- a/api/package.json +++ b/api/package.json @@ -48,7 +48,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.84", + "@librechat/agents": "^2.4.85", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index bf32385162..a9f5543a61 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -257,7 +257,7 @@ class AgentClient extends BaseClient { }; } - const files = await this.addImageURLs( + const files = await this.processAttachments( orderedMessages[orderedMessages.length - 1], attachments, ); diff --git a/api/server/services/Files/Local/crud.js b/api/server/services/Files/Local/crud.js index 16e75ba200..d3a3a21538 100644 --- a/api/server/services/Files/Local/crud.js +++ b/api/server/services/Files/Local/crud.js @@ -4,6 +4,7 @@ const axios = require('axios'); const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint } = require('librechat-data-provider'); const { generateShortLivedToken } = require('@librechat/api'); +const { resizeImageBuffer } = require('~/server/services/Files/images/resize'); const { getBufferMetadata } = require('~/server/utils'); const paths = require('~/config/paths'); @@ -286,7 +287,18 @@ async function uploadLocalFile({ req, file, file_id }) { await fs.promises.writeFile(newPath, inputBuffer); const filepath = path.posix.join('/', 'uploads', req.user.id, path.basename(newPath)); - return { filepath, bytes }; + let height, width; + if (file.mimetype && file.mimetype.startsWith('image/')) { + try { + const { width: imgWidth, height: imgHeight } = await resizeImageBuffer(inputBuffer, 'high'); + height = imgHeight; + width = imgWidth; + } catch (error) { + logger.warn('[uploadLocalFile] Could not get image dimensions:', error.message); + } + } + + return { filepath, bytes, height, width }; } /** diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index c8221a6de5..f7220715f6 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -522,11 +522,6 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { } const isImage = file.mimetype.startsWith('image'); - if (!isImage && !tool_resource) { - /** Note: this needs to be removed when we can support files to providers */ - throw new Error('No tool resource provided for non-image agent file upload'); - } - let fileInfoMetadata; const entity_id = messageAttachment === true ? undefined : agent_id; const basePath = mime.getType(file.originalname)?.startsWith('image') ? 'images' : 'uploads'; diff --git a/client/src/Providers/DragDropContext.tsx b/client/src/Providers/DragDropContext.tsx index a86af6510b..3a5fe2924c 100644 --- a/client/src/Providers/DragDropContext.tsx +++ b/client/src/Providers/DragDropContext.tsx @@ -1,23 +1,38 @@ import React, { createContext, useContext, useMemo } from 'react'; +import type { EModelEndpoint } from 'librechat-data-provider'; +import { useGetEndpointsQuery } from '~/data-provider'; +import { getEndpointField } from '~/utils/endpoints'; import { useChatContext } from './ChatContext'; interface DragDropContextValue { conversationId: string | null | undefined; agentId: string | null | undefined; + endpoint: string | null | undefined; + endpointType?: EModelEndpoint | undefined; } const DragDropContext = createContext(undefined); export function DragDropProvider({ children }: { children: React.ReactNode }) { const { conversation } = useChatContext(); + const { data: endpointsConfig } = useGetEndpointsQuery(); + + const endpointType = useMemo(() => { + return ( + getEndpointField(endpointsConfig, conversation?.endpoint, 'type') || + (conversation?.endpoint as EModelEndpoint | undefined) + ); + }, [conversation?.endpoint, endpointsConfig]); /** Context value only created when conversation fields change */ const contextValue = useMemo( () => ({ conversationId: conversation?.conversationId, agentId: conversation?.agent_id, + endpoint: conversation?.endpoint, + endpointType: endpointType, }), - [conversation?.conversationId, conversation?.agent_id], + [conversation?.conversationId, conversation?.agent_id, conversation?.endpoint, endpointType], ); return {children}; diff --git a/client/src/components/Chat/Input/Files/AttachFileChat.tsx b/client/src/components/Chat/Input/Files/AttachFileChat.tsx index f80b8aef82..4757c598a5 100644 --- a/client/src/components/Chat/Input/Files/AttachFileChat.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileChat.tsx @@ -2,13 +2,15 @@ import { memo, useMemo } from 'react'; import { Constants, supportsFiles, + EModelEndpoint, mergeFileConfig, isAgentsEndpoint, isAssistantsEndpoint, fileConfig as defaultFileConfig, } from 'librechat-data-provider'; import type { EndpointFileConfig, TConversation } from 'librechat-data-provider'; -import { useGetFileConfig } from '~/data-provider'; +import { useGetFileConfig, useGetEndpointsQuery } from '~/data-provider'; +import { getEndpointField } from '~/utils/endpoints'; import AttachFileMenu from './AttachFileMenu'; import AttachFile from './AttachFile'; @@ -20,7 +22,7 @@ function AttachFileChat({ conversation: TConversation | null; }) { const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO; - const { endpoint, endpointType } = conversation ?? { endpoint: null }; + const { endpoint } = conversation ?? { endpoint: null }; const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]); const isAssistants = useMemo(() => isAssistantsEndpoint(endpoint), [endpoint]); @@ -28,6 +30,15 @@ function AttachFileChat({ select: (data) => mergeFileConfig(data), }); + const { data: endpointsConfig } = useGetEndpointsQuery(); + + const endpointType = useMemo(() => { + return ( + getEndpointField(endpointsConfig, endpoint, 'type') || + (endpoint as EModelEndpoint | undefined) + ); + }, [endpoint, endpointsConfig]); + const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''] as EndpointFileConfig | undefined; const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false; const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false; @@ -37,7 +48,9 @@ function AttachFileChat({ } else if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) { return ( { @@ -55,44 +70,75 @@ const AttachFileMenu = ({ overrideEndpointFileConfig: endpointFileConfig, toolResource, }); + + const { agentsConfig } = useGetAgentsConfig(); const { data: startupConfig } = useGetStartupConfig(); const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled; const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false); - const { agentsConfig } = useGetAgentsConfig(); + /** TODO: Ephemeral Agent Capabilities * Allow defining agent capabilities on a per-endpoint basis * Use definition for agents endpoint for ephemeral agents * */ const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); - const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions( + const { fileSearchAllowedByAgent, codeAllowedByAgent, provider } = useAgentToolPermissions( agentId, ephemeralAgent, ); - const handleUploadClick = (isImage?: boolean) => { + const handleUploadClick = ( + fileType?: 'image' | 'document' | 'multimodal' | 'google_multimodal', + ) => { 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 if (fileType === 'multimodal') { + inputRef.current.accept = 'image/*,.pdf,application/pdf'; + } else if (fileType === 'google_multimodal') { + inputRef.current.accept = 'image/*,.pdf,application/pdf,video/*,audio/*'; + } else { + inputRef.current.accept = ''; + } inputRef.current.click(); inputRef.current.accept = ''; }; const dropdownItems = useMemo(() => { - const createMenuItems = (onAction: (isImage?: boolean) => void) => { - const items: MenuItemProps[] = [ - { + const createMenuItems = ( + onAction: (fileType?: 'image' | 'document' | 'multimodal' | 'google_multimodal') => void, + ) => { + const items: MenuItemProps[] = []; + + const currentProvider = provider || endpoint; + + if (isDocumentSupportedProvider(endpointType || currentProvider)) { + items.push({ + label: localize('com_ui_upload_provider'), + onClick: () => { + setToolResource(undefined); + onAction( + (provider || endpoint) === EModelEndpoint.google ? 'google_multimodal' : 'multimodal', + ); + }, + icon: , + }); + } else { + items.push({ label: localize('com_ui_upload_image_input'), onClick: () => { setToolResource(undefined); - onAction(true); + onAction('image'); }, icon: , - }, - ]; + }); + } if (capabilities.contextEnabled) { items.push({ @@ -156,8 +202,11 @@ const AttachFileMenu = ({ return localItems; }, [ - capabilities, localize, + endpoint, + provider, + endpointType, + capabilities, setToolResource, setEphemeralAgent, sharePointEnabled, diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index e9992c4dcb..209972e4a8 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -1,8 +1,18 @@ import React, { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; import { OGDialog, OGDialogTemplate } from '@librechat/client'; -import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider'; -import { ImageUpIcon, FileSearch, TerminalSquareIcon, FileType2Icon } from 'lucide-react'; +import { + EToolResources, + defaultAgentCapabilities, + isDocumentSupportedProvider, +} from 'librechat-data-provider'; +import { + ImageUpIcon, + FileSearch, + FileType2Icon, + FileImageIcon, + TerminalSquareIcon, +} from 'lucide-react'; import { useAgentToolPermissions, useAgentCapabilities, @@ -34,22 +44,34 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD * Use definition for agents endpoint for ephemeral agents * */ const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); - const { conversationId, agentId } = useDragDropContext(); + const { conversationId, agentId, endpoint, endpointType } = useDragDropContext(); const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId ?? '')); - const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions( + const { fileSearchAllowedByAgent, codeAllowedByAgent, provider } = useAgentToolPermissions( agentId, ephemeralAgent, ); const options = useMemo(() => { - const _options: FileOption[] = [ - { + const _options: FileOption[] = []; + const currentProvider = provider || endpoint; + + // Check if provider supports document upload + if (isDocumentSupportedProvider(endpointType || currentProvider)) { + _options.push({ + label: localize('com_ui_upload_provider'), + value: undefined, + icon: , + condition: true, // Allow for both images and documents + }); + } else { + // Only show image upload option if all files are images and provider doesn't support documents + _options.push({ label: localize('com_ui_upload_image_input'), value: undefined, icon: , condition: files.every((file) => file.type?.startsWith('image/')), - }, - ]; + }); + } if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) { _options.push({ label: localize('com_ui_upload_file_search'), @@ -73,7 +95,16 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD } return _options; - }, [capabilities, files, localize, fileSearchAllowedByAgent, codeAllowedByAgent]); + }, [ + files, + localize, + provider, + endpoint, + endpointType, + capabilities, + codeAllowedByAgent, + fileSearchAllowedByAgent, + ]); if (!isVisible) { return null; diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 5d98b5aca0..aa9f4da82d 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -57,7 +57,7 @@ const Part = memo( ); } else if (part.type === ContentTypes.TEXT) { - const text = typeof part.text === 'string' ? part.text : part.text.value; + const text = typeof part.text === 'string' ? part.text : part.text?.value; if (typeof text !== 'string') { return null; @@ -71,7 +71,7 @@ const Part = memo( ); } else if (part.type === ContentTypes.THINK) { - const reasoning = typeof part.think === 'string' ? part.think : part.think.value; + const reasoning = typeof part.think === 'string' ? part.think : part.think?.value; if (typeof reasoning !== 'string') { return null; } diff --git a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx index 0d53fb50ee..d2a303f49f 100644 --- a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx @@ -37,7 +37,7 @@ const LogContent: React.FC = ({ output = '', renderImages, atta attachments?.forEach((attachment) => { const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; const isImage = - imageExtRegex.test(attachment.filename) && + imageExtRegex.test(attachment.filename ?? '') && width != null && height != null && filepath != null; @@ -56,21 +56,25 @@ const LogContent: React.FC = ({ output = '', renderImages, atta const renderAttachment = (file: TAttachment) => { const now = new Date(); - const expiresAt = typeof file.expiresAt === 'number' ? new Date(file.expiresAt) : null; + const expiresAt = + 'expiresAt' in file && typeof file.expiresAt === 'number' ? new Date(file.expiresAt) : null; const isExpired = expiresAt ? isAfter(now, expiresAt) : false; + const filename = file.filename || ''; if (isExpired) { - return `${file.filename} ${localize('com_download_expired')}`; + return `${filename} ${localize('com_download_expired')}`; } + const filepath = file.filepath || ''; + // const expirationText = expiresAt // ? ` ${localize('com_download_expires', { 0: format(expiresAt, 'MM/dd/yy HH:mm') })}` // : ` ${localize('com_click_to_download')}`; return ( - + {'- '} - {file.filename} {localize('com_click_to_download')} + {filename} {localize('com_click_to_download')} ); }; diff --git a/client/src/hooks/Agents/useAgentToolPermissions.ts b/client/src/hooks/Agents/useAgentToolPermissions.ts index 90f2bc88e5..eea549d7a6 100644 --- a/client/src/hooks/Agents/useAgentToolPermissions.ts +++ b/client/src/hooks/Agents/useAgentToolPermissions.ts @@ -9,6 +9,7 @@ interface AgentToolPermissionsResult { fileSearchAllowedByAgent: boolean; codeAllowedByAgent: boolean; tools: string[] | undefined; + provider?: string; } /** @@ -36,6 +37,8 @@ export default function useAgentToolPermissions( [agentData?.tools, selectedAgent?.tools], ); + const provider = useMemo(() => selectedAgent?.provider, [selectedAgent?.provider]); + const fileSearchAllowedByAgent = useMemo(() => { // Check ephemeral agent settings if (isEphemeralAgent(agentId)) { @@ -61,6 +64,7 @@ export default function useAgentToolPermissions( return { fileSearchAllowedByAgent, codeAllowedByAgent, + provider, tools, }; } diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 9deaa78f24..7825888985 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -392,13 +392,6 @@ const useFileHandling = (params?: UseFileHandling) => { } else { // File wasn't processed, proceed with original const isImage = originalFile.type.split('/')[0] === 'image'; - const tool_resource = - initialExtendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource; - if (isAgentsEndpoint(endpoint) && !isImage && tool_resource == null) { - /** Note: this needs to be removed when we can support files to providers */ - setError('com_error_files_unsupported_capability'); - continue; - } // Update progress to show ready for upload const readyExtendedFile = { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 0facdb493a..c4b8481398 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -363,7 +363,6 @@ "com_error_files_dupe": "Duplicate file detected.", "com_error_files_empty": "Empty files are not allowed.", "com_error_files_process": "An error occurred while processing the file.", - "com_error_files_unsupported_capability": "No capabilities enabled that support this file type.", "com_error_files_upload": "An error occurred while uploading the file.", "com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.", "com_error_files_validation": "An error occurred while validating the file.", @@ -1230,6 +1229,7 @@ "com_ui_upload_invalid": "Invalid file for upload. Must be an image not exceeding the limit", "com_ui_upload_invalid_var": "Invalid file for upload. Must be an image not exceeding {{0}} MB", "com_ui_upload_ocr_text": "Upload as Text", + "com_ui_upload_provider": "Upload to Provider", "com_ui_upload_success": "Successfully uploaded file", "com_ui_upload_type": "Select Upload Type", "com_ui_usage": "Usage", diff --git a/client/src/utils/files.ts b/client/src/utils/files.ts index 81830c64ff..0959ba91bb 100644 --- a/client/src/utils/files.ts +++ b/client/src/utils/files.ts @@ -1,4 +1,11 @@ -import { SheetPaths, TextPaths, FilePaths, CodePaths } from '@librechat/client'; +import { + TextPaths, + FilePaths, + CodePaths, + AudioPaths, + VideoPaths, + SheetPaths, +} from '@librechat/client'; import { megabyte, QueryKeys, @@ -38,6 +45,18 @@ const artifact = { title: 'Code', }; +const audioFile = { + paths: AudioPaths, + fill: '#FF6B35', + title: 'Audio', +}; + +const videoFile = { + paths: VideoPaths, + fill: '#8B5CF6', + title: 'Video', +}; + export const fileTypes = { /* Category matches */ file: { @@ -47,6 +66,8 @@ export const fileTypes = { }, text: textDocument, txt: textDocument, + audio: audioFile, + video: videoFile, // application:, /* Partial matches */ diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index 7a52ff4106..caae46d923 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -25,7 +25,7 @@ export const getLatestText = (message?: TMessage | null, includeIndex?: boolean) continue; } - const text = (typeof part?.text === 'string' ? part.text : part?.text.value) ?? ''; + const text = (typeof part?.text === 'string' ? part.text : part?.text?.value) ?? ''; if (text.length > 0) { if (includeIndex === true) { return `${text}-${i}`; @@ -52,7 +52,12 @@ export const getAllContentText = (message?: TMessage | null): string => { if (message.content && message.content.length > 0) { return message.content .filter((part) => part.type === ContentTypes.TEXT) - .map((part) => (typeof part.text === 'string' ? part.text : part.text.value) || '') + .map((part) => { + if (!('text' in part)) return ''; + const text = part.text; + if (typeof text === 'string') return text; + return text?.value || ''; + }) .filter((text) => text.length > 0) .join('\n'); } diff --git a/package-lock.json b/package-lock.json index 019317a2ea..72bc3a7af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.84", + "@librechat/agents": "^2.4.85", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -21522,9 +21522,9 @@ } }, "node_modules/@librechat/agents": { - "version": "2.4.84", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.84.tgz", - "integrity": "sha512-wOPqv5yQfhkuBZ29FrJGUdDMCIvcnqUAigFeoPU8QOeqi+S9rRobx2+2D3+JbbgSsDL5yO7SyxGEHkQ7A6xZDQ==", + "version": "2.4.85", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.85.tgz", + "integrity": "sha512-t6h5f6ApnoEC+x8kqBlke1RR6BPzT+9BvlkA8VxvQVJtYIt5Ey4BOTRDGjdilDoXUcLui11PbjCd17EbjPkTcA==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -51336,7 +51336,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", - "@librechat/agents": "^2.4.84", + "@librechat/agents": "^2.4.85", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", diff --git a/packages/api/package.json b/packages/api/package.json index 3ea7d343e4..05054498e2 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -80,7 +80,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", - "@librechat/agents": "^2.4.84", + "@librechat/agents": "^2.4.85", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", diff --git a/packages/api/src/files/encode/audio.ts b/packages/api/src/files/encode/audio.ts new file mode 100644 index 0000000000..d411e61640 --- /dev/null +++ b/packages/api/src/files/encode/audio.ts @@ -0,0 +1,74 @@ +import { Providers } from '@librechat/agents'; +import { isDocumentSupportedProvider } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { Request } from 'express'; +import type { StrategyFunctions, AudioResult } from '~/types/files'; +import { validateAudio } from '~/files/validation'; +import { getFileStream } from './utils'; + +/** + * Encodes and formats audio files for different providers + * @param req - The request object + * @param files - Array of audio files + * @param provider - The provider to format for (currently only google is supported) + * @param getStrategyFunctions - Function to get strategy functions + * @returns Promise that resolves to audio and file metadata + */ +export async function encodeAndFormatAudios( + req: Request, + files: IMongoFile[], + provider: Providers, + getStrategyFunctions: (source: string) => StrategyFunctions, +): Promise { + if (!files?.length) { + return { audios: [], files: [] }; + } + + const encodingMethods: Record = {}; + const result: AudioResult = { audios: [], files: [] }; + + const results = await Promise.allSettled( + files.map((file) => getFileStream(req, file, encodingMethods, getStrategyFunctions)), + ); + + for (const settledResult of results) { + if (settledResult.status === 'rejected') { + console.error('Audio processing failed:', settledResult.reason); + continue; + } + + const processed = settledResult.value; + if (!processed) continue; + + const { file, content, metadata } = processed; + + if (!content || !file) { + if (metadata) result.files.push(metadata); + continue; + } + + if (!file.type.startsWith('audio/') || !isDocumentSupportedProvider(provider)) { + result.files.push(metadata); + continue; + } + + const audioBuffer = Buffer.from(content, 'base64'); + const validation = await validateAudio(audioBuffer, audioBuffer.length, provider); + + if (!validation.isValid) { + throw new Error(`Audio validation failed: ${validation.error}`); + } + + if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + result.audios.push({ + type: 'audio', + mimeType: file.type, + data: content, + }); + } + + result.files.push(metadata); + } + + return result; +} diff --git a/packages/api/src/files/encode/document.ts b/packages/api/src/files/encode/document.ts new file mode 100644 index 0000000000..bc1396958c --- /dev/null +++ b/packages/api/src/files/encode/document.ts @@ -0,0 +1,108 @@ +import { Providers } from '@librechat/agents'; +import { isOpenAILikeProvider, isDocumentSupportedProvider } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { Request } from 'express'; +import type { StrategyFunctions, DocumentResult } from '~/types/files'; +import { validatePdf } from '~/files/validation'; +import { getFileStream } from './utils'; + +/** + * Processes and encodes document files for various providers + * @param req - Express request object + * @param files - Array of file objects to process + * @param provider - The provider name + * @param getStrategyFunctions - Function to get strategy functions + * @returns Promise that resolves to documents and file metadata + */ +export async function encodeAndFormatDocuments( + req: Request, + files: IMongoFile[], + { provider, useResponsesApi }: { provider: Providers; useResponsesApi?: boolean }, + getStrategyFunctions: (source: string) => StrategyFunctions, +): Promise { + if (!files?.length) { + return { documents: [], files: [] }; + } + + const encodingMethods: Record = {}; + const result: DocumentResult = { documents: [], files: [] }; + + const documentFiles = files.filter( + (file) => file.type === 'application/pdf' || file.type?.startsWith('application/'), + ); + + if (!documentFiles.length) { + return result; + } + + const results = await Promise.allSettled( + documentFiles.map((file) => { + if (file.type !== 'application/pdf' || !isDocumentSupportedProvider(provider)) { + return Promise.resolve(null); + } + return getFileStream(req, file, encodingMethods, getStrategyFunctions); + }), + ); + + for (const settledResult of results) { + if (settledResult.status === 'rejected') { + console.error('Document processing failed:', settledResult.reason); + continue; + } + + const processed = settledResult.value; + if (!processed) continue; + + const { file, content, metadata } = processed; + + if (!content || !file) { + if (metadata) result.files.push(metadata); + continue; + } + + if (file.type === 'application/pdf' && isDocumentSupportedProvider(provider)) { + const pdfBuffer = Buffer.from(content, 'base64'); + const validation = await validatePdf(pdfBuffer, pdfBuffer.length, provider); + + if (!validation.isValid) { + throw new Error(`PDF validation failed: ${validation.error}`); + } + + if (provider === Providers.ANTHROPIC) { + result.documents.push({ + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: content, + }, + cache_control: { type: 'ephemeral' }, + citations: { enabled: true }, + }); + } else if (useResponsesApi) { + result.documents.push({ + type: 'input_file', + filename: file.filename, + file_data: `data:application/pdf;base64,${content}`, + }); + } else if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + result.documents.push({ + type: 'document', + mimeType: 'application/pdf', + data: content, + }); + } else if (isOpenAILikeProvider(provider) && provider != Providers.AZURE) { + result.documents.push({ + type: 'file', + file: { + filename: file.filename, + file_data: `data:application/pdf;base64,${content}`, + }, + }); + } + result.files.push(metadata); + } + } + + return result; +} diff --git a/packages/api/src/files/encode/index.ts b/packages/api/src/files/encode/index.ts new file mode 100644 index 0000000000..a0708596f3 --- /dev/null +++ b/packages/api/src/files/encode/index.ts @@ -0,0 +1,3 @@ +export * from './audio'; +export * from './document'; +export * from './video'; diff --git a/packages/api/src/files/encode/utils.ts b/packages/api/src/files/encode/utils.ts new file mode 100644 index 0000000000..3664e074c9 --- /dev/null +++ b/packages/api/src/files/encode/utils.ts @@ -0,0 +1,46 @@ +import getStream from 'get-stream'; +import { FileSources } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { Request } from 'express'; +import type { StrategyFunctions, ProcessedFile } from '~/types/files'; + +/** + * Processes a file by downloading and encoding it to base64 + * @param req - Express request object + * @param file - File object to process + * @param encodingMethods - Cache of encoding methods by source + * @param getStrategyFunctions - Function to get strategy functions for a source + * @returns Processed file with content and metadata, or null if filepath missing + */ +export async function getFileStream( + req: Request, + file: IMongoFile, + encodingMethods: Record, + getStrategyFunctions: (source: string) => StrategyFunctions, +): Promise { + if (!file?.filepath) { + return null; + } + + const source = file.source ?? FileSources.local; + if (!encodingMethods[source]) { + encodingMethods[source] = getStrategyFunctions(source); + } + + const { getDownloadStream } = encodingMethods[source]; + const stream = await getDownloadStream(req, file.filepath); + const buffer = await getStream.buffer(stream); + + return { + file, + content: buffer.toString('base64'), + metadata: { + file_id: file.file_id, + temp_file_id: file.temp_file_id, + filepath: file.filepath, + source: file.source, + filename: file.filename, + type: file.type, + }, + }; +} diff --git a/packages/api/src/files/encode/video.ts b/packages/api/src/files/encode/video.ts new file mode 100644 index 0000000000..10fd4c691f --- /dev/null +++ b/packages/api/src/files/encode/video.ts @@ -0,0 +1,74 @@ +import { Providers } from '@librechat/agents'; +import { isDocumentSupportedProvider } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { Request } from 'express'; +import type { StrategyFunctions, VideoResult } from '~/types/files'; +import { validateVideo } from '~/files/validation'; +import { getFileStream } from './utils'; + +/** + * Encodes and formats video files for different providers + * @param req - The request object + * @param files - Array of video files + * @param provider - The provider to format for + * @param getStrategyFunctions - Function to get strategy functions + * @returns Promise that resolves to videos and file metadata + */ +export async function encodeAndFormatVideos( + req: Request, + files: IMongoFile[], + provider: Providers, + getStrategyFunctions: (source: string) => StrategyFunctions, +): Promise { + if (!files?.length) { + return { videos: [], files: [] }; + } + + const encodingMethods: Record = {}; + const result: VideoResult = { videos: [], files: [] }; + + const results = await Promise.allSettled( + files.map((file) => getFileStream(req, file, encodingMethods, getStrategyFunctions)), + ); + + for (const settledResult of results) { + if (settledResult.status === 'rejected') { + console.error('Video processing failed:', settledResult.reason); + continue; + } + + const processed = settledResult.value; + if (!processed) continue; + + const { file, content, metadata } = processed; + + if (!content || !file) { + if (metadata) result.files.push(metadata); + continue; + } + + if (!file.type.startsWith('video/') || !isDocumentSupportedProvider(provider)) { + result.files.push(metadata); + continue; + } + + const videoBuffer = Buffer.from(content, 'base64'); + const validation = await validateVideo(videoBuffer, videoBuffer.length, provider); + + if (!validation.isValid) { + throw new Error(`Video validation failed: ${validation.error}`); + } + + if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + result.videos.push({ + type: 'video', + mimeType: file.type, + data: content, + }); + } + + result.files.push(metadata); + } + + return result; +} diff --git a/packages/api/src/files/index.ts b/packages/api/src/files/index.ts index 49e5bb4151..3d1a3118e3 100644 --- a/packages/api/src/files/index.ts +++ b/packages/api/src/files/index.ts @@ -1,5 +1,7 @@ export * from './audio'; +export * from './encode'; export * from './mistral/crud'; export * from './ocr'; export * from './parse'; +export * from './validation'; export * from './text'; diff --git a/packages/api/src/files/validation.ts b/packages/api/src/files/validation.ts new file mode 100644 index 0000000000..a8394fd8f6 --- /dev/null +++ b/packages/api/src/files/validation.ts @@ -0,0 +1,186 @@ +import { Providers } from '@librechat/agents'; +import { mbToBytes, isOpenAILikeProvider } from 'librechat-data-provider'; + +export interface PDFValidationResult { + isValid: boolean; + error?: string; +} + +export interface VideoValidationResult { + isValid: boolean; + error?: string; +} + +export interface AudioValidationResult { + isValid: boolean; + error?: string; +} + +export async function validatePdf( + pdfBuffer: Buffer, + fileSize: number, + provider: Providers, +): Promise { + if (provider === Providers.ANTHROPIC) { + return validateAnthropicPdf(pdfBuffer, fileSize); + } + + if (isOpenAILikeProvider(provider)) { + return validateOpenAIPdf(fileSize); + } + + if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + return validateGooglePdf(fileSize); + } + + return { isValid: true }; +} + +/** + * Validates if a PDF meets Anthropic's requirements + * @param pdfBuffer - The PDF file as a buffer + * @param fileSize - The file size in bytes + * @returns Promise that resolves to validation result + */ +async function validateAnthropicPdf( + pdfBuffer: Buffer, + fileSize: number, +): Promise { + try { + if (fileSize > mbToBytes(32)) { + return { + isValid: false, + error: `PDF file size (${Math.round(fileSize / (1024 * 1024))}MB) exceeds Anthropic's 32MB limit`, + }; + } + + if (!pdfBuffer || pdfBuffer.length < 5) { + return { + isValid: false, + error: 'Invalid PDF file: too small or corrupted', + }; + } + + const pdfHeader = pdfBuffer.subarray(0, 5).toString(); + if (!pdfHeader.startsWith('%PDF-')) { + return { + isValid: false, + error: 'Invalid PDF file: missing PDF header', + }; + } + + 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.', + }; + } + + 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`, + }; + } + + return { isValid: true }; + } catch (error) { + console.error('PDF validation error:', error); + return { + isValid: false, + error: 'Failed to validate PDF file', + }; + } +} + +async function validateOpenAIPdf(fileSize: number): Promise { + if (fileSize > 10 * 1024 * 1024) { + return { + isValid: false, + error: "PDF file size exceeds OpenAI's 10MB limit", + }; + } + + return { isValid: true }; +} + +async function validateGooglePdf(fileSize: number): Promise { + if (fileSize > 20 * 1024 * 1024) { + return { + isValid: false, + error: "PDF file size exceeds Google's 20MB limit", + }; + } + + return { isValid: true }; +} + +/** + * Validates video files for different providers + * @param videoBuffer - The video file as a buffer + * @param fileSize - The file size in bytes + * @param provider - The provider to validate for + * @returns Promise that resolves to validation result + */ +export async function validateVideo( + videoBuffer: Buffer, + fileSize: number, + provider: Providers, +): Promise { + if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + if (fileSize > 20 * 1024 * 1024) { + return { + isValid: false, + error: `Video file size (${Math.round(fileSize / (1024 * 1024))}MB) exceeds Google's 20MB limit`, + }; + } + } + + if (!videoBuffer || videoBuffer.length < 10) { + return { + isValid: false, + error: 'Invalid video file: too small or corrupted', + }; + } + + return { isValid: true }; +} + +/** + * Validates audio files for different providers + * @param audioBuffer - The audio file as a buffer + * @param fileSize - The file size in bytes + * @param provider - The provider to validate for + * @returns Promise that resolves to validation result + */ +export async function validateAudio( + audioBuffer: Buffer, + fileSize: number, + provider: Providers, +): Promise { + if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + if (fileSize > 20 * 1024 * 1024) { + return { + isValid: false, + error: `Audio file size (${Math.round(fileSize / (1024 * 1024))}MB) exceeds Google's 20MB limit`, + }; + } + } + + if (!audioBuffer || audioBuffer.length < 10) { + return { + isValid: false, + error: 'Invalid audio file: too small or corrupted', + }; + } + + return { isValid: true }; +} diff --git a/packages/api/src/types/files.ts b/packages/api/src/types/files.ts index 4bfcc23e46..dc37410050 100644 --- a/packages/api/src/types/files.ts +++ b/packages/api/src/types/files.ts @@ -1,4 +1,7 @@ +import type { IMongoFile } from '@librechat/data-schemas'; import type { ServerRequest } from './http'; +import type { Readable } from 'stream'; +import type { Request } from 'express'; export interface STTService { getInstance(): Promise; getProviderSchema(req: ServerRequest): Promise<[string, object]>; @@ -26,3 +29,85 @@ export interface AudioProcessingResult { text: string; bytes: number; } + +export interface VideoResult { + videos: Array<{ + type: string; + mimeType: string; + data: string; + }>; + files: Array<{ + file_id?: string; + temp_file_id?: string; + filepath: string; + source?: string; + filename: string; + type: string; + }>; +} + +export interface DocumentResult { + documents: Array<{ + type: 'document' | 'file' | 'input_file'; + /** Anthropic File Format, `document` */ + source?: { + type: string; + media_type: string; + data: string; + }; + cache_control?: { type: string }; + citations?: { enabled: boolean }; + /** Google File Format, `document` */ + mimeType?: string; + data?: string; + /** OpenAI File Format, `file` */ + file?: { + filename?: string; + file_data?: string; + }; + /** OpenAI Responses API File Format, `input_file` */ + filename?: string; + file_data?: string; + }>; + files: Array<{ + file_id?: string; + temp_file_id?: string; + filepath: string; + source?: string; + filename: string; + type: string; + }>; +} + +export interface AudioResult { + audios: Array<{ + type: string; + mimeType: string; + data: string; + }>; + files: Array<{ + file_id?: string; + temp_file_id?: string; + filepath: string; + source?: string; + filename: string; + type: string; + }>; +} + +export interface ProcessedFile { + file: IMongoFile; + content: string; + metadata: { + file_id: string; + temp_file_id?: string; + filepath: string; + source?: string; + filename: string; + type: string; + }; +} + +export interface StrategyFunctions { + getDownloadStream: (req: Request, filepath: string) => Promise; +} diff --git a/packages/client/src/svgs/AudioPaths.tsx b/packages/client/src/svgs/AudioPaths.tsx new file mode 100644 index 0000000000..874f54328d --- /dev/null +++ b/packages/client/src/svgs/AudioPaths.tsx @@ -0,0 +1,41 @@ +export default function AudioPaths() { + return ( + <> + + + + + + + ); +} diff --git a/packages/client/src/svgs/VideoPaths.tsx b/packages/client/src/svgs/VideoPaths.tsx new file mode 100644 index 0000000000..6876824e42 --- /dev/null +++ b/packages/client/src/svgs/VideoPaths.tsx @@ -0,0 +1,10 @@ +export default function VideoPaths() { + return ( + <> + {/* Video container - rounded rectangle (not filled) */} + + {/* Play button - centered and pointing right */} + + + ); +} diff --git a/packages/client/src/svgs/index.ts b/packages/client/src/svgs/index.ts index 13a5a1cc0a..d3f8c6e45b 100644 --- a/packages/client/src/svgs/index.ts +++ b/packages/client/src/svgs/index.ts @@ -65,9 +65,11 @@ export { default as PersonalizationIcon } from './PersonalizationIcon'; export { default as MCPIcon } from './MCPIcon'; export { default as VectorIcon } from './VectorIcon'; export { default as SquirclePlusIcon } from './SquirclePlusIcon'; +export { default as AudioPaths } from './AudioPaths'; export { default as CodePaths } from './CodePaths'; export { default as FileIcon } from './FileIcon'; export { default as FilePaths } from './FilePaths'; export { default as SheetPaths } from './SheetPaths'; export { default as TextPaths } from './TextPaths'; +export { default as VideoPaths } from './VideoPaths'; export { default as SharePointIcon } from './SharePointIcon'; diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index 75c403afbc..3ad24da67a 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -57,6 +57,27 @@ export const fullMimeTypesList = [ 'application/zip', 'image/svg', 'image/svg+xml', + // Video formats + 'video/mp4', + 'video/avi', + 'video/mov', + 'video/wmv', + 'video/flv', + 'video/webm', + 'video/mkv', + 'video/m4v', + 'video/3gp', + 'video/ogv', + // Audio formats + 'audio/mp3', + 'audio/wav', + 'audio/ogg', + 'audio/m4a', + 'audio/aac', + 'audio/flac', + 'audio/wma', + 'audio/opus', + 'audio/mpeg', ...excelFileTypes, ]; @@ -123,7 +144,9 @@ export const applicationMimeTypes = export const imageMimeTypes = /^image\/(jpeg|gif|png|webp|heic|heif)$/; export const audioMimeTypes = - /^audio\/(mp3|mpeg|mpeg3|wav|wave|x-wav|ogg|vorbis|mp4|x-m4a|flac|x-flac|webm)$/; + /^audio\/(mp3|mpeg|mpeg3|wav|wave|x-wav|ogg|vorbis|mp4|m4a|x-m4a|flac|x-flac|webm|aac|wma|opus)$/; + +export const videoMimeTypes = /^video\/(mp4|avi|mov|wmv|flv|webm|mkv|m4v|3gp|ogv)$/; export const defaultOCRMimeTypes = [ imageMimeTypes, @@ -142,8 +165,9 @@ export const supportedMimeTypes = [ excelMimeTypes, applicationMimeTypes, imageMimeTypes, + videoMimeTypes, audioMimeTypes, - /** Supported by LC Code Interpreter PAI */ + /** Supported by LC Code Interpreter API */ /^image\/(svg|svg\+xml)$/, ]; @@ -199,6 +223,13 @@ export const fileConfig = { [EModelEndpoint.assistants]: assistantsFileConfig, [EModelEndpoint.azureAssistants]: assistantsFileConfig, [EModelEndpoint.agents]: assistantsFileConfig, + [EModelEndpoint.anthropic]: { + fileLimit: 10, + fileSizeLimit: defaultSizeLimit, + totalSizeLimit: defaultSizeLimit, + supportedMimeTypes, + disabled: false, + }, default: { fileLimit: 10, fileSizeLimit: defaultSizeLimit, diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index 7d4016449a..61616a57a8 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -369,7 +369,7 @@ export function parseTextParts( continue; } if (part.type === ContentTypes.TEXT) { - const textValue = typeof part.text === 'string' ? part.text : part.text.value; + const textValue = (typeof part.text === 'string' ? part.text : part.text?.value) || ''; if ( result.length > 0 && diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 8ae503ef25..a279f4f84d 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -31,6 +31,61 @@ export enum EModelEndpoint { gptPlugins = 'gptPlugins', } +/** Mirrors `@librechat/agents` providers */ +export enum Providers { + OPENAI = 'openAI', + ANTHROPIC = 'anthropic', + AZURE = 'azureOpenAI', + GOOGLE = 'google', + VERTEXAI = 'vertexai', + BEDROCK = 'bedrock', + BEDROCK_LEGACY = 'bedrock_legacy', + MISTRALAI = 'mistralai', + MISTRAL = 'mistral', + OLLAMA = 'ollama', + DEEPSEEK = 'deepseek', + OPENROUTER = 'openrouter', + XAI = 'xai', +} + +/** + * Endpoints that support direct PDF processing in the agent system + */ +export const documentSupportedProviders = new Set([ + EModelEndpoint.anthropic, + EModelEndpoint.openAI, + EModelEndpoint.custom, + EModelEndpoint.azureOpenAI, + EModelEndpoint.google, + Providers.VERTEXAI, + Providers.MISTRALAI, + Providers.MISTRAL, + Providers.OLLAMA, + Providers.DEEPSEEK, + Providers.OPENROUTER, + Providers.XAI, +]); + +const openAILikeProviders = new Set([ + Providers.OPENAI, + Providers.AZURE, + EModelEndpoint.custom, + Providers.MISTRALAI, + Providers.MISTRAL, + Providers.OLLAMA, + Providers.DEEPSEEK, + Providers.OPENROUTER, + Providers.XAI, +]); + +export const isOpenAILikeProvider = (provider?: string | null): boolean => { + return openAILikeProviders.has(provider ?? ''); +}; + +export const isDocumentSupportedProvider = (provider?: string | null): boolean => { + return documentSupportedProviders.has(provider ?? ''); +}; + export const paramEndpoints = new Set([ EModelEndpoint.agents, EModelEndpoint.openAI, diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index beb1e10701..246c60a5c3 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -475,10 +475,20 @@ export type ContentPart = ( ) & PartMetadata; +export type TextData = (Text & PartMetadata) | undefined; + export type TMessageContentParts = - | { type: ContentTypes.ERROR; text?: string | (Text & PartMetadata); error?: string } - | { type: ContentTypes.THINK; think: string | (Text & PartMetadata) } - | { type: ContentTypes.TEXT; text: string | (Text & PartMetadata); tool_call_ids?: string[] } + | { + type: ContentTypes.ERROR; + text?: string | TextData; + error?: string; + } + | { type: ContentTypes.THINK; think?: string | TextData } + | { + type: ContentTypes.TEXT; + text?: string | TextData; + tool_call_ids?: string[]; + } | { type: ContentTypes.TOOL_CALL; tool_call: ( From a5189052ec06dfda343a198d6e5e5174fe0ef31a Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 7 Oct 2025 20:12:49 +0200 Subject: [PATCH 015/272] =?UTF-8?q?=E2=99=BF=EF=B8=8F=20fix:=20Accessibili?= =?UTF-8?q?ty,=20UI=20consistency,=20dialog=20&=20avatar=20refactors=20(#9?= =?UTF-8?q?975)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 refactor: Improve accessibility and styling in ChatGroupItem and FilterPrompts components * 🔧 fix: Add button type and keyboard accessibility to dropdown menu trigger in ChatGroupItem * 🔧 fix(757): Enhance accessibility by updating aria-labels and adding localization for prompt groups * 🔧 fix(618): Update version to 0.3.1 and enhance accessibility in InfoHoverCard component * 🔧 fix(618): Update aria-label in InfoHoverCard to use dynamic text prop for improved accessibility * 🔧 fix: Enhance accessibility by updating aria-labels and roles in Conversations components * 🔧 fix(620): Enhance accessibility by adding tabIndex to Tabs.Content components in ArtifactTabs, Settings, and Speech components * refactor: remove RevokeKeysButton component and update related components for accessibility - Deleted RevokeKeysButton component. - Updated SharedLinks and General components to use Label for accessibility. - Enhanced Personalization component with aria-labelledby and aria-describedby attributes. - Refactored ConversationModeSwitch to use ToggleSwitch for better state management. - Improved AutoSendTextSelector with local state management and accessibility attributes. - Replaced Switch components with ToggleSwitch in various Speech and TTS components for consistency. - Added aria-labelledby attributes to Dropdown components for better accessibility. - Updated translation.json to include new localization keys and improved existing ones. - Enhanced Slider component to support aria attributes for better accessibility. * 🔧 fix: Enhance user feedback for API key operations with success and error messages * 🔧 fix: Update aria-labels in Avatar component for improved localization and accessibility * 🔧 fix: Refactor handleFile and handleDrop functions for improved readability and maintainability --- .../src/components/Artifacts/ArtifactTabs.tsx | 3 +- client/src/components/Audio/Voices.tsx | 10 +- .../Conversations/Conversations.tsx | 5 +- client/src/components/Conversations/Convo.tsx | 8 +- .../components/Conversations/ConvoLink.tsx | 3 +- .../ConvoOptions/ConvoOptions.tsx | 1 + .../Input/SetKeyDialog/SetKeyDialog.tsx | 221 ++++++++--- client/src/components/Nav/Settings.tsx | 18 +- .../Nav/SettingsTabs/Account/Avatar.tsx | 272 +++++++++---- .../SettingsTabs/Account/DeleteAccount.tsx | 5 +- .../Account/DisableTwoFactorToggle.tsx | 2 +- .../Account/DisplayUsernameMessages.tsx | 3 +- .../Nav/SettingsTabs/Chat/ChatDirection.tsx | 14 +- .../SettingsTabs/Chat/FontSizeSelector.tsx | 5 +- .../Nav/SettingsTabs/Chat/ForkSettings.tsx | 9 +- .../SettingsTabs/Commands/AtCommandSwitch.tsx | 26 -- .../Nav/SettingsTabs/Commands/Commands.tsx | 63 ++- .../Commands/PlusCommandSwitch.tsx | 26 -- .../Commands/SlashCommandSwitch.tsx | 25 -- .../Nav/SettingsTabs/Data/ClearChats.tsx | 6 +- .../components/Nav/SettingsTabs/Data/Data.tsx | 4 +- .../Nav/SettingsTabs/Data/DeleteCache.tsx | 4 +- .../SettingsTabs/Data/ImportConversations.tsx | 152 ++++---- .../Nav/SettingsTabs/Data/RevokeAllKeys.tsx | 15 - .../Nav/SettingsTabs/Data/RevokeKeys.tsx | 72 ++++ .../SettingsTabs/Data/RevokeKeysButton.tsx | 84 ---- .../Nav/SettingsTabs/Data/SharedLinks.tsx | 6 +- .../Nav/SettingsTabs/General/General.tsx | 10 +- .../Nav/SettingsTabs/Personalization.tsx | 10 +- .../Speech/ConversationModeSwitch.tsx | 29 +- .../Speech/STT/AutoSendTextSelector.tsx | 126 ++++-- .../Speech/STT/AutoTranscribeAudioSwitch.tsx | 34 +- .../Speech/STT/DecibelSelector.tsx | 4 +- .../Speech/STT/EngineSTTDropdown.tsx | 5 +- .../Speech/STT/LanguageSTTDropdown.tsx | 5 +- .../Speech/STT/SpeechToTextSwitch.tsx | 33 +- .../Nav/SettingsTabs/Speech/Speech.tsx | 6 +- .../Speech/TTS/AutomaticPlaybackSwitch.tsx | 30 +- .../Speech/TTS/CacheTTSSwitch.tsx | 34 +- .../Speech/TTS/CloudBrowserVoicesSwitch.tsx | 36 +- .../Speech/TTS/EngineTTSDropdown.tsx | 5 +- .../SettingsTabs/Speech/TTS/PlaybackRate.tsx | 4 +- .../Speech/TTS/TextToSpeechSwitch.tsx | 33 +- .../Nav/SettingsTabs/ToggleSwitch.tsx | 18 +- .../src/components/Nav/SettingsTabs/index.ts | 9 +- .../Prompts/Groups/ChatGroupItem.tsx | 26 +- .../Prompts/Groups/DashGroupItem.tsx | 2 +- .../Prompts/Groups/FilterPrompts.tsx | 2 +- client/src/components/Prompts/Groups/List.tsx | 2 +- .../src/components/Prompts/PreviewPrompt.tsx | 2 +- client/src/locales/en/translation.json | 31 +- packages/client/src/components/Dropdown.tsx | 3 + .../client/src/components/DropdownMenu.tsx | 360 ++++++++++-------- .../client/src/components/InfoHoverCard.tsx | 17 +- packages/client/src/components/Label.tsx | 2 +- packages/client/src/components/Slider.tsx | 75 ++-- 56 files changed, 1158 insertions(+), 857 deletions(-) delete mode 100644 client/src/components/Nav/SettingsTabs/Commands/AtCommandSwitch.tsx delete mode 100644 client/src/components/Nav/SettingsTabs/Commands/PlusCommandSwitch.tsx delete mode 100644 client/src/components/Nav/SettingsTabs/Commands/SlashCommandSwitch.tsx delete mode 100644 client/src/components/Nav/SettingsTabs/Data/RevokeAllKeys.tsx create mode 100644 client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx delete mode 100644 client/src/components/Nav/SettingsTabs/Data/RevokeKeysButton.tsx diff --git a/client/src/components/Artifacts/ArtifactTabs.tsx b/client/src/components/Artifacts/ArtifactTabs.tsx index cd8c441ad7..a463aca792 100644 --- a/client/src/components/Artifacts/ArtifactTabs.tsx +++ b/client/src/components/Artifacts/ArtifactTabs.tsx @@ -44,6 +44,7 @@ export default function ArtifactTabs({ value="code" id="artifacts-code" className={cn('flex-grow overflow-auto')} + tabIndex={-1} > {isMermaid ? ( @@ -58,7 +59,7 @@ export default function ArtifactTabs({ /> )} - + -
{localize('com_nav_voice_select')}
+
{localize('com_nav_voice_select')}
); @@ -48,9 +51,11 @@ export function ExternalVoiceDropdown() { } }; + const labelId = 'external-voice-dropdown-label'; + return (
-
{localize('com_nav_voice_select')}
+
{localize('com_nav_voice_select')}
); diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 3a04f558f9..b16c6458c7 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -28,6 +28,8 @@ const LoadingSpinner = memo(() => { ); }); +LoadingSpinner.displayName = 'LoadingSpinner'; + const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { const localize = useLocalize(); return ( @@ -74,6 +76,7 @@ const Conversations: FC = ({ isLoading, isSearchLoading, }) => { + const localize = useLocalize(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const convoHeight = isSmallScreen ? 44 : 34; @@ -181,7 +184,7 @@ const Conversations: FC = ({ {isSearchLoading ? (
- Loading... + {localize('com_ui_loading')}
) : (
diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 190cef2a4e..048c2f129d 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -135,8 +135,9 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co 'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9', isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt', )} - role="listitem" - tabIndex={0} + role="button" + tabIndex={renaming ? -1 : 0} + aria-label={`${title || localize('com_ui_untitled')} conversation`} onClick={(e) => { if (renaming) { return; @@ -149,7 +150,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co if (renaming) { return; } - if (e.key === 'Enter') { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); handleNavigation(false); } }} diff --git a/client/src/components/Conversations/ConvoLink.tsx b/client/src/components/Conversations/ConvoLink.tsx index 1667cf0980..68c16594a5 100644 --- a/client/src/components/Conversations/ConvoLink.tsx +++ b/client/src/components/Conversations/ConvoLink.tsx @@ -40,8 +40,7 @@ const ConvoLink: React.FC = ({ e.stopPropagation(); onRename(); }} - role="button" - aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')} + aria-label={title || localize('com_ui_untitled')} > {title || localize('com_ui_untitled')}
diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index 7affbd8e93..9cf1a109d3 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -201,6 +201,7 @@ function ConvoOptions({ void; +}) => { + const localize = useLocalize(); + const [open, setOpen] = useState(false); + const { showToast } = useToastContext(); + const revokeKeyMutation = useRevokeUserKeyMutation(endpoint); + const revokeKeysMutation = useRevokeAllUserKeysMutation(); + + const handleSuccess = () => { + showToast({ + message: localize('com_ui_revoke_key_success'), + status: NotificationSeverity.SUCCESS, + }); + + if (!setDialogOpen) { + return; + } + + setDialogOpen(false); + }; + + const handleError = () => { + showToast({ + message: localize('com_ui_revoke_key_error'), + status: NotificationSeverity.ERROR, + }); + }; + + const onClick = () => { + revokeKeyMutation.mutate( + {}, + { + onSuccess: handleSuccess, + onError: handleError, + }, + ); + }; + + const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading; + + return ( +
+ + + + + + + {localize('com_ui_revoke_key_endpoint', { 0: endpoint })} + +
+ +
+ + + + +
+
+
+ ); +}; + const SetKeyDialog = ({ open, onOpenChange, @@ -83,7 +188,7 @@ const SetKeyDialog = ({ const submit = () => { const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel); - let expiresAt; + let expiresAt: number | null; if (selectedOption?.value === 0) { expiresAt = null; @@ -92,8 +197,20 @@ const SetKeyDialog = ({ } const saveKey = (key: string) => { - saveUserKey(key, expiresAt); - onOpenChange(false); + try { + saveUserKey(key, expiresAt); + showToast({ + message: localize('com_ui_save_key_success'), + status: NotificationSeverity.SUCCESS, + }); + onOpenChange(false); + } catch (error) { + logger.error('Error saving user key:', error); + showToast({ + message: localize('com_ui_save_key_error'), + status: NotificationSeverity.ERROR, + }); + } }; if (formSet.has(endpoint) || formSet.has(endpointType ?? '')) { @@ -148,6 +265,14 @@ const SetKeyDialog = ({ return; } + if (!userKey.trim()) { + showToast({ + message: localize('com_ui_key_required'), + status: NotificationSeverity.ERROR, + }); + return; + } + saveKey(userKey); setUserKey(''); }; @@ -159,56 +284,54 @@ const SetKeyDialog = ({ return ( - - - {expiryTime === 'never' - ? localize('com_endpoint_config_key_never_expires') - : `${localize('com_endpoint_config_key_encryption')} ${new Date( - expiryTime ?? 0, - ).toLocaleString()}`} - - option.label)} - sizeClasses="w-[185px]" - portal={false} + + + + {`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`} + + +
+ + {expiryTime === 'never' + ? localize('com_endpoint_config_key_never_expires') + : `${localize('com_endpoint_config_key_encryption')} ${new Date( + expiryTime ?? 0, + ).toLocaleString()}`} + + option.label)} + sizeClasses="w-[185px]" + portal={false} + /> +
+ + -
- - - - -
- } - selection={{ - selectHandler: submit, - selectClasses: 'btn btn-primary', - selectText: localize('com_ui_submit'), - }} - leftButtons={ +
+ +
+ - } - /> + + + ); }; diff --git a/client/src/components/Nav/Settings.tsx b/client/src/components/Nav/Settings.tsx index d1c05b7bbc..868c987070 100644 --- a/client/src/components/Nav/Settings.tsx +++ b/client/src/components/Nav/Settings.tsx @@ -182,7 +182,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { - {localize('com_ui_close')} + {localize('com_ui_close_settings')}
@@ -220,35 +220,35 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { ))}
- + - + - + - + {hasAnyPersonalizationFeature && ( - + )} - + {startupConfig?.balance?.enabled && ( - + )} - +
diff --git a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx index 0ce86231a7..ed677f771a 100644 --- a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx @@ -1,9 +1,11 @@ import React, { useState, useRef, useCallback } from 'react'; import { useSetRecoilState } from 'recoil'; +// @ts-ignore - no type definitions available import AvatarEditor from 'react-avatar-editor'; -import { FileImage, RotateCw, Upload } from 'lucide-react'; +import { FileImage, RotateCw, Upload, ZoomIn, ZoomOut, Move, X } from 'lucide-react'; import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider'; import { + Label, Slider, Button, Spinner, @@ -25,14 +27,20 @@ interface AvatarEditorRef { getImage: () => HTMLImageElement; } +interface Position { + x: number; + y: number; +} + function Avatar() { const setUser = useSetRecoilState(store.user); const [scale, setScale] = useState(1); const [rotation, setRotation] = useState(0); + const [position, setPosition] = useState({ x: 0.5, y: 0.5 }); + const [isDragging, setIsDragging] = useState(false); const editorRef = useRef(null); const fileInputRef = useRef(null); - const openButtonRef = useRef(null); const [image, setImage] = useState(null); const [isDialogOpen, setDialogOpen] = useState(false); @@ -48,7 +56,6 @@ function Avatar() { onSuccess: (data) => { showToast({ message: localize('com_ui_upload_success') }); setUser((prev) => ({ ...prev, avatar: data.url }) as TUser); - openButtonRef.current?.click(); }, onError: (error) => { console.error('Error:', error); @@ -61,29 +68,45 @@ function Avatar() { handleFile(file); }; - const handleFile = (file: File | undefined) => { - if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) { - setImage(file); - setScale(1); - setRotation(0); - } else { - const megabytes = - fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2; - showToast({ - message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }), - status: 'error', - }); - } - }; + const handleFile = useCallback( + (file: File | undefined) => { + if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) { + setImage(file); + setScale(1); + setRotation(0); + setPosition({ x: 0.5, y: 0.5 }); + } else { + const megabytes = + fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2; + showToast({ + message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }), + status: 'error', + }); + } + }, + [fileConfig.avatarSizeLimit, localize, showToast], + ); const handleScaleChange = (value: number[]) => { setScale(value[0]); }; + const handleZoomIn = () => { + setScale((prev) => Math.min(prev + 0.2, 5)); + }; + + const handleZoomOut = () => { + setScale((prev) => Math.max(prev - 0.2, 1)); + }; + const handleRotate = () => { setRotation((prev) => (prev + 90) % 360); }; + const handlePositionChange = (position: Position) => { + setPosition(position); + }; + const handleUpload = () => { if (editorRef.current) { const canvas = editorRef.current.getImageScaledToCanvas(); @@ -98,11 +121,14 @@ function Avatar() { } }; - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const file = e.dataTransfer.files[0]; - handleFile(file); - }, []); + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + handleFile(file); + }, + [handleFile], + ); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -116,8 +142,15 @@ function Avatar() { setImage(null); setScale(1); setRotation(0); + setPosition({ x: 0.5, y: 0.5 }); }, []); + const handleReset = () => { + setScale(1); + setRotation(0); + setPosition({ x: 0.5, y: 0.5 }); + }; + return ( { - openButtonRef.current?.focus(); - }, 0); } }} >
{localize('com_nav_profile_picture')} - +
- + {image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')} -
+
{image != null ? ( <> -
+
setIsDragging(true)} + onMouseUp={() => setIsDragging(false)} + onMouseLeave={() => setIsDragging(false)} + > + {!isDragging && ( +
+
+ +
+
+ )}
-
-
- {localize('com_ui_zoom')} - + +
+ {/* Zoom Controls */} +
+
+ + {Math.round(scale * 100)}% +
+
+ + + +
- + +
+ + +
+ + {/* Helper Text */} +

+ {localize('com_ui_editor_instructions')} +

+
+ + {/* Action Buttons */} +
+ +
- ) : (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openFileDialog(); + } + }} + aria-label={localize('com_ui_upload_avatar_label')} > - -

+ +

{localize('com_ui_drag_drop')}

-
)} diff --git a/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx b/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx index bd2e36c45f..29d1608b46 100644 --- a/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx @@ -1,6 +1,7 @@ import { LockIcon, Trash } from 'lucide-react'; import React, { useState, useCallback } from 'react'; import { + Label, Input, Button, Spinner, @@ -45,11 +46,11 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea <>
- {localize('com_nav_delete_account')} +
); diff --git a/client/src/components/Nav/SettingsTabs/Chat/ChatDirection.tsx b/client/src/components/Nav/SettingsTabs/Chat/ChatDirection.tsx index 7676617d32..a639d0ca42 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/ChatDirection.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/ChatDirection.tsx @@ -19,16 +19,16 @@ const ChatDirection = () => {
); diff --git a/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx b/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx index c638244bcf..82fa2e746b 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx @@ -20,9 +20,11 @@ export default function FontSizeSelector() { { value: 'text-xl', label: localize('com_nav_font_size_xl') }, ]; + const labelId = 'font-size-selector-label'; + return (
-
{localize('com_nav_font_size')}
+
{localize('com_nav_font_size')}
); diff --git a/client/src/components/Nav/SettingsTabs/Chat/ForkSettings.tsx b/client/src/components/Nav/SettingsTabs/Chat/ForkSettings.tsx index a81d4f4f50..e1145fc3ca 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/ForkSettings.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/ForkSettings.tsx @@ -20,13 +20,14 @@ export const ForkSettings = () => { <>
-
{localize('com_ui_fork_default')}
+
{localize('com_ui_fork_default')}
@@ -34,7 +35,7 @@ export const ForkSettings = () => {
-
{localize('com_ui_fork_change_default')}
+
{localize('com_ui_fork_change_default')}
{ sizeClasses="w-[200px]" testId="fork-setting-dropdown" className="z-[50]" + aria-labelledby="fork-change-default-label" />
@@ -54,7 +56,7 @@ export const ForkSettings = () => {
-
{localize('com_ui_fork_split_target_setting')}
+
{localize('com_ui_fork_split_target_setting')}
{ onCheckedChange={setSplitAtTarget} className="ml-4" data-testid="splitAtTarget" + aria-labelledby="split-at-target-label" />
diff --git a/client/src/components/Nav/SettingsTabs/Commands/AtCommandSwitch.tsx b/client/src/components/Nav/SettingsTabs/Commands/AtCommandSwitch.tsx deleted file mode 100644 index ab30c44dc0..0000000000 --- a/client/src/components/Nav/SettingsTabs/Commands/AtCommandSwitch.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; -import store from '~/store'; - -export default function AtCommandSwitch() { - const [atCommand, setAtCommand] = useRecoilState(store.atCommand); - const localize = useLocalize(); - - const handleCheckedChange = (value: boolean) => { - setAtCommand(value); - }; - - return ( -
-
{localize('com_nav_at_command_description')}
- -
- ); -} diff --git a/client/src/components/Nav/SettingsTabs/Commands/Commands.tsx b/client/src/components/Nav/SettingsTabs/Commands/Commands.tsx index c06733f21a..ff04c9087b 100644 --- a/client/src/components/Nav/SettingsTabs/Commands/Commands.tsx +++ b/client/src/components/Nav/SettingsTabs/Commands/Commands.tsx @@ -1,10 +1,33 @@ import { memo } from 'react'; import { InfoHoverCard, ESide } from '@librechat/client'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import SlashCommandSwitch from './SlashCommandSwitch'; import { useLocalize, useHasAccess } from '~/hooks'; -import PlusCommandSwitch from './PlusCommandSwitch'; -import AtCommandSwitch from './AtCommandSwitch'; +import ToggleSwitch from '../ToggleSwitch'; +import store from '~/store'; + +const commandSwitchConfigs = [ + { + stateAtom: store.atCommand, + localizationKey: 'com_nav_at_command_description' as const, + switchId: 'atCommand', + key: 'atCommand', + permissionType: undefined, + }, + { + stateAtom: store.plusCommand, + localizationKey: 'com_nav_plus_command_description' as const, + switchId: 'plusCommand', + key: 'plusCommand', + permissionType: PermissionTypes.MULTI_CONVO, + }, + { + stateAtom: store.slashCommand, + localizationKey: 'com_nav_slash_command_description' as const, + switchId: 'slashCommand', + key: 'slashCommand', + permissionType: PermissionTypes.PROMPTS, + }, +] as const; function Commands() { const localize = useLocalize(); @@ -19,6 +42,19 @@ function Commands() { permission: Permissions.USE, }); + const getShowSwitch = (permissionType?: PermissionTypes) => { + if (!permissionType) { + return true; + } + if (permissionType === PermissionTypes.MULTI_CONVO) { + return hasAccessToMultiConvo === true; + } + if (permissionType === PermissionTypes.PROMPTS) { + return hasAccessToPrompts === true; + } + return true; + }; + return (
@@ -28,19 +64,16 @@ function Commands() {
-
- -
- {hasAccessToMultiConvo === true && ( -
- + {commandSwitchConfigs.map((config) => ( +
+
- )} - {hasAccessToPrompts === true && ( -
- -
- )} + ))}
); diff --git a/client/src/components/Nav/SettingsTabs/Commands/PlusCommandSwitch.tsx b/client/src/components/Nav/SettingsTabs/Commands/PlusCommandSwitch.tsx deleted file mode 100644 index 2125f94a19..0000000000 --- a/client/src/components/Nav/SettingsTabs/Commands/PlusCommandSwitch.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; -import store from '~/store'; - -export default function PlusCommandSwitch() { - const [plusCommand, setPlusCommand] = useRecoilState(store.plusCommand); - const localize = useLocalize(); - - const handleCheckedChange = (value: boolean) => { - setPlusCommand(value); - }; - - return ( -
-
{localize('com_nav_plus_command_description')}
- -
- ); -} diff --git a/client/src/components/Nav/SettingsTabs/Commands/SlashCommandSwitch.tsx b/client/src/components/Nav/SettingsTabs/Commands/SlashCommandSwitch.tsx deleted file mode 100644 index 68b4636365..0000000000 --- a/client/src/components/Nav/SettingsTabs/Commands/SlashCommandSwitch.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; -import store from '~/store'; - -export default function SlashCommandSwitch() { - const [slashCommand, setSlashCommand] = useRecoilState(store.slashCommand); - const localize = useLocalize(); - - const handleCheckedChange = (value: boolean) => { - setSlashCommand(value); - }; - - return ( -
-
{localize('com_nav_slash_command_description')}
- -
- ); -} diff --git a/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx b/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx index bd745dcee8..44535e0a54 100644 --- a/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx @@ -31,12 +31,12 @@ export const ClearChats = () => { return (
- +
- +
diff --git a/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx b/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx index 573a87e7a4..48c7f3a434 100644 --- a/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx @@ -38,14 +38,14 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => { return (
- + diff --git a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx index 7837a052c0..f3c6f4e8cb 100644 --- a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx @@ -1,96 +1,114 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useCallback } from 'react'; import { Import } from 'lucide-react'; -import { Spinner, useToastContext } from '@librechat/client'; -import type { TError } from 'librechat-data-provider'; +import { Spinner, useToastContext, Label, Button } from '@librechat/client'; import { useUploadConversationsMutation } from '~/data-provider'; +import { NotificationSeverity } from '~/common'; import { useLocalize } from '~/hooks'; -import { cn } from '~/utils'; +import { cn, logger } from '~/utils'; function ImportConversations() { const localize = useLocalize(); const fileInputRef = useRef(null); - const { showToast } = useToastContext(); - const [, setErrors] = useState([]); - const [allowImport, setAllowImport] = useState(true); - const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]); + + const [isUploading, setIsUploading] = useState(false); + + const handleSuccess = useCallback(() => { + showToast({ + message: localize('com_ui_import_conversation_success'), + status: NotificationSeverity.SUCCESS, + }); + setIsUploading(false); + }, [localize, showToast]); + + const handleError = useCallback( + (error: unknown) => { + logger.error('Import error:', error); + setIsUploading(false); + + const isUnsupportedType = error?.toString().includes('Unsupported import type'); + + showToast({ + message: localize( + isUnsupportedType + ? 'com_ui_import_conversation_file_type_error' + : 'com_ui_import_conversation_error', + ), + status: NotificationSeverity.ERROR, + }); + }, + [localize, showToast], + ); const uploadFile = useUploadConversationsMutation({ - onSuccess: () => { - showToast({ message: localize('com_ui_import_conversation_success') }); - setAllowImport(true); - }, - onError: (error) => { - console.error('Error: ', error); - setAllowImport(true); - setError( - (error as TError).response?.data?.message ?? 'An error occurred while uploading the file.', - ); - if (error?.toString().includes('Unsupported import type') === true) { - showToast({ - message: localize('com_ui_import_conversation_file_type_error'), - status: 'error', - }); - } else { - showToast({ message: localize('com_ui_import_conversation_error'), status: 'error' }); - } - }, - onMutate: () => { - setAllowImport(false); - }, + onSuccess: handleSuccess, + onError: handleError, + onMutate: () => setIsUploading(true), }); - const startUpload = async (file: File) => { - const formData = new FormData(); - formData.append('file', file, encodeURIComponent(file.name || 'File')); + const handleFileUpload = useCallback( + async (file: File) => { + try { + const formData = new FormData(); + formData.append('file', file, encodeURIComponent(file.name || 'File')); + uploadFile.mutate(formData); + } catch (error) { + logger.error('File processing error:', error); + setIsUploading(false); + showToast({ + message: localize('com_ui_import_conversation_upload_error'), + status: NotificationSeverity.ERROR, + }); + } + }, + [uploadFile, showToast, localize], + ); - uploadFile.mutate(formData); - }; + const handleFileChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + handleFileUpload(file); + } + event.target.value = ''; + }, + [handleFileUpload], + ); - const handleFiles = async (_file: File) => { - try { - await startUpload(_file); - } catch (error) { - console.log('file handling error', error); - setError('An error occurred while processing the file.'); - } - }; - - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - handleFiles(file); - } - }; - - const handleImportClick = () => { + const handleImportClick = useCallback(() => { fileInputRef.current?.click(); - }; + }, []); - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleImportClick(); - } - }; + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleImportClick(); + } + }, + [handleImportClick], + ); + + const isImportDisabled = isUploading; return (
-
{localize('com_ui_import_conversation_info')}
- + { - const localize = useLocalize(); - - return ( -
- - -
- ); -}; diff --git a/client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx b/client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx new file mode 100644 index 0000000000..25147146ba --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { useRevokeAllUserKeysMutation } from 'librechat-data-provider/react-query'; +import { + OGDialogTemplate, + Button, + Label, + OGDialog, + OGDialogTrigger, + Spinner, +} from '@librechat/client'; +import { useLocalize } from '~/hooks'; + +export const RevokeKeys = ({ + disabled = false, + setDialogOpen, +}: { + disabled?: boolean; + setDialogOpen?: (open: boolean) => void; +}) => { + const localize = useLocalize(); + const [open, setOpen] = useState(false); + const revokeKeysMutation = useRevokeAllUserKeysMutation(); + + const handleSuccess = () => { + if (!setDialogOpen) { + return; + } + + setDialogOpen(false); + }; + + const onClick = () => { + revokeKeysMutation.mutate({}, { onSuccess: handleSuccess }); + }; + + const isLoading = revokeKeysMutation.isLoading; + + return ( +
+ + + + + + + + {localize('com_ui_revoke_keys_confirm')} + + } + selection={{ + selectHandler: onClick, + selectClasses: + 'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80', + selectText: isLoading ? : localize('com_ui_revoke'), + }} + /> + +
+ ); +}; diff --git a/client/src/components/Nav/SettingsTabs/Data/RevokeKeysButton.tsx b/client/src/components/Nav/SettingsTabs/Data/RevokeKeysButton.tsx deleted file mode 100644 index 51cf386a5d..0000000000 --- a/client/src/components/Nav/SettingsTabs/Data/RevokeKeysButton.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useState } from 'react'; -import { - useRevokeAllUserKeysMutation, - useRevokeUserKeyMutation, -} from 'librechat-data-provider/react-query'; -import { - OGDialogTemplate, - Button, - Label, - OGDialog, - OGDialogTrigger, - Spinner, -} from '@librechat/client'; -import { useLocalize } from '~/hooks'; - -export const RevokeKeysButton = ({ - endpoint = '', - all = false, - disabled = false, - setDialogOpen, -}: { - endpoint?: string; - all?: boolean; - disabled?: boolean; - setDialogOpen?: (open: boolean) => void; -}) => { - const localize = useLocalize(); - const [open, setOpen] = useState(false); - const revokeKeyMutation = useRevokeUserKeyMutation(endpoint); - const revokeKeysMutation = useRevokeAllUserKeysMutation(); - - const handleSuccess = () => { - if (!setDialogOpen) { - return; - } - - setDialogOpen(false); - }; - - const onClick = () => { - if (all) { - revokeKeysMutation.mutate({}); - } else { - revokeKeyMutation.mutate({}, { onSuccess: handleSuccess }); - } - }; - - const dialogTitle = all - ? localize('com_ui_revoke_keys') - : localize('com_ui_revoke_key_endpoint', { 0: endpoint }); - - const dialogMessage = all - ? localize('com_ui_revoke_keys_confirm') - : localize('com_ui_revoke_key_confirm'); - - const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading; - - return ( - - - - - {dialogMessage}} - selection={{ - selectHandler: onClick, - selectClasses: - 'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80', - selectText: isLoading ? : localize('com_ui_revoke'), - }} - /> - - ); -}; diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index 5f24a5770c..ae25223a9b 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -286,11 +286,13 @@ export default function SharedLinks() { return (
-
{localize('com_nav_shared_links')}
+ setIsOpen(true)}> - + -
{localize('com_nav_theme')}
+
{localize('com_nav_theme')}
); @@ -112,9 +115,11 @@ export const LangSelector = ({ { value: 'uk-UA', label: localize('com_nav_lang_ukrainian') }, ]; + const labelId = 'language-selector-label'; + return (
-
{localize('com_nav_language')}
+
{localize('com_nav_language')}
); diff --git a/client/src/components/Nav/SettingsTabs/Personalization.tsx b/client/src/components/Nav/SettingsTabs/Personalization.tsx index 50ce452783..f9e43dc6f5 100644 --- a/client/src/components/Nav/SettingsTabs/Personalization.tsx +++ b/client/src/components/Nav/SettingsTabs/Personalization.tsx @@ -65,10 +65,13 @@ export default function Personalization({
-
+
{localize('com_ui_reference_saved_memories')}
-
+
{localize('com_ui_reference_saved_memories_description')}
@@ -76,7 +79,8 @@ export default function Personalization({ checked={referenceSavedMemories} onCheckedChange={handleMemoryToggle} disabled={updateMemoryPreferencesMutation.isLoading} - aria-label={localize('com_ui_reference_saved_memories')} + aria-labelledby="reference-saved-memories-label" + aria-describedby="reference-saved-memories-description" />
diff --git a/client/src/components/Nav/SettingsTabs/Speech/ConversationModeSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/ConversationModeSwitch.tsx index 5745943e57..3a3df8efab 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/ConversationModeSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/ConversationModeSwitch.tsx @@ -1,6 +1,5 @@ -import { Switch } from '@librechat/client'; import { useRecoilState, useRecoilValue } from 'recoil'; -import { useLocalize } from '~/hooks'; +import ToggleSwitch from '../ToggleSwitch'; import store from '~/store'; export default function ConversationModeSwitch({ @@ -8,8 +7,6 @@ export default function ConversationModeSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [conversationMode, setConversationMode] = useRecoilState(store.conversationMode); const speechToText = useRecoilValue(store.speechToText); const textToSpeech = useRecoilValue(store.textToSpeech); const [, setAutoSendText] = useRecoilState(store.autoSendText); @@ -20,27 +17,19 @@ export default function ConversationModeSwitch({ setAutoTranscribeAudio(value); setAutoSendText(3); setDecibelValue(-45); - setConversationMode(value); if (onCheckedChange) { onCheckedChange(value); } }; return ( -
-
- {localize('com_nav_conversation_mode')} -
-
- -
-
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/AutoSendTextSelector.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/AutoSendTextSelector.tsx index 5c5e8e3da3..a033ed322c 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/AutoSendTextSelector.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/AutoSendTextSelector.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; -import { Slider, InputNumber } from '@librechat/client'; +import { Slider, InputNumber, Switch } from '@librechat/client'; import { cn, defaultTextProps, optionText } from '~/utils/'; import { useLocalize } from '~/hooks'; import store from '~/store'; @@ -11,40 +11,104 @@ export default function AutoSendTextSelector() { const speechToText = useRecoilValue(store.speechToText); const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText); + // Local state for enabled/disabled toggle + const [isEnabled, setIsEnabled] = useState(autoSendText !== -1); + const [delayValue, setDelayValue] = useState(autoSendText === -1 ? 3 : autoSendText); + + // Sync local state when autoSendText changes externally + useEffect(() => { + setIsEnabled(autoSendText !== -1); + if (autoSendText !== -1) { + setDelayValue(autoSendText); + } + }, [autoSendText]); + + const handleToggle = (enabled: boolean) => { + setIsEnabled(enabled); + if (enabled) { + setAutoSendText(delayValue); + } else { + setAutoSendText(-1); + } + }; + + const handleSliderChange = (value: number[]) => { + const newValue = value[0]; + setDelayValue(newValue); + if (isEnabled) { + setAutoSendText(newValue); + } + }; + + const handleInputChange = (value: number[] | null) => { + const newValue = value ? value[0] : 3; + setDelayValue(newValue); + if (isEnabled) { + setAutoSendText(newValue); + } + }; + + const labelId = 'auto-send-text-label'; + return ( -
+
-
{localize('com_nav_auto_send_text')}
-
- ({localize('com_nav_auto_send_text_disabled')}) -
-
- setAutoSendText(value[0])} - onDoubleClick={() => setAutoSendText(-1)} - min={-1} - max={60} - step={1} - className="ml-4 flex h-4 w-24" +
+
{localize('com_nav_auto_send_text')}
+
+ -
- setAutoSendText(value ? value[0] : 0)} - min={-1} - max={60} - className={cn( - defaultTextProps, - cn( - optionText, - 'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200', - ), - )} - />
+ {isEnabled && ( +
+
+
+ {localize('com_nav_setting_delay')} +
+
+
+ { + setDelayValue(3); + if (isEnabled) { + setAutoSendText(3); + } + }} + min={0} + max={60} + step={1} + className="ml-4 flex h-4 w-24" + disabled={!speechToText || !isEnabled} + aria-labelledby="auto-send-delay-label" + /> +
+ +
+
+ )}
); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/AutoTranscribeAudioSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/AutoTranscribeAudioSwitch.tsx index 1304f35c63..00a8c9f833 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/AutoTranscribeAudioSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/AutoTranscribeAudioSwitch.tsx @@ -1,6 +1,5 @@ -import { Switch } from '@librechat/client'; -import { useRecoilState, useRecoilValue } from 'recoil'; -import { useLocalize } from '~/hooks'; +import { useRecoilValue } from 'recoil'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function AutoTranscribeAudioSwitch({ @@ -8,30 +7,15 @@ export default function AutoTranscribeAudioSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState( - store.autoTranscribeAudio, - ); const speechToText = useRecoilValue(store.speechToText); - const handleCheckedChange = (value: boolean) => { - setAutoTranscribeAudio(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; - return ( -
-
{localize('com_nav_auto_transcribe_audio')}
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx index f23de198c6..b311355108 100755 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx @@ -13,7 +13,7 @@ export default function DecibelSelector() { return (
-
{localize('com_nav_db_sensitivity')}
+
{localize('com_nav_db_sensitivity')}
({localize('com_endpoint_default_with_num', { 0: '-45' })}) @@ -29,6 +29,7 @@ export default function DecibelSelector() { step={1} className="ml-4 flex h-4 w-24" disabled={!speechToText} + aria-labelledby="decibel-selector-label" />
setDecibelValue(value ? value[0] : 0)} min={-100} max={-30} + aria-labelledby="decibel-selector-label" className={cn( defaultTextProps, cn( diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/EngineSTTDropdown.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/EngineSTTDropdown.tsx index f6bc4b91e7..8fc3dd8352 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/EngineSTTDropdown.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/EngineSTTDropdown.tsx @@ -23,9 +23,11 @@ const EngineSTTDropdown: React.FC = ({ external }) => { setEngineSTT(value); }; + const labelId = 'engine-stt-dropdown-label'; + return (
-
{localize('com_nav_engine')}
+
{localize('com_nav_engine')}
= ({ external }) => { sizeClasses="w-[180px]" testId="EngineSTTDropdown" className="z-50" + aria-labelledby={labelId} />
); diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/LanguageSTTDropdown.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/LanguageSTTDropdown.tsx index c3bb37ceef..53da4e7989 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/LanguageSTTDropdown.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/LanguageSTTDropdown.tsx @@ -94,9 +94,11 @@ export default function LanguageSTTDropdown() { setLanguageSTT(value); }; + const labelId = 'language-stt-dropdown-label'; + return (
-
{localize('com_nav_language')}
+
{localize('com_nav_language')}
); diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/SpeechToTextSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/SpeechToTextSwitch.tsx index 99c81f60e5..e06f3392d0 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/SpeechToTextSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/SpeechToTextSwitch.tsx @@ -1,6 +1,4 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function SpeechToTextSwitch({ @@ -8,28 +6,13 @@ export default function SpeechToTextSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [speechToText, setSpeechToText] = useRecoilState(store.speechToText); - - const handleCheckedChange = (value: boolean) => { - setSpeechToText(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; - return ( -
-
- {localize('com_nav_speech_to_text')} -
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx b/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx index acd87fa233..ee4c1eb09d 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx @@ -23,7 +23,7 @@ import { } from './STT'; import ConversationModeSwitch from './ConversationModeSwitch'; import { useLocalize } from '~/hooks'; -import { cn, logger } from '~/utils'; +import { cn } from '~/utils'; import store from '~/store'; function Speech() { @@ -186,7 +186,7 @@ function Speech() {
- +
@@ -198,7 +198,7 @@ function Speech() {
- +
diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/AutomaticPlaybackSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/AutomaticPlaybackSwitch.tsx index 67537a8d65..916d38f5af 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/AutomaticPlaybackSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/AutomaticPlaybackSwitch.tsx @@ -1,6 +1,4 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function AutomaticPlaybackSwitch({ @@ -8,26 +6,12 @@ export default function AutomaticPlaybackSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [automaticPlayback, setAutomaticPlayback] = useRecoilState(store.automaticPlayback); - - const handleCheckedChange = (value: boolean) => { - setAutomaticPlayback(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; - return ( -
-
{localize('com_nav_automatic_playback')}
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/CacheTTSSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/CacheTTSSwitch.tsx index b3268e1a4b..6ebc54e3da 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/CacheTTSSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/CacheTTSSwitch.tsx @@ -1,6 +1,5 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; +import { useRecoilValue } from 'recoil'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function CacheTTSSwitch({ @@ -8,28 +7,15 @@ export default function CacheTTSSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [cacheTTS, setCacheTTS] = useRecoilState(store.cacheTTS); - const [textToSpeech] = useRecoilState(store.textToSpeech); - - const handleCheckedChange = (value: boolean) => { - setCacheTTS(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; + const textToSpeech = useRecoilValue(store.textToSpeech); return ( -
-
{localize('com_nav_enable_cache_tts')}
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/CloudBrowserVoicesSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/CloudBrowserVoicesSwitch.tsx index 6a10806baa..9fa57bf90a 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/CloudBrowserVoicesSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/CloudBrowserVoicesSwitch.tsx @@ -1,6 +1,5 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; +import { useRecoilValue } from 'recoil'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function CloudBrowserVoicesSwitch({ @@ -8,30 +7,15 @@ export default function CloudBrowserVoicesSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState( - store.cloudBrowserVoices, - ); - const [textToSpeech] = useRecoilState(store.textToSpeech); - - const handleCheckedChange = (value: boolean) => { - setCloudBrowserVoices(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; + const textToSpeech = useRecoilValue(store.textToSpeech); return ( -
-
{localize('com_nav_enable_cloud_browser_voice')}
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/EngineTTSDropdown.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/EngineTTSDropdown.tsx index 95d45671b3..a5a576ba92 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/EngineTTSDropdown.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/EngineTTSDropdown.tsx @@ -23,9 +23,11 @@ const EngineTTSDropdown: React.FC = ({ external }) => { setEngineTTS(value); }; + const labelId = 'engine-tts-dropdown-label'; + return (
-
{localize('com_nav_engine')}
+
{localize('com_nav_engine')}
= ({ external }) => { sizeClasses="w-[180px]" testId="EngineTTSDropdown" className="z-50" + aria-labelledby={labelId} />
); diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/PlaybackRate.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/PlaybackRate.tsx index 571055a377..fee956e2f2 100755 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/PlaybackRate.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/PlaybackRate.tsx @@ -13,7 +13,7 @@ export default function DecibelSelector() { return (
-
{localize('com_nav_playback_rate')}
+
{localize('com_nav_playback_rate')}
({localize('com_endpoint_default_with_num', { 0: '1' })}) @@ -29,6 +29,7 @@ export default function DecibelSelector() { step={0.1} className="ml-4 flex h-4 w-24" disabled={!textToSpeech} + aria-labelledby="playback-rate-label" />
setPlaybackRate(value ? value[0] : 0)} min={0.1} max={2} + aria-labelledby="playback-rate-label" className={cn( defaultTextProps, cn( diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/TextToSpeechSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/TextToSpeechSwitch.tsx index b9a4ad1665..f4c499eb78 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/TextToSpeechSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/TextToSpeechSwitch.tsx @@ -1,6 +1,4 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function TextToSpeechSwitch({ @@ -8,28 +6,13 @@ export default function TextToSpeechSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [TextToSpeech, setTextToSpeech] = useRecoilState(store.textToSpeech); - - const handleCheckedChange = (value: boolean) => { - setTextToSpeech(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; - return ( -
-
- {localize('com_nav_text_to_speech')} -
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx b/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx index 64c8062ca7..391ab0a494 100644 --- a/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx @@ -11,6 +11,9 @@ interface ToggleSwitchProps { hoverCardText?: LocalizeKey; switchId: string; onCheckedChange?: (value: boolean) => void; + showSwitch?: boolean; + disabled?: boolean; + strongLabel?: boolean; } const ToggleSwitch: React.FC = ({ @@ -19,6 +22,9 @@ const ToggleSwitch: React.FC = ({ hoverCardText, switchId, onCheckedChange, + showSwitch = true, + disabled = false, + strongLabel = false, }) => { const [switchState, setSwitchState] = useRecoilState(stateAtom); const localize = useLocalize(); @@ -28,10 +34,18 @@ const ToggleSwitch: React.FC = ({ onCheckedChange?.(value); }; + const labelId = `${switchId}-label`; + + if (!showSwitch) { + return null; + } + return (
-
{localize(localizationKey)}
+
+ {strongLabel ? {localize(localizationKey)} : localize(localizationKey)} +
{hoverCardText && }
= ({ onCheckedChange={handleCheckedChange} className="ml-4" data-testid={switchId} + aria-labelledby={labelId} + disabled={disabled} />
); diff --git a/client/src/components/Nav/SettingsTabs/index.ts b/client/src/components/Nav/SettingsTabs/index.ts index 9eab047c86..911cbaa98a 100644 --- a/client/src/components/Nav/SettingsTabs/index.ts +++ b/client/src/components/Nav/SettingsTabs/index.ts @@ -1,9 +1,8 @@ -export { default as General } from './General/General'; export { default as Chat } from './Chat/Chat'; export { default as Data } from './Data/Data'; -export { default as Commands } from './Commands/Commands'; -export { RevokeKeysButton } from './Data/RevokeKeysButton'; -export { default as Account } from './Account/Account'; -export { default as Balance } from './Balance/Balance'; export { default as Speech } from './Speech/Speech'; +export { default as Balance } from './Balance/Balance'; +export { default as General } from './General/General'; +export { default as Account } from './Account/Account'; +export { default as Commands } from './Commands/Commands'; export { default as Personalization } from './Personalization'; diff --git a/client/src/components/Prompts/Groups/ChatGroupItem.tsx b/client/src/components/Prompts/Groups/ChatGroupItem.tsx index a69ff16be3..586535f7cd 100644 --- a/client/src/components/Prompts/Groups/ChatGroupItem.tsx +++ b/client/src/components/Prompts/Groups/ChatGroupItem.tsx @@ -71,10 +71,12 @@ function ChatGroupItem({ { e.stopPropagation(); setPreviewDialogOpen(true); }} - className="w-full cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" + className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" > - {canEdit && ( { e.stopPropagation(); onEditClick(e); }} > - diff --git a/client/src/components/Prompts/Groups/DashGroupItem.tsx b/client/src/components/Prompts/Groups/DashGroupItem.tsx index 3e906fba8c..d10def66c6 100644 --- a/client/src/components/Prompts/Groups/DashGroupItem.tsx +++ b/client/src/components/Prompts/Groups/DashGroupItem.tsx @@ -89,7 +89,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps onKeyDown={handleKeyDown} role="button" tabIndex={0} - aria-label={`${group.name} prompt group`} + aria-label={`${group.name} Prompt, ${localize('com_ui_category')}: ${group.category ?? ''}`} >
diff --git a/client/src/components/Prompts/Groups/FilterPrompts.tsx b/client/src/components/Prompts/Groups/FilterPrompts.tsx index 7f2b4d301d..f74800bdf8 100644 --- a/client/src/components/Prompts/Groups/FilterPrompts.tsx +++ b/client/src/components/Prompts/Groups/FilterPrompts.tsx @@ -87,7 +87,7 @@ export default function FilterPrompts({ className = '' }: { className?: string } value={categoryFilter || SystemCategories.ALL} onChange={onSelect} options={filterOptions} - className="bg-transparent" + className="rounded-lg bg-transparent" icon={} label="Filter: " ariaLabel={localize('com_ui_filter_prompts')} diff --git a/client/src/components/Prompts/Groups/List.tsx b/client/src/components/Prompts/Groups/List.tsx index dca07b07b0..2dc8c5265e 100644 --- a/client/src/components/Prompts/Groups/List.tsx +++ b/client/src/components/Prompts/Groups/List.tsx @@ -40,7 +40,7 @@ export default function List({
)} -
+
{isLoading && isChatRoute && ( diff --git a/client/src/components/Prompts/PreviewPrompt.tsx b/client/src/components/Prompts/PreviewPrompt.tsx index b16443cb75..6193bd9e5d 100644 --- a/client/src/components/Prompts/PreviewPrompt.tsx +++ b/client/src/components/Prompts/PreviewPrompt.tsx @@ -14,7 +14,7 @@ const PreviewPrompt = ({ return ( -
+
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index c4b8481398..2fc5623f4d 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1,6 +1,6 @@ { - "chat_direction_left_to_right": "something needs to go here. was empty", - "chat_direction_right_to_left": "something needs to go here. was empty", + "chat_direction_left_to_right": "Left to Right", + "chat_direction_right_to_left": "Right to Left", "com_a11y_ai_composing": "The AI is still composing.", "com_a11y_end": "The AI has finished their reply.", "com_a11y_start": "The AI has started their reply.", @@ -408,7 +408,6 @@ "com_nav_auto_scroll": "Auto-Scroll to latest message on chat open", "com_nav_auto_send_prompts": "Auto-send Prompts", "com_nav_auto_send_text": "Auto send text", - "com_nav_auto_send_text_disabled": "set -1 to disable", "com_nav_auto_transcribe_audio": "Auto transcribe audio", "com_nav_automatic_playback": "Autoplay Latest Message", "com_nav_balance": "Balance", @@ -573,6 +572,7 @@ "com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard", "com_nav_speech_to_text": "Speech to Text", "com_nav_stop_generating": "Stop generating", + "com_nav_setting_delay": "Delay (s)", "com_nav_text_to_speech": "Text to Speech", "com_nav_theme": "Theme", "com_nav_theme_dark": "Dark", @@ -761,6 +761,7 @@ "com_ui_close": "Close", "com_ui_close_menu": "Close Menu", "com_ui_close_window": "Close Window", + "com_ui_close_settings": "Close Settings", "com_ui_code": "Code", "com_ui_collapse_chat": "Collapse Chat", "com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used", @@ -950,8 +951,9 @@ "com_ui_image_edited": "Image edited", "com_ui_image_gen": "Image Gen", "com_ui_import": "Import", - "com_ui_import_conversation_error": "There was an error importing your conversations", - "com_ui_import_conversation_file_type_error": "Unsupported import type", + "com_ui_import_conversation_error": "There was an error while importing your conversations", + "com_ui_import_conversation_file_type_error": "Error with file type. Please select a valid JSON file.", + "com_ui_import_conversation_upload_error": "Error uploading file. Please try again.", "com_ui_import_conversation_info": "Import conversations from a JSON file", "com_ui_import_conversation_success": "Conversations imported successfully", "com_ui_include_shadcnui": "Include shadcn/ui components instructions", @@ -1077,6 +1079,7 @@ "com_ui_prompts_allow_create": "Allow creating Prompts", "com_ui_prompts_allow_share": "Allow sharing Prompts", "com_ui_prompts_allow_use": "Allow using Prompts", + "com_ui_prompt_groups": "Prompt Groups List", "com_ui_provider": "Provider", "com_ui_quality": "Quality", "com_ui_read_aloud": "Read aloud", @@ -1279,5 +1282,21 @@ "com_ui_x_selected": "{{0}} selected", "com_ui_yes": "Yes", "com_ui_zoom": "Zoom", - "com_user_message": "You" + "com_user_message": "You", + "com_ui_rotate": "Rotate", + "com_ui_reset": "Reset", + "com_ui_zoom_in": "Zoom in", + "com_ui_zoom_out": "Zoom out", + "com_ui_zoom_level": "Zoom level", + "com_ui_rotate_90": "Rotate 90 degrees", + "com_ui_reset_adjustments": "Reset adjustments", + "com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size", + "com_ui_save_key_success": "API key saved successfully", + "com_ui_save_key_error": "Failed to save API key. Please try again.", + "com_ui_revoke_key_success": "API key revoked successfully", + "com_ui_revoke_key_error": "Failed to revoke API key. Please try again.", + "com_ui_key_required": "API key is required", + "com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})", + "com_ui_upload_avatar_label": "Upload avatar image", + "com_ui_file_input_avatar_label": "File input for avatar" } diff --git a/packages/client/src/components/Dropdown.tsx b/packages/client/src/components/Dropdown.tsx index 5a4d3f2b20..536bbc5829 100644 --- a/packages/client/src/components/Dropdown.tsx +++ b/packages/client/src/components/Dropdown.tsx @@ -16,6 +16,7 @@ interface DropdownProps { iconOnly?: boolean; renderValue?: (option: Option) => React.ReactNode; ariaLabel?: string; + 'aria-labelledby'?: string; portal?: boolean; } @@ -37,6 +38,7 @@ const Dropdown: React.FC = ({ iconOnly = false, renderValue, ariaLabel, + 'aria-labelledby': ariaLabelledBy, portal = true, }) => { const handleChange = (value: string) => { @@ -77,6 +79,7 @@ const Dropdown: React.FC = ({ )} data-testid={testId} aria-label={ariaLabel} + aria-labelledby={ariaLabelledBy} >
{icon} diff --git a/packages/client/src/components/DropdownMenu.tsx b/packages/client/src/components/DropdownMenu.tsx index b317806826..4c050a2713 100644 --- a/packages/client/src/components/DropdownMenu.tsx +++ b/packages/client/src/components/DropdownMenu.tsx @@ -1,191 +1,225 @@ import * as React from 'react'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import { Check, ChevronRight, Circle } from 'lucide-react'; +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; import { cn } from '~/utils'; -const DropdownMenu = DropdownMenuPrimitive.Root; +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ; +} -const DropdownMenuGroup = DropdownMenuPrimitive.Group; +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} -const DropdownMenuSub = DropdownMenuPrimitive.Sub; +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className = '', inset, children, ...props }, ref) => ( - - {children} - - -)); -DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = '', ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = '', sideOffset = 4, ...props }, ref) => ( - - & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + - -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className = '', inset, ...props }, ref) => ( - -)); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = '', children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = '', children, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className = '', inset, ...props }, ref) => ( - -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = '', ...props }, ref) => ( - -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; - -const DropdownMenuShortcut = ({ - className = '', - ...props -}: React.HTMLAttributes) => { - return ( - ); -}; -DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} export { DropdownMenu, + DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, DropdownMenuRadioItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, DropdownMenuSub, - DropdownMenuSubContent, DropdownMenuSubTrigger, - DropdownMenuRadioGroup, + DropdownMenuSubContent, }; diff --git a/packages/client/src/components/InfoHoverCard.tsx b/packages/client/src/components/InfoHoverCard.tsx index ab43b6dd18..5b45666807 100644 --- a/packages/client/src/components/InfoHoverCard.tsx +++ b/packages/client/src/components/InfoHoverCard.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { CircleHelpIcon } from 'lucide-react'; import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from './HoverCard'; import { ESide } from '~/common'; @@ -8,15 +9,23 @@ type InfoHoverCardProps = { }; const InfoHoverCard = ({ side, text }: InfoHoverCardProps) => { + const [isOpen, setIsOpen] = useState(false); + return ( - - - {' '} + + setIsOpen(true)} + onBlur={() => setIsOpen(false)} + aria-label={text} + > +
-

{text}

+ {text}
diff --git a/packages/client/src/components/Label.tsx b/packages/client/src/components/Label.tsx index 54f75fb2a8..d250e47e3f 100644 --- a/packages/client/src/components/Label.tsx +++ b/packages/client/src/components/Label.tsx @@ -13,7 +13,7 @@ const Label = React.forwardRef< {...props} {...{ className: cn( - 'block w-full break-all text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200', + 'block w-full break-all text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200', className, ), }} diff --git a/packages/client/src/components/Slider.tsx b/packages/client/src/components/Slider.tsx index 4be0f20039..3845b8901f 100644 --- a/packages/client/src/components/Slider.tsx +++ b/packages/client/src/components/Slider.tsx @@ -2,37 +2,56 @@ import * as React from 'react'; import * as SliderPrimitive from '@radix-ui/react-slider'; import { cn } from '~/utils'; -const Slider = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - className?: string; - onDoubleClick?: () => void; - } ->(({ className, onDoubleClick, ...props }, ref) => ( - & { + className?: string; + onDoubleClick?: () => void; + 'aria-describedby'?: string; +} & ( + | { 'aria-label': string; 'aria-labelledby'?: never } + | { 'aria-labelledby': string; 'aria-label'?: never } + | { 'aria-label': string; 'aria-labelledby': string } + ); + +const Slider = React.forwardRef, SliderProps>( + ( + { + className, onDoubleClick, - }} - > - - - - ( + - -)); + > + + + + + + ), +); Slider.displayName = SliderPrimitive.Root.displayName; export { Slider }; From bb7a0274fa722c2b4b969d5623d4c4343f62e549 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:14:18 -0400 Subject: [PATCH 016/272] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#9995)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/ar/translation.json | 1 - client/src/locales/ca/translation.json | 1 - client/src/locales/cs/translation.json | 1 - client/src/locales/da/translation.json | 1 - client/src/locales/de/translation.json | 1 - client/src/locales/es/translation.json | 1 - client/src/locales/et/translation.json | 1 - client/src/locales/fa/translation.json | 1 - client/src/locales/fr/translation.json | 1 - client/src/locales/he/translation.json | 1 - client/src/locales/hu/translation.json | 1 - client/src/locales/it/translation.json | 1 - client/src/locales/ja/translation.json | 1 - client/src/locales/ko/translation.json | 1 - client/src/locales/lv/translation.json | 1 - client/src/locales/nb/translation.json | 1 - client/src/locales/pt-BR/translation.json | 1 - client/src/locales/pt-PT/translation.json | 1 - client/src/locales/ru/translation.json | 1 - client/src/locales/th/translation.json | 1 - client/src/locales/tr/translation.json | 1 - client/src/locales/uk/translation.json | 1 - client/src/locales/zh-Hans/translation.json | 1 - client/src/locales/zh-Hant/translation.json | 1 - 24 files changed, 24 deletions(-) diff --git a/client/src/locales/ar/translation.json b/client/src/locales/ar/translation.json index 0ac4053c1f..783925519d 100644 --- a/client/src/locales/ar/translation.json +++ b/client/src/locales/ar/translation.json @@ -243,7 +243,6 @@ "com_error_files_dupe": "تم اكتشاف ملف مكرر.", "com_error_files_empty": "الملفات الفارغة غير مسموح بها", "com_error_files_process": "حدث خطأ أثناء معالجة الملف.", - "com_error_files_unsupported_capability": "لا توجد قدرات مفعّلة تدعم هذا النوع من الملفات.", "com_error_files_upload": "حدث خطأ أثناء رفع الملف.", "com_error_files_upload_canceled": "تم إلغاء طلب تحميل الملف. ملاحظة: قد تكون عملية تحميل الملف لا تزال قيد المعالجة وستحتاج إلى حذفها يدويًا.", "com_error_files_validation": "حدث خطأ أثناء التحقق من صحة الملف.", diff --git a/client/src/locales/ca/translation.json b/client/src/locales/ca/translation.json index f4624a156b..9deb6fd305 100644 --- a/client/src/locales/ca/translation.json +++ b/client/src/locales/ca/translation.json @@ -273,7 +273,6 @@ "com_error_files_dupe": "S'ha detectat un fitxer duplicat.", "com_error_files_empty": "No es permeten fitxers buits.", "com_error_files_process": "S'ha produït un error en processar el fitxer.", - "com_error_files_unsupported_capability": "No hi ha capacitats habilitades que admetin aquest tipus de fitxer.", "com_error_files_upload": "S'ha produït un error en pujar el fitxer.", "com_error_files_upload_canceled": "La sol·licitud de pujada de fitxer s'ha cancel·lat. Nota: la pujada podria seguir processant-se i s'haurà d'esborrar manualment.", "com_error_files_validation": "S'ha produït un error en validar el fitxer.", diff --git a/client/src/locales/cs/translation.json b/client/src/locales/cs/translation.json index cd838f4015..d776f584cc 100644 --- a/client/src/locales/cs/translation.json +++ b/client/src/locales/cs/translation.json @@ -189,7 +189,6 @@ "com_error_files_dupe": "Byl zjištěn duplicitní soubor.", "com_error_files_empty": "Prázdné soubory nejsou povoleny.", "com_error_files_process": "Při zpracování souboru došlo k chybě.", - "com_error_files_unsupported_capability": "Nejsou povoleny žádné funkce podporující tento typ souboru.", "com_error_files_upload": "Při nahrávání souboru došlo k chybě.", "com_error_files_upload_canceled": "Požadavek na nahrání souboru byl zrušen. Poznámka: nahrávání souboru může stále probíhat a bude nutné jej ručně smazat.", "com_error_files_validation": "Při ověřování souboru došlo k chybě.", diff --git a/client/src/locales/da/translation.json b/client/src/locales/da/translation.json index a664f6b435..67e93245db 100644 --- a/client/src/locales/da/translation.json +++ b/client/src/locales/da/translation.json @@ -268,7 +268,6 @@ "com_error_files_dupe": "Duplikatfil fundet.", "com_error_files_empty": "Tomme filer er ikke tilladt.", "com_error_files_process": "Der opstod en fejl under behandlingen af filen.", - "com_error_files_unsupported_capability": "Ingen funktioner er aktiveret, der understøtter denne filtype.", "com_error_files_upload": "Der opstod en fejl under upload af filen.", "com_error_files_upload_canceled": "Anmodningen om filoverførsel blev annulleret. Bemærk: Filuploaden kan stadig være under behandling og skal slettes manuelt.", "com_error_files_validation": "Der opstod en fejl under validering af filen.", diff --git a/client/src/locales/de/translation.json b/client/src/locales/de/translation.json index 2aeada60bc..845e5aa919 100644 --- a/client/src/locales/de/translation.json +++ b/client/src/locales/de/translation.json @@ -361,7 +361,6 @@ "com_error_files_dupe": "Doppelte Datei erkannt.", "com_error_files_empty": "Leere Dateien sind nicht zulässig", "com_error_files_process": "Bei der Verarbeitung der Datei ist ein Fehler aufgetreten.", - "com_error_files_unsupported_capability": "Keine aktivierten Funktionen unterstützen diesen Dateityp", "com_error_files_upload": "Beim Hochladen der Datei ist ein Fehler aufgetreten", "com_error_files_upload_canceled": "Die Datei-Upload-Anfrage wurde abgebrochen. Hinweis: Der Upload-Vorgang könnte noch im Hintergrund laufen und die Datei muss möglicherweise manuell gelöscht werden.", "com_error_files_validation": "Bei der Validierung der Datei ist ein Fehler aufgetreten.", diff --git a/client/src/locales/es/translation.json b/client/src/locales/es/translation.json index 66da09910a..7a0b6f4731 100644 --- a/client/src/locales/es/translation.json +++ b/client/src/locales/es/translation.json @@ -309,7 +309,6 @@ "com_error_files_dupe": "Se detectó un archivo duplicado", "com_error_files_empty": "No se permiten archivos vacíos.", "com_error_files_process": "Se produjo un error al procesar el archivo.", - "com_error_files_unsupported_capability": "No hay capacidades habilitadas que admitan este tipo de archivo.", "com_error_files_upload": "Se produjo un error durante la subida del archivo", "com_error_files_upload_canceled": "La solicitud de carga del archivo fue cancelada. Nota: es posible que la carga del archivo aún esté en proceso y necesite ser eliminada manualmente.", "com_error_files_validation": "Se produjo un error durante la validación del archivo.", diff --git a/client/src/locales/et/translation.json b/client/src/locales/et/translation.json index b6f843ac9e..c190ba59df 100644 --- a/client/src/locales/et/translation.json +++ b/client/src/locales/et/translation.json @@ -268,7 +268,6 @@ "com_error_files_dupe": "Leiti duplikaatfail.", "com_error_files_empty": "Tühjad failid pole lubatud.", "com_error_files_process": "Faili töötlemisel tekkis viga.", - "com_error_files_unsupported_capability": "Ühtegi seda failitüüpi toetavat võimalust pole lubatud.", "com_error_files_upload": "Faili üleslaadimisel tekkis viga.", "com_error_files_upload_canceled": "Faili üleslaadimise taotlus tühistati. Märkus: faili üleslaadimine võib endiselt olla pooleli ja see tuleb käsitsi kustutada.", "com_error_files_validation": "Faili valideerimisel tekkis viga.", diff --git a/client/src/locales/fa/translation.json b/client/src/locales/fa/translation.json index 6ab7e338e9..ea46f6dbc0 100644 --- a/client/src/locales/fa/translation.json +++ b/client/src/locales/fa/translation.json @@ -263,7 +263,6 @@ "com_error_files_dupe": "فایل تکراری شناسایی شد.", "com_error_files_empty": "فایل های خالی مجاز نیستند.", "com_error_files_process": "هنگام پردازش فایل خطایی روی داد.", - "com_error_files_unsupported_capability": "هیچ قابلیتی فعال نیست که از این نوع فایل پشتیبانی کند.", "com_error_files_upload": "هنگام آپلود فایل خطایی روی داد.", "com_error_files_upload_canceled": "درخواست آپلود فایل لغو شد. توجه: ممکن است فایل آپلود هنوز در حال پردازش باشد و باید به صورت دستی حذف شود.", "com_error_files_validation": "هنگام اعتبارسنجی فایل خطایی روی داد.", diff --git a/client/src/locales/fr/translation.json b/client/src/locales/fr/translation.json index 8ed9bd27e9..9ac0005196 100644 --- a/client/src/locales/fr/translation.json +++ b/client/src/locales/fr/translation.json @@ -301,7 +301,6 @@ "com_error_files_dupe": "Fichier en double détecté.", "com_error_files_empty": "Les fichiers vides ne sont pas autorisés", "com_error_files_process": "Une erreur s'est produite lors du traitement du fichier.", - "com_error_files_unsupported_capability": "Aucune capacité activée ne prend en charge ce type de fichier.", "com_error_files_upload": "Une erreur s'est produite lors du téléversement du fichier", "com_error_files_upload_canceled": "La demande de téléversement du fichier a été annulée. Remarque : le téléversement peut être toujours en cours de traitement et devra être supprimé manuellement.", "com_error_files_validation": "Une erreur s'est produite lors de la validation du fichier.", diff --git a/client/src/locales/he/translation.json b/client/src/locales/he/translation.json index 104fd4819a..ccd7d482b8 100644 --- a/client/src/locales/he/translation.json +++ b/client/src/locales/he/translation.json @@ -357,7 +357,6 @@ "com_error_files_dupe": "זוהה קובץ כפול", "com_error_files_empty": "אין אפשרות לקבצים ריקים", "com_error_files_process": "אירעה שגיאה במהלך עיבוד הקובץ.", - "com_error_files_unsupported_capability": "לא הופעלו התכונות התומכות בסוג קובץ זה.", "com_error_files_upload": "אירעה שגיאה בעת העלאת הקובץ", "com_error_files_upload_canceled": "בקשת העלאת הקובץ בוטלה. הערה: ייתכן שהעלאת הקובץ עדיין בעיבוד ותצטרך למחוק אותו בצורה ידנית.", "com_error_files_validation": "אירעה שגיאה במהלך אימות הקובץ.", diff --git a/client/src/locales/hu/translation.json b/client/src/locales/hu/translation.json index bbe74ecef9..78f4fdf08a 100644 --- a/client/src/locales/hu/translation.json +++ b/client/src/locales/hu/translation.json @@ -263,7 +263,6 @@ "com_error_files_dupe": "Duplikált fájl észlelve.", "com_error_files_empty": "Üres fájlok nem engedélyezettek.", "com_error_files_process": "Hiba történt a fájl feldolgozása során.", - "com_error_files_unsupported_capability": "Nincs engedélyezve olyan képesség, amely támogatja ezt a fájltípust.", "com_error_files_upload": "Hiba történt a fájl feltöltése során.", "com_error_files_upload_canceled": "A fájlfeltöltési kérés megszakítva. Megjegyzés: a fájlfeltöltés még folyamatban lehet, és manuálisan kell törölni.", "com_error_files_validation": "Hiba történt a fájl ellenőrzése során.", diff --git a/client/src/locales/it/translation.json b/client/src/locales/it/translation.json index bfc8219f97..b25c19cff2 100644 --- a/client/src/locales/it/translation.json +++ b/client/src/locales/it/translation.json @@ -330,7 +330,6 @@ "com_error_files_dupe": "File duplicato rilevato.", "com_error_files_empty": "I file vuoti non sono consentiti.", "com_error_files_process": "Si è verificato un errore durante l'elaborazione del file.", - "com_error_files_unsupported_capability": "Nessuna funzionalità abilitata che supporti questo tipo di file.", "com_error_files_upload": "Si è verificato un errore durante il caricamento del file.", "com_error_files_upload_canceled": "La richiesta di caricamento del file è stata annullata. Nota: il caricamento del file potrebbe essere ancora in corso e potrebbe essere necessario eliminarlo manualmente.", "com_error_files_validation": "Si è verificato un errore durante la validazione del file.", diff --git a/client/src/locales/ja/translation.json b/client/src/locales/ja/translation.json index 0741a749a5..ea83224d15 100644 --- a/client/src/locales/ja/translation.json +++ b/client/src/locales/ja/translation.json @@ -291,7 +291,6 @@ "com_error_files_dupe": "重複したファイルが検出されました。", "com_error_files_empty": "空のファイルはアップロードできません", "com_error_files_process": "ファイルの処理中にエラーが発生しました。", - "com_error_files_unsupported_capability": "このファイル形式に対応する機能が有効になっていません", "com_error_files_upload": "ファイルのアップロード中にエラーが発生しました。", "com_error_files_upload_canceled": "ファイルのアップロードがキャンセルされました。注意:アップロード処理が継続している可能性があるため、手動でファイルを削除する必要があるかもしれません。", "com_error_files_validation": "ファイルの検証中にエラーが発生しました。", diff --git a/client/src/locales/ko/translation.json b/client/src/locales/ko/translation.json index 835306cbc9..f143066c92 100644 --- a/client/src/locales/ko/translation.json +++ b/client/src/locales/ko/translation.json @@ -287,7 +287,6 @@ "com_error_files_dupe": "중복된 파일이 감지되었습니다", "com_error_files_empty": "빈 파일은 허용되지 않습니다", "com_error_files_process": "파일 처리 중 오류가 발생했습니다", - "com_error_files_unsupported_capability": "이 파일 형식을 지원하는 기능이 활성화되어 있지 않습니다", "com_error_files_upload": "파일 업로드 중 오류가 발생했습니다", "com_error_files_upload_canceled": "파일 업로드가 취소되었습니다. 참고: 업로드 처리가 아직 진행 중일 수 있으며 수동으로 삭제해야 할 수 있습니다.", "com_error_files_validation": "파일 유효성 검사 중 오류가 발생했습니다", diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index 8c020c3b75..22aaeae987 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -363,7 +363,6 @@ "com_error_files_dupe": "Atrasts dublikāta fails.", "com_error_files_empty": "Tukši faili nav atļauti.", "com_error_files_process": "Apstrādājot failu, radās kļūda.", - "com_error_files_unsupported_capability": "Šis faila tips nav atbalstīts.", "com_error_files_upload": "Augšupielādējot failu, radās kļūda.", "com_error_files_upload_canceled": "Faila augšupielādes pieprasījums tika atcelts. Piezīme. Iespējams, ka faila augšupielāde joprojām tiek apstrādāta, un tā būs manuāli jādzēš.", "com_error_files_validation": "Validējot failu, radās kļūda.", diff --git a/client/src/locales/nb/translation.json b/client/src/locales/nb/translation.json index 0674b28c7f..6dbbf60e2b 100644 --- a/client/src/locales/nb/translation.json +++ b/client/src/locales/nb/translation.json @@ -360,7 +360,6 @@ "com_error_files_dupe": "Duplikatfil oppdaget.", "com_error_files_empty": "Tomme filer er ikke tillatt.", "com_error_files_process": "Det oppstod en feil under behandling av filen.", - "com_error_files_unsupported_capability": "Ingen kapabiliteter aktivert som støtter denne filtypen.", "com_error_files_upload": "Det oppstod en feil under opplasting av filen.", "com_error_files_upload_canceled": "Forespørselen om filopplasting ble avbrutt. Merk: Filopplastingen kan fortsatt behandles og må slettes manuelt.", "com_error_files_validation": "Det oppstod en feil under validering av filen.", diff --git a/client/src/locales/pt-BR/translation.json b/client/src/locales/pt-BR/translation.json index bdaccdd764..0e004d19e1 100644 --- a/client/src/locales/pt-BR/translation.json +++ b/client/src/locales/pt-BR/translation.json @@ -354,7 +354,6 @@ "com_error_files_dupe": "Foi detectado um arquivo duplicado.", "com_error_files_empty": "Pensamento", "com_error_files_process": "Ocorreu um erro ao processar o arquivo.", - "com_error_files_unsupported_capability": "Não existem capacidades ativadas que suportem este tipo de arquivo.", "com_error_files_upload": "Ocorreu um erro ao carregar o arquivo.", "com_error_files_upload_canceled": "O pedido de carregamento de arquivos foi cancelado. Nota: o carregamento de arquivo pode ainda estar a ser processado e terá de ser eliminado manualmente.", "com_error_files_validation": "Ocorreu um erro durante a validação do arquivo.", diff --git a/client/src/locales/pt-PT/translation.json b/client/src/locales/pt-PT/translation.json index a9b63cfa05..c71240e46c 100644 --- a/client/src/locales/pt-PT/translation.json +++ b/client/src/locales/pt-PT/translation.json @@ -333,7 +333,6 @@ "com_error_files_dupe": "Ficheiro duplicado detectado", "com_error_files_empty": "Ficheiros vazios não são permitidos.", "com_error_files_process": "Ocorreu um erro ao processar o ficheiro.", - "com_error_files_unsupported_capability": "Não existem funcionalidades ativas que suportem este tipo de ficheiro.", "com_error_files_upload": "Ocorreu um erro ao enviar o ficheiro.", "com_error_files_upload_canceled": "O enviar do ficheiro foi cancelado. Nota: O envio pode estar ainda a ser processado e poderá necessitar de ser apagado manualmente.", "com_error_files_validation": "Ocorreu um erro ao validar o ficheiro.", diff --git a/client/src/locales/ru/translation.json b/client/src/locales/ru/translation.json index f8a2a1878b..86f73dec4c 100644 --- a/client/src/locales/ru/translation.json +++ b/client/src/locales/ru/translation.json @@ -362,7 +362,6 @@ "com_error_files_dupe": "Обнаружен дублирующийся файл", "com_error_files_empty": "Пустые файлы не допускаются", "com_error_files_process": "Произошла ошибка при обработке файла", - "com_error_files_unsupported_capability": "Отсутствуют разрешения для работы с данным типом файлов", "com_error_files_upload": "При загрузке файла произошла ошибка", "com_error_files_upload_canceled": "Запрос на загрузку файла был отменен. Примечание: файл все еще может обрабатываться и потребуется удалить его вручную.", "com_error_files_validation": "Произошла ошибка при проверке файла", diff --git a/client/src/locales/th/translation.json b/client/src/locales/th/translation.json index bb7e411096..24b28e1ac6 100644 --- a/client/src/locales/th/translation.json +++ b/client/src/locales/th/translation.json @@ -275,7 +275,6 @@ "com_error_files_dupe": "ตรวจพบไฟล์ซ้ำ", "com_error_files_empty": "ไม่อนุญาตให้ใช้ไฟล์ว่างเปล่า", "com_error_files_process": "เกิดข้อผิดพลาดขณะประมวลผลไฟล์", - "com_error_files_unsupported_capability": "ไม่มีความสามารถที่เปิดใช้งานที่รองรับประเภทไฟล์นี้", "com_error_files_upload": "เกิดข้อผิดพลาดขณะอัปโหลดไฟล์", "com_error_files_upload_canceled": "คำขออัปโหลดไฟล์ถูกยกเลิกแล้ว หมายเหตุ: การอัปโหลดไฟล์อาจยังคงประมวลผลอยู่และจะต้องลบด้วยตนเอง", "com_error_files_validation": "เกิดข้อผิดพลาดขณะตรวจสอบไฟล์", diff --git a/client/src/locales/tr/translation.json b/client/src/locales/tr/translation.json index 932ebffd4e..6e1fb5653a 100644 --- a/client/src/locales/tr/translation.json +++ b/client/src/locales/tr/translation.json @@ -243,7 +243,6 @@ "com_error_files_dupe": "Yinelenen dosya tespit edildi.", "com_error_files_empty": "Boş dosyalara izin verilmez.", "com_error_files_process": "Dosya işlenirken bir hata oluştu.", - "com_error_files_unsupported_capability": "Bu dosya türünü destekleyen hiçbir yetenek etkin değil.", "com_error_files_upload": "Dosya yüklenirken bir hata oluştu.", "com_error_files_upload_canceled": "Dosya yükleme isteği iptal edildi. Not: dosya yüklemesi hala işleniyor olabilir ve manuel olarak silinmesi gerekecektir.", "com_error_files_validation": "Dosya doğrulanırken bir hata oluştu.", diff --git a/client/src/locales/uk/translation.json b/client/src/locales/uk/translation.json index 8e50c1e162..6f9a95a6f4 100644 --- a/client/src/locales/uk/translation.json +++ b/client/src/locales/uk/translation.json @@ -360,7 +360,6 @@ "com_error_files_dupe": "Виявлено дубльований файл", "com_error_files_empty": "Порожні файли не дозволяються", "com_error_files_process": "Сталася помилка під час обробки файлу", - "com_error_files_unsupported_capability": "Відсутні дозволи для роботи з даним типом файлів", "com_error_files_upload": "Під час завантаження файлу сталася помилка", "com_error_files_upload_canceled": "Запит на завантаження файлу було скасовано. Примітка: файл все ще може оброблятися, і його доведеться видалити вручну.", "com_error_files_validation": "Сталася помилка під час перевірки файлу", diff --git a/client/src/locales/zh-Hans/translation.json b/client/src/locales/zh-Hans/translation.json index beba21af65..0385cb6ea1 100644 --- a/client/src/locales/zh-Hans/translation.json +++ b/client/src/locales/zh-Hans/translation.json @@ -363,7 +363,6 @@ "com_error_files_dupe": "检测到重复文件", "com_error_files_empty": "不允许上传空文件", "com_error_files_process": "处理文件时发生错误", - "com_error_files_unsupported_capability": "未启用支持此类文件的功能", "com_error_files_upload": "上传文件时发生错误", "com_error_files_upload_canceled": "文件上传请求已取消。注意:文件上传可能仍在进行中,需要手动删除。", "com_error_files_validation": "验证文件时出错。", diff --git a/client/src/locales/zh-Hant/translation.json b/client/src/locales/zh-Hant/translation.json index 5aa57ea7d8..995f88a313 100644 --- a/client/src/locales/zh-Hant/translation.json +++ b/client/src/locales/zh-Hant/translation.json @@ -308,7 +308,6 @@ "com_error_files_dupe": "偵測到重複的檔案。", "com_error_files_empty": "不允許空白檔案。", "com_error_files_process": "處理檔案時發生錯誤。", - "com_error_files_unsupported_capability": "未啟用支援此檔案類型的功能。", "com_error_files_upload": "上傳檔案時發生錯誤", "com_error_files_upload_canceled": "檔案上傳請求已取消。注意:檔案上傳可能仍在處理中,需要手動刪除。", "com_error_files_validation": "驗證檔案時發生錯誤。", From e9b678dd6a57630e9613a0d42345c0a503da8911 Mon Sep 17 00:00:00 2001 From: MarcAmick <5194465+MarcAmick@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:47:21 -0400 Subject: [PATCH 017/272] =?UTF-8?q?=E2=9A=96=EF=B8=8F=20fix:=20Add=20Confi?= =?UTF-8?q?gurable=20File=20Size=20Cap=20for=20Conversation=20Imports=20(#?= =?UTF-8?q?10012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Check file size of conversation being imported against a configured max size to prevent bringing down the application by uploading a large file chore: remove non-english localization as needs to be added via locize * feat: Implement file size validation for conversation imports to prevent oversized uploads --------- Co-authored-by: Marc Amick Co-authored-by: Danny Avila --- .env.example | 6 ++++++ api/server/routes/config.js | 3 +++ api/server/utils/import/importConversations.js | 10 ++++++++++ .../SettingsTabs/Data/ImportConversations.tsx | 18 +++++++++++++++++- client/src/locales/en/translation.json | 1 + packages/data-provider/src/config.ts | 1 + 6 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index d4007651fb..dadcc140ae 100644 --- a/.env.example +++ b/.env.example @@ -650,6 +650,12 @@ HELP_AND_FAQ_URL=https://librechat.ai # Google tag manager id #ANALYTICS_GTM_ID=user provided google tag manager id +# limit conversation file imports to a certain number of bytes in size to avoid the container +# maxing out memory limitations by unremarking this line and supplying a file size in bytes +# such as the below example of 250 mib +# CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000 + + #===============# # REDIS Options # #===============# diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 8674769643..bae5f764b0 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -115,6 +115,9 @@ router.get('/', async function (req, res) { sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE, sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE, openidReuseTokens, + conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES + ? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10) + : 0, }; const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10); diff --git a/api/server/utils/import/importConversations.js b/api/server/utils/import/importConversations.js index 4d2bc4c333..d9e4d4332d 100644 --- a/api/server/utils/import/importConversations.js +++ b/api/server/utils/import/importConversations.js @@ -10,6 +10,15 @@ const importConversations = async (job) => { const { filepath, requestUserId } = job; try { logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`); + + /* error if file is too large */ + const fileInfo = await fs.stat(filepath); + if (fileInfo.size > process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES) { + throw new Error( + `File size is ${fileInfo.size} bytes. It exceeds the maximum limit of ${process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES} bytes.`, + ); + } + const fileData = await fs.readFile(filepath, 'utf8'); const jsonData = JSON.parse(fileData); const importer = getImporter(jsonData); @@ -17,6 +26,7 @@ const importConversations = async (job) => { logger.debug(`user: ${requestUserId} | Finished importing conversations`); } catch (error) { logger.error(`user: ${requestUserId} | Failed to import conversation: `, error); + throw error; // throw error all the way up so request does not return success } finally { try { await fs.unlink(filepath); diff --git a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx index f3c6f4e8cb..2d06b74392 100644 --- a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx @@ -1,5 +1,7 @@ import { useState, useRef, useCallback } from 'react'; import { Import } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { QueryKeys, TStartupConfig } from 'librechat-data-provider'; import { Spinner, useToastContext, Label, Button } from '@librechat/client'; import { useUploadConversationsMutation } from '~/data-provider'; import { NotificationSeverity } from '~/common'; @@ -7,6 +9,8 @@ import { useLocalize } from '~/hooks'; import { cn, logger } from '~/utils'; function ImportConversations() { + const queryClient = useQueryClient(); + const startupConfig = queryClient.getQueryData([QueryKeys.startupConfig]); const localize = useLocalize(); const fileInputRef = useRef(null); const { showToast } = useToastContext(); @@ -49,6 +53,17 @@ function ImportConversations() { const handleFileUpload = useCallback( async (file: File) => { try { + const maxFileSize = (startupConfig as any)?.conversationImportMaxFileSize; + if (maxFileSize && file.size > maxFileSize) { + const size = (maxFileSize / (1024 * 1024)).toFixed(2); + showToast({ + message: localize('com_error_files_upload_too_large', { 0: size }), + status: NotificationSeverity.ERROR, + }); + setIsUploading(false); + return; + } + const formData = new FormData(); formData.append('file', file, encodeURIComponent(file.name || 'File')); uploadFile.mutate(formData); @@ -61,13 +76,14 @@ function ImportConversations() { }); } }, - [uploadFile, showToast, localize], + [uploadFile, showToast, localize, startupConfig], ); const handleFileChange = useCallback( (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { + setIsUploading(true); handleFileUpload(file); } event.target.value = ''; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 2fc5623f4d..f3d575e1de 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -365,6 +365,7 @@ "com_error_files_process": "An error occurred while processing the file.", "com_error_files_upload": "An error occurred while uploading the file.", "com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.", + "com_error_files_upload_too_large": "The file is too large. Please upload a file smaller than {{0}} MB", "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.", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index d212f559fe..2b78453be4 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -669,6 +669,7 @@ export type TStartupConfig = { } >; mcpPlaceholder?: string; + conversationImportMaxFileSize?: number; }; export enum OCRStrategy { From ff027e8243adf5708018f8b3a454b2d7221fb9df Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 9 Oct 2025 00:31:04 -0700 Subject: [PATCH 018/272] =?UTF-8?q?=F0=9F=91=A8=E2=80=8D=F0=9F=94=A7=20fix?= =?UTF-8?q?:=20Direct=20Provider=20Attachment=20Support=20for=20Agents=20(?= =?UTF-8?q?#10035)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: show direct upload option on applicable agents * fix: allow agent file upload handler to process direct upload files (no tool_resource) --- api/server/services/Files/process.js | 6 ++++-- client/src/components/Chat/Input/Files/AttachFileMenu.tsx | 2 +- client/src/hooks/Agents/useAgentToolPermissions.ts | 5 ++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index f7220715f6..5e945f0e36 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -508,7 +508,10 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { const { file } = req; const appConfig = req.config; const { agent_id, tool_resource, file_id, temp_file_id = null } = metadata; - if (agent_id && !tool_resource) { + + let messageAttachment = !!metadata.message_file; + + if (agent_id && !tool_resource && !messageAttachment) { throw new Error('No tool resource provided for agent file upload'); } @@ -516,7 +519,6 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { throw new Error('Image uploads are not supported for file search tool resources'); } - let messageAttachment = !!metadata.message_file; if (!messageAttachment && !agent_id) { throw new Error('No agent ID provided for agent file upload'); } diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index fa56cdf08b..a3e5a8d304 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -118,7 +118,7 @@ const AttachFileMenu = ({ const currentProvider = provider || endpoint; - if (isDocumentSupportedProvider(endpointType || currentProvider)) { + if (isDocumentSupportedProvider(currentProvider || endpointType)) { items.push({ label: localize('com_ui_upload_provider'), onClick: () => { diff --git a/client/src/hooks/Agents/useAgentToolPermissions.ts b/client/src/hooks/Agents/useAgentToolPermissions.ts index eea549d7a6..cff9e9635b 100644 --- a/client/src/hooks/Agents/useAgentToolPermissions.ts +++ b/client/src/hooks/Agents/useAgentToolPermissions.ts @@ -37,7 +37,10 @@ export default function useAgentToolPermissions( [agentData?.tools, selectedAgent?.tools], ); - const provider = useMemo(() => selectedAgent?.provider, [selectedAgent?.provider]); + const provider = useMemo( + () => agentData?.provider || selectedAgent?.provider, + [agentData?.provider, selectedAgent?.provider], + ); const fileSearchAllowedByAgent = useMemo(() => { // Check ephemeral agent settings From 6fa3db2969cf8a487798465d198c4da1e083fc07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pedro=20Silva?= Date: Thu, 9 Oct 2025 08:35:22 +0100 Subject: [PATCH 019/272] =?UTF-8?q?=F0=9F=91=91=20feat:=20Add=20OIDC=20Cla?= =?UTF-8?q?im-Based=20Admin=20Role=20Assignment=20(#9170)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add support for users to be admins when logging in using OpenID * fix: Linting issues * fix: whitespace * chore: add unit tests for OIDC_ADMIN_ROLE * refactor: Replace custom property retrieval function with lodash's get for improved readability and maintainability * feat: Enhance OpenID role extraction and error handling in setupOpenId function - Improved role validation to check for both array and string types. - Added detailed error messages for missing or invalid role paths in tokens. - Expanded unit tests to cover various scenarios for nested role extraction and error handling. * fix: Improve error handling for role extraction in OpenID strategy - Enhanced validation to check for invalid role types (array or string). - Updated error messages for clarity when roles are missing or of incorrect type. - Added unit tests to cover scenarios where roles return invalid types (object, number). * feat: Implement user role demotion in OpenID strategy when admin role is absent from token - Added logic to demote users from 'ADMIN' to 'USER' if the admin role is not present in the token. - Enhanced logging to capture role changes for better traceability. - Introduced unit tests to verify the demotion behavior and ensure correct handling when admin role environment variables are not configured. --------- Co-authored-by: Danny Avila --- .env.example | 3 + api/strategies/openidStrategy.js | 72 +++- api/strategies/openidStrategy.spec.js | 475 ++++++++++++++++++++++++++ 3 files changed, 539 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index dadcc140ae..78208307ff 100644 --- a/.env.example +++ b/.env.example @@ -459,6 +459,9 @@ OPENID_CALLBACK_URL=/oauth/openid/callback OPENID_REQUIRED_ROLE= OPENID_REQUIRED_ROLE_TOKEN_KIND= OPENID_REQUIRED_ROLE_PARAMETER_PATH= +OPENID_ADMIN_ROLE= +OPENID_ADMIN_ROLE_PARAMETER_PATH= +OPENID_ADMIN_ROLE_TOKEN_KIND= # Set to determine which user info property returned from OpenID Provider to store as the User's username OPENID_USERNAME_CLAIM= # Set to determine which user info property returned from OpenID Provider to store as the User's name diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index ce564fc655..079bed9e10 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -1,4 +1,5 @@ const undici = require('undici'); +const { get } = require('lodash'); const fetch = require('node-fetch'); const passport = require('passport'); const client = require('openid-client'); @@ -329,6 +330,12 @@ async function setupOpenId() { : 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata', }); + // Set of env variables that specify how to set if a user is an admin + // If not set, all users will be treated as regular users + const adminRole = process.env.OPENID_ADMIN_ROLE; + const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; + const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + const openidLogin = new CustomOpenIDStrategy( { config: openidConfig, @@ -386,20 +393,19 @@ async function setupOpenId() { } else if (requiredRoleTokenKind === 'id') { decodedToken = jwtDecode(tokenset.id_token); } - const pathParts = requiredRoleParameterPath.split('.'); - let found = true; - let roles = pathParts.reduce((o, key) => { - if (o === null || o === undefined || !(key in o)) { - found = false; - return []; - } - return o[key]; - }, decodedToken); - if (!found) { + let roles = get(decodedToken, requiredRoleParameterPath); + if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) { logger.error( - `[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`, + `[openidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`, ); + const rolesList = + requiredRoles.length === 1 + ? `"${requiredRoles[0]}"` + : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; + return done(null, false, { + message: `You must have ${rolesList} role to log in.`, + }); } if (!requiredRoles.some((role) => roles.includes(role))) { @@ -447,6 +453,50 @@ async function setupOpenId() { } } + if (adminRole && adminRoleParameterPath && adminRoleTokenKind) { + let adminRoleObject; + switch (adminRoleTokenKind) { + case 'access': + adminRoleObject = jwtDecode(tokenset.access_token); + break; + case 'id': + adminRoleObject = jwtDecode(tokenset.id_token); + break; + case 'userinfo': + adminRoleObject = userinfo; + break; + default: + logger.error( + `[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`, + ); + return done(new Error('Invalid admin role token kind')); + } + + const adminRoles = get(adminRoleObject, adminRoleParameterPath); + + // Accept 3 types of values for the object extracted from adminRoleParameterPath: + // 1. A boolean value indicating if the user is an admin + // 2. A string with a single role name + // 3. An array of role names + + if ( + adminRoles && + (adminRoles === true || + adminRoles === adminRole || + (Array.isArray(adminRoles) && adminRoles.includes(adminRole))) + ) { + user.role = 'ADMIN'; + logger.info( + `[openidStrategy] User ${username} is an admin based on role: ${adminRole}`, + ); + } else if (user.role === 'ADMIN') { + user.role = 'USER'; + logger.info( + `[openidStrategy] User ${username} demoted from admin - role no longer present in token`, + ); + } + } + if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { /** @type {string | undefined} */ const imageUrl = userinfo.picture; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index e668e078de..fa6af7f40f 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -125,6 +125,9 @@ describe('setupOpenId', () => { process.env.OPENID_REQUIRED_ROLE = 'requiredRole'; process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'permissions'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; delete process.env.OPENID_USERNAME_CLAIM; delete process.env.OPENID_NAME_CLAIM; delete process.env.PROXY; @@ -133,6 +136,7 @@ describe('setupOpenId', () => { // Default jwtDecode mock returns a token that includes the required role. jwtDecode.mockReturnValue({ roles: ['requiredRole'], + permissions: ['admin'], }); // By default, assume that no user is found, so createUser will be called @@ -441,4 +445,475 @@ describe('setupOpenId', () => { expect(callOptions.usePKCE).toBe(false); expect(callOptions.params?.code_challenge_method).toBeUndefined(); }); + + it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => { + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the user role is set to "ADMIN" + expect(user.role).toBe('ADMIN'); + }); + + it('should not set user role if OPENID_ADMIN_ROLE is set but the user does not have that role', async () => { + // Arrange – simulate a token without the admin permission + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + permissions: ['not-admin'], + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the user role is not defined + expect(user.role).toBeUndefined(); + }); + + it('should demote existing admin user when admin role is removed from token', async () => { + // Arrange – simulate an existing user who is currently an admin + const existingAdminUser = { + _id: 'existingAdminId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'adminuser', + name: 'Admin User', + role: 'ADMIN', + }; + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingAdminUser; + } + return null; + }); + + // Token without admin permission + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + permissions: ['not-admin'], + }); + + const { logger } = require('@librechat/data-schemas'); + + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the user was demoted + expect(user.role).toBe('USER'); + expect(updateUser).toHaveBeenCalledWith( + existingAdminUser._id, + expect.objectContaining({ + role: 'USER', + }), + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('demoted from admin - role no longer present in token'), + ); + }); + + it('should NOT demote admin user when admin role env vars are not configured', async () => { + // Arrange – remove admin role env vars + delete process.env.OPENID_ADMIN_ROLE; + delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; + delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + // Simulate an existing admin user + const existingAdminUser = { + _id: 'existingAdminId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'adminuser', + name: 'Admin User', + role: 'ADMIN', + }; + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingAdminUser; + } + return null; + }); + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the admin user was NOT demoted + expect(user.role).toBe('ADMIN'); + expect(updateUser).toHaveBeenCalledWith( + existingAdminUser._id, + expect.objectContaining({ + role: 'ADMIN', + }), + ); + }); + + describe('lodash get - nested path extraction', () => { + it('should extract roles from deeply nested token path', async () => { + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-client.roles'; + + jwtDecode.mockReturnValue({ + resource_access: { + 'my-client': { + roles: ['app-user', 'viewer'], + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + expect(user.email).toBe(tokenset.claims().email); + }); + + it('should extract roles from three-level nested path', async () => { + process.env.OPENID_REQUIRED_ROLE = 'editor'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.access.permissions.roles'; + + jwtDecode.mockReturnValue({ + data: { + access: { + permissions: { + roles: ['editor', 'reader'], + }, + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + }); + + it('should log error and reject login when required role path does not exist in token', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.nonexistent.roles'; + + jwtDecode.mockReturnValue({ + resource_access: { + 'my-client': { + roles: ['app-user'], + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user, details } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + "Key 'resource_access.nonexistent.roles' not found or invalid type in id token!", + ), + ); + expect(user).toBe(false); + expect(details.message).toContain('role to log in'); + }); + + it('should handle missing intermediate nested path gracefully', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'org.team.roles'; + + jwtDecode.mockReturnValue({ + org: { + other: 'value', + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'org.team.roles' not found or invalid type in id token!"), + ); + expect(user).toBe(false); + }); + + it('should extract admin role from nested path in access token', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'realm_access.roles'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access'; + + jwtDecode.mockImplementation((token) => { + if (token === 'fake_access_token') { + return { + realm_access: { + roles: ['admin', 'user'], + }, + }; + } + return { + roles: ['requiredRole'], + }; + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should extract admin role from nested path in userinfo', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'organization.permissions'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'userinfo'; + + const userinfoWithNestedGroups = { + ...tokenset.claims(), + organization: { + permissions: ['admin', 'write'], + }, + }; + + require('openid-client').fetchUserInfo.mockResolvedValue({ + organization: { + permissions: ['admin', 'write'], + }, + }); + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate({ + ...tokenset, + claims: () => userinfoWithNestedGroups, + }); + + expect(user.role).toBe('ADMIN'); + }); + + it('should handle boolean admin role value', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'is_admin'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + is_admin: true, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should handle string admin role value matching exactly', async () => { + process.env.OPENID_ADMIN_ROLE = 'super-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + role: 'super-admin', + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should not set admin role when string value does not match', async () => { + process.env.OPENID_ADMIN_ROLE = 'super-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + role: 'regular-user', + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBeUndefined(); + }); + + it('should handle array admin role value', async () => { + process.env.OPENID_ADMIN_ROLE = 'site-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + app_roles: ['user', 'site-admin', 'moderator'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should not set admin when role is not in array', async () => { + process.env.OPENID_ADMIN_ROLE = 'site-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + app_roles: ['user', 'moderator'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBeUndefined(); + }); + + it('should handle nested path with special characters in keys', async () => { + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-app-123.roles'; + + jwtDecode.mockReturnValue({ + resource_access: { + 'my-app-123': { + roles: ['app-user'], + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + }); + + it('should handle empty object at nested path', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'access.roles'; + + jwtDecode.mockReturnValue({ + access: {}, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'access.roles' not found or invalid type in id token!"), + ); + expect(user).toBe(false); + }); + + it('should handle null value at intermediate path', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.roles'; + + jwtDecode.mockReturnValue({ + data: null, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'data.roles' not found or invalid type in id token!"), + ); + expect(user).toBe(false); + }); + + it('should reject login with invalid admin role token kind', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'roles'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'invalid'; + + const { logger } = require('@librechat/data-schemas'); + + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'admin'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + await expect(validate(tokenset)).rejects.toThrow('Invalid admin role token kind'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid admin role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'", + ), + ); + }); + + it('should reject login when roles path returns invalid type (object)', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; + + jwtDecode.mockReturnValue({ + roles: { admin: true, user: false }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user, details } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'roles' not found or invalid type in id token!"), + ); + expect(user).toBe(false); + expect(details.message).toContain('role to log in'); + }); + + it('should reject login when roles path returns invalid type (number)', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roleCount'; + + jwtDecode.mockReturnValue({ + roleCount: 5, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'roleCount' not found or invalid type in id token!"), + ); + expect(user).toBe(false); + }); + }); }); From 20282f32c8c2ab476b9abfaaf60bc899ab041a4b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 9 Oct 2025 03:45:10 -0400 Subject: [PATCH 020/272] =?UTF-8?q?=F0=9F=93=A6=20chore:=20Bump=20`nodemai?= =?UTF-8?q?ler`=20to=20v7.0.9=20(#10045)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: add build script for client package to streamline builds * chore: update nodemailer dependency to version 7.0.9 --- api/package.json | 2 +- package-lock.json | 19 ++++++++++--------- package.json | 1 + 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/api/package.json b/api/package.json index 73cb0633e9..f0b654b1af 100644 --- a/api/package.json +++ b/api/package.json @@ -93,7 +93,7 @@ "multer": "^2.0.2", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", - "nodemailer": "^6.9.15", + "nodemailer": "^7.0.9", "ollama": "^0.5.0", "openai": "^5.10.1", "openid-client": "^6.5.0", diff --git a/package-lock.json b/package-lock.json index 72bc3a7af6..1a789de54d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,7 +109,7 @@ "multer": "^2.0.2", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", - "nodemailer": "^6.9.15", + "nodemailer": "^7.0.9", "ollama": "^0.5.0", "openai": "^5.10.1", "openid-client": "^6.5.0", @@ -2491,6 +2491,15 @@ "node": ">= 0.6" } }, + "api/node_modules/nodemailer": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "api/node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -41973,14 +41982,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nodemailer": { - "version": "6.9.15", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz", - "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/nodemon": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.3.tgz", diff --git a/package.json b/package.json index 458f4d2985..4a69267be2 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "build:data-provider": "cd packages/data-provider && npm run build", "build:api": "cd packages/api && npm run build", "build:data-schemas": "cd packages/data-schemas && npm run build", + "build:client": "cd client && npm run build", "build:client-package": "cd packages/client && npm run build", "build:packages": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && npm run build:client-package", "frontend": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && npm run build:client-package && cd client && npm run build", From fbe341a171c1550aca58cf266eb127573dacfe8e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 10 Oct 2025 11:22:16 +0300 Subject: [PATCH 021/272] =?UTF-8?q?=E2=9A=A1=20refactor:=20Latest=20Messag?= =?UTF-8?q?e=20Tracking=20with=20Robust=20Text=20Key=20Generation=20(#1005?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: enhance logging for latest message actions in message components * fix: Extract previous convoId from latest text in message helpers and process hooks - Updated `useMessageHelpers` and `useMessageProcess` to extract `convoId` from the previous text key for improved message handling. - Refactored `getLengthAndLastTenChars` to `getLengthAndLastNChars` for better flexibility in character length retrieval. - Introduced `getLatestContentForKey` function to streamline content extraction from messages. * chore: Enhance logging for clearing latest messages in conversation hooks * refactor: Update message key formatting for improved URL parameter handling - Modified `getLatestContentForKey` to change the format from `${text}-${i}` to `${text}&i=${i}` for better URL parameter structure. - Adjusted `getTextKey` to increase character length retrieval from 12 to 16 in `getLengthAndLastNChars` for enhanced text processing. * refactor: Simplify convoId extraction and enhance message formatting - Updated `useMessageHelpers` and `useMessageProcess` to extract `convoId` using a new format for improved clarity. - Refactored `getLatestContentForKey` to streamline content formatting and ensure consistent use of `Constants.COMMON_DIVIDER` for better message structure. - Removed redundant length and last character extraction logic from `getLengthAndLastNChars` for cleaner code. * chore: linting * chore: Simplify pre-commit hook by removing unnecessary lines --- .husky/pre-commit | 3 - .../Chat/Messages/ui/MessageRender.tsx | 5 +- .../src/components/Messages/ContentRender.tsx | 5 +- .../Conversations/useNavigateToConvo.tsx | 1 + .../src/hooks/Messages/useMessageHelpers.tsx | 20 +++- .../src/hooks/Messages/useMessageProcess.tsx | 18 +++- client/src/hooks/SSE/useEventHandlers.ts | 2 + client/src/hooks/useNewConvo.ts | 1 + client/src/utils/messages.ts | 92 +++++++++++++++---- 9 files changed, 118 insertions(+), 29 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 67f5b00272..23c736d1de 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,2 @@ -#!/usr/bin/env sh -set -e -. "$(dirname -- "$0")/_/husky.sh" [ -n "$CI" ] && exit 0 npx lint-staged --config ./.husky/lint-staged.config.js diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index ad38f1ee40..f056fccc98 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -97,7 +97,10 @@ const MessageRender = memo( () => showCardRender && !isLatestMessage ? () => { - logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`); + logger.log( + 'latest_message', + `Message Card click: Setting ${msg?.messageId} as latest message`, + ); logger.dir(msg); setLatestMessage(msg!); } diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index bdf051453e..ce88687d23 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -96,7 +96,10 @@ const ContentRender = memo( () => showCardRender && !isLatestMessage ? () => { - logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`); + logger.log( + 'latest_message', + `Message Card click: Setting ${msg?.messageId} as latest message`, + ); logger.dir(msg); setLatestMessage(msg!); } diff --git a/client/src/hooks/Conversations/useNavigateToConvo.tsx b/client/src/hooks/Conversations/useNavigateToConvo.tsx index dd8ce11aa5..55f43fa820 100644 --- a/client/src/hooks/Conversations/useNavigateToConvo.tsx +++ b/client/src/hooks/Conversations/useNavigateToConvo.tsx @@ -51,6 +51,7 @@ const useNavigateToConvo = (index = 0) => { hasSetConversation.current = true; setSubmission(null); if (resetLatestMessage) { + logger.log('latest_message', 'Clearing all latest messages'); clearAllLatestMessages(); } diff --git a/client/src/hooks/Messages/useMessageHelpers.tsx b/client/src/hooks/Messages/useMessageHelpers.tsx index 264fe666d6..8343e97756 100644 --- a/client/src/hooks/Messages/useMessageHelpers.tsx +++ b/client/src/hooks/Messages/useMessageHelpers.tsx @@ -3,8 +3,8 @@ import { useEffect, useRef, useCallback, useMemo } from 'react'; import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; import type { TMessageProps } from '~/common'; import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; +import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils'; import useCopyToClipboard from './useCopyToClipboard'; -import { getTextKey, logger } from '~/utils'; export default function useMessageHelpers(props: TMessageProps) { const latestText = useRef(''); @@ -49,15 +49,27 @@ export default function useMessageHelpers(props: TMessageProps) { messageId: message.messageId, convoId, }; + + /* Extracted convoId from previous textKey (format: messageId|||length|||lastChars|||convoId) */ + let previousConvoId: string | null = null; + if ( + latestText.current && + typeof latestText.current === 'string' && + latestText.current.length > 0 + ) { + const parts = latestText.current.split(TEXT_KEY_DIVIDER); + previousConvoId = parts[parts.length - 1] || null; + } + if ( textKey !== latestText.current || - (latestText.current && convoId !== latestText.current.split(Constants.COMMON_DIVIDER)[2]) + (convoId != null && previousConvoId != null && convoId !== previousConvoId) ) { - logger.log('[useMessageHelpers] Setting latest message: ', logInfo); + logger.log('latest_message', '[useMessageHelpers] Setting latest message: ', logInfo); latestText.current = textKey; setLatestMessage({ ...message }); } else { - logger.log('No change in latest message', logInfo); + logger.log('latest_message', 'No change in latest message', logInfo); } }, [isLast, message, setLatestMessage, conversation?.conversationId]); diff --git a/client/src/hooks/Messages/useMessageProcess.tsx b/client/src/hooks/Messages/useMessageProcess.tsx index ea5779a69f..30bec90d17 100644 --- a/client/src/hooks/Messages/useMessageProcess.tsx +++ b/client/src/hooks/Messages/useMessageProcess.tsx @@ -3,8 +3,8 @@ import { useRecoilValue } from 'recoil'; import { Constants } from 'librechat-data-provider'; import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import type { TMessage } from 'librechat-data-provider'; +import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils'; import { useMessagesViewContext } from '~/Providers'; -import { getTextKey, logger } from '~/utils'; import store from '~/store'; export default function useMessageProcess({ message }: { message?: TMessage | null }) { @@ -43,11 +43,21 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu messageId: message.messageId, convoId, }; + + /* Extracted convoId from previous textKey (format: messageId|||length|||lastChars|||convoId) */ + let previousConvoId: string | null = null; + if ( + latestText.current && + typeof latestText.current === 'string' && + latestText.current.length > 0 + ) { + const parts = latestText.current.split(TEXT_KEY_DIVIDER); + previousConvoId = parts[parts.length - 1] || null; + } + if ( textKey !== latestText.current || - (convoId != null && - latestText.current && - convoId !== latestText.current.split(Constants.COMMON_DIVIDER)[2]) + (convoId != null && previousConvoId != null && convoId !== previousConvoId) ) { logger.log('latest_message', '[useMessageProcess] Setting latest message; logInfo:', logInfo); latestText.current = textKey; diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 1d860fbb7a..83c1ff1ad9 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -339,6 +339,7 @@ export default function useEventHandlers({ setShowStopButton(true); if (resetLatestMessage) { + logger.log('latest_message', 'syncHandler: resetting latest message'); resetLatestMessage(); } }, @@ -418,6 +419,7 @@ export default function useEventHandlers({ } if (resetLatestMessage) { + logger.log('latest_message', 'createdHandler: resetting latest message'); resetLatestMessage(); } scrollToEnd(() => setAbortScroll(false)); diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index ab2d177428..22ea5f327c 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -179,6 +179,7 @@ const useNewConvo = (index = 0) => { } setSubmission({} as TSubmission); if (!(keepLatestMessage ?? false)) { + logger.log('latest_message', 'Clearing all latest messages'); clearAllLatestMessages(); } if (isCancelled) { diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index caae46d923..fe8ec36499 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -1,15 +1,7 @@ -import { ContentTypes, Constants } from 'librechat-data-provider'; +import { ContentTypes } from 'librechat-data-provider'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; -export const getLengthAndLastTenChars = (str?: string): string => { - if (typeof str !== 'string' || str.length === 0) { - return '0'; - } - - const length = str.length; - const lastTenChars = str.slice(-10); - return `${length}${lastTenChars}`; -}; +export const TEXT_KEY_DIVIDER = '|||'; export const getLatestText = (message?: TMessage | null, includeIndex?: boolean): string => { if (!message) { @@ -65,16 +57,84 @@ export const getAllContentText = (message?: TMessage | null): string => { return ''; }; +const getLatestContentForKey = (message: TMessage): string => { + const formatText = (str: string, index: number): string => { + if (str.length === 0) { + return '0'; + } + const length = str.length; + const lastChars = str.slice(-16); + return `${length}${TEXT_KEY_DIVIDER}${lastChars}${TEXT_KEY_DIVIDER}${index}`; + }; + + if (message.text) { + return formatText(message.text, -1); + } + + if (!message.content || message.content.length === 0) { + return ''; + } + + for (let i = message.content.length - 1; i >= 0; i--) { + const part = message.content[i] as TMessageContentParts | undefined; + if (!part?.type) { + continue; + } + + const type = part.type; + let text = ''; + + // Handle THINK type - extract think content + if (type === ContentTypes.THINK && 'think' in part) { + text = typeof part.think === 'string' ? part.think : (part.think?.value ?? ''); + } + // Handle TEXT type + else if (type === ContentTypes.TEXT && 'text' in part) { + text = typeof part.text === 'string' ? part.text : (part.text?.value ?? ''); + } + // Handle ERROR type + else if (type === ContentTypes.ERROR && 'error' in part) { + text = String(part.error || 'err').slice(0, 30); + } + // Handle TOOL_CALL - use simple marker with type + else if (type === ContentTypes.TOOL_CALL && 'tool_call' in part) { + const tcType = part.tool_call?.type || 'x'; + const tcName = String(part.tool_call?.['name'] || 'unknown').slice(0, 20); + const tcArgs = String(part.tool_call?.['args'] || 'none').slice(0, 20); + const tcOutput = String(part.tool_call?.['output'] || 'none').slice(0, 20); + text = `tc_${tcType}_${tcName}_${tcArgs}_${tcOutput}`; + } + // Handle IMAGE_FILE - use simple marker with file_id suffix + else if (type === ContentTypes.IMAGE_FILE && 'image_file' in part) { + const fileId = part.image_file?.file_id || 'x'; + text = `if_${fileId.slice(-8)}`; + } + // Handle IMAGE_URL - use simple marker + else if (type === ContentTypes.IMAGE_URL) { + text = 'iu'; + } + // Handle AGENT_UPDATE - use simple marker with agentId suffix + else if (type === ContentTypes.AGENT_UPDATE && 'agent_update' in part) { + const agentId = String(part.agent_update?.agentId || 'x').slice(0, 30); + text = `au_${agentId}`; + } else { + text = type; + } + + if (text.length > 0) { + return formatText(text, i); + } + } + + return ''; +}; + export const getTextKey = (message?: TMessage | null, convoId?: string | null) => { if (!message) { return ''; } - const text = getLatestText(message, true); - return `${(message.messageId as string | null) ?? ''}${ - Constants.COMMON_DIVIDER - }${getLengthAndLastTenChars(text)}${Constants.COMMON_DIVIDER}${ - message.conversationId ?? convoId - }`; + const contentKey = getLatestContentForKey(message); + return `${(message.messageId as string | null) ?? ''}${TEXT_KEY_DIVIDER}${contentKey}${TEXT_KEY_DIVIDER}${message.conversationId ?? convoId}`; }; export const scrollToEnd = (callback?: () => void) => { From 07d0abc9fdc455d419adcc31d5c8dca10cf1bcd8 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 10 Oct 2025 12:35:37 +0300 Subject: [PATCH 022/272] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20fix:=20Extract?= =?UTF-8?q?=20File=20Context=20&=20Persist=20Attachments=20(#10069)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - problem: `addImageUrls` had a side effect that was being leveraged before to populate both the `ocr` message field, now `fileContext`, and `client.options.attachments`, which would record the user's uploaded message attachments to the user message when saved to the database and returned at the end of the request lifecycle - solution: created dedicated handling for file context, and made sure to populate `allFiles` with non-provider attachments --- api/app/clients/BaseClient.js | 46 ++++++++++++++- api/server/controllers/agents/client.js | 30 +++++----- api/server/services/Files/images/encode.js | 33 +---------- packages/api/src/files/context.ts | 68 ++++++++++++++++++++++ packages/api/src/files/index.ts | 1 + 5 files changed, 128 insertions(+), 50 deletions(-) create mode 100644 packages/api/src/files/context.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 32c76523f7..5c6561396e 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -3,6 +3,7 @@ const fetch = require('node-fetch'); const { logger } = require('@librechat/data-schemas'); const { getBalanceConfig, + extractFileContext, encodeAndFormatAudios, encodeAndFormatVideos, encodeAndFormatDocuments, @@ -10,6 +11,7 @@ const { const { Constants, ErrorTypes, + FileSources, ContentTypes, excludedKeys, EModelEndpoint, @@ -21,6 +23,7 @@ const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { checkBalance } = require('~/models/balanceMethods'); const { truncateToolCallOutputs } = require('./prompts'); +const countTokens = require('~/server/utils/countTokens'); const { getFiles } = require('~/models/File'); const TextStream = require('./TextStream'); @@ -1245,27 +1248,62 @@ class BaseClient { return audioResult.files; } + /** + * Extracts text context from attachments and sets it on the message. + * This handles text that was already extracted from files (OCR, transcriptions, document text, etc.) + * @param {TMessage} message - The message to add context to + * @param {MongoFile[]} attachments - Array of file attachments + * @returns {Promise} + */ + async addFileContextToMessage(message, attachments) { + const fileContext = await extractFileContext({ + attachments, + req: this.options?.req, + tokenCountFn: (text) => countTokens(text), + }); + + if (fileContext) { + message.fileContext = fileContext; + } + } + async processAttachments(message, attachments) { const categorizedAttachments = { images: [], - documents: [], videos: [], audios: [], + documents: [], }; + const allFiles = []; + for (const file of attachments) { + /** @type {FileSources} */ + const source = file.source ?? FileSources.local; + if (source === FileSources.text) { + allFiles.push(file); + continue; + } + if (file.embedded === true || file.metadata?.fileIdentifier != null) { + allFiles.push(file); + continue; + } + if (file.type.startsWith('image/')) { categorizedAttachments.images.push(file); } else if (file.type === 'application/pdf') { categorizedAttachments.documents.push(file); + allFiles.push(file); } else if (file.type.startsWith('video/')) { categorizedAttachments.videos.push(file); + allFiles.push(file); } else if (file.type.startsWith('audio/')) { categorizedAttachments.audios.push(file); + allFiles.push(file); } } - const [imageFiles, documentFiles, videoFiles, audioFiles] = await Promise.all([ + const [imageFiles] = await Promise.all([ categorizedAttachments.images.length > 0 ? this.addImageURLs(message, categorizedAttachments.images) : Promise.resolve([]), @@ -1280,7 +1318,8 @@ class BaseClient { : Promise.resolve([]), ]); - const allFiles = [...imageFiles, ...documentFiles, ...videoFiles, ...audioFiles]; + allFiles.push(...imageFiles); + const seenFileIds = new Set(); const uniqueFiles = []; @@ -1345,6 +1384,7 @@ class BaseClient { {}, ); + await this.addFileContextToMessage(message, files); await this.processAttachments(message, files); this.message_file_map[message.messageId] = files; diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index a9f5543a61..a648488d14 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -211,16 +211,13 @@ class AgentClient extends BaseClient { * @returns {Promise>>} */ async addImageURLs(message, attachments) { - const { files, text, image_urls } = await encodeAndFormat( + const { files, image_urls } = await encodeAndFormat( this.options.req, attachments, this.options.agent.provider, VisionModes.agents, ); message.image_urls = image_urls.length ? image_urls : undefined; - if (text && text.length) { - message.ocr = text; - } return files; } @@ -248,19 +245,18 @@ class AgentClient extends BaseClient { if (this.options.attachments) { const attachments = await this.options.attachments; + const latestMessage = orderedMessages[orderedMessages.length - 1]; if (this.message_file_map) { - this.message_file_map[orderedMessages[orderedMessages.length - 1].messageId] = attachments; + this.message_file_map[latestMessage.messageId] = attachments; } else { this.message_file_map = { - [orderedMessages[orderedMessages.length - 1].messageId]: attachments, + [latestMessage.messageId]: attachments, }; } - const files = await this.processAttachments( - orderedMessages[orderedMessages.length - 1], - attachments, - ); + await this.addFileContextToMessage(latestMessage, attachments); + const files = await this.processAttachments(latestMessage, attachments); this.options.attachments = files; } @@ -280,21 +276,21 @@ class AgentClient extends BaseClient { assistantName: this.options?.modelLabel, }); - if (message.ocr && i !== orderedMessages.length - 1) { + if (message.fileContext && i !== orderedMessages.length - 1) { if (typeof formattedMessage.content === 'string') { - formattedMessage.content = message.ocr + '\n' + formattedMessage.content; + formattedMessage.content = message.fileContext + '\n' + formattedMessage.content; } else { const textPart = formattedMessage.content.find((part) => part.type === 'text'); textPart - ? (textPart.text = message.ocr + '\n' + textPart.text) - : formattedMessage.content.unshift({ type: 'text', text: message.ocr }); + ? (textPart.text = message.fileContext + '\n' + textPart.text) + : formattedMessage.content.unshift({ type: 'text', text: message.fileContext }); } - } else if (message.ocr && i === orderedMessages.length - 1) { - systemContent = [systemContent, message.ocr].join('\n'); + } else if (message.fileContext && i === orderedMessages.length - 1) { + systemContent = [systemContent, message.fileContext].join('\n'); } const needsTokenCount = - (this.contextStrategy && !orderedMessages[i].tokenCount) || message.ocr; + (this.contextStrategy && !orderedMessages[i].tokenCount) || message.fileContext; /* If tokens were never counted, or, is a Vision request and the message has files, count again */ if (needsTokenCount || (this.isVisionModel && (message.image_urls || message.files))) { diff --git a/api/server/services/Files/images/encode.js b/api/server/services/Files/images/encode.js index 34128e3152..7609ed388a 100644 --- a/api/server/services/Files/images/encode.js +++ b/api/server/services/Files/images/encode.js @@ -1,16 +1,14 @@ const axios = require('axios'); +const { logAxiosError } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); -const { logAxiosError, processTextWithTokenLimit } = require('@librechat/api'); const { FileSources, VisionModes, ImageDetail, ContentTypes, EModelEndpoint, - mergeFileConfig, } = require('librechat-data-provider'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); -const countTokens = require('~/server/utils/countTokens'); /** * Converts a readable stream to a base64 encoded string. @@ -88,15 +86,14 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]); * @param {Array} files - The array of files to encode and format. * @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image. * @param {string} [mode] - Optional: The endpoint mode for the image. - * @returns {Promise<{ text: string; files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details. + * @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details. */ async function encodeAndFormat(req, files, endpoint, mode) { const promises = []; /** @type {Record, 'prepareImagePayload' | 'getDownloadStream'>>} */ const encodingMethods = {}; - /** @type {{ text: string; files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */ + /** @type {{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */ const result = { - text: '', files: [], image_urls: [], }; @@ -105,29 +102,9 @@ async function encodeAndFormat(req, files, endpoint, mode) { return result; } - const fileTokenLimit = - req.body?.fileTokenLimit ?? mergeFileConfig(req.config?.fileConfig).fileTokenLimit; - for (let file of files) { /** @type {FileSources} */ const source = file.source ?? FileSources.local; - if (source === FileSources.text && file.text) { - let fileText = file.text; - - const { text: limitedText, wasTruncated } = await processTextWithTokenLimit({ - text: fileText, - tokenLimit: fileTokenLimit, - tokenCountFn: (text) => countTokens(text), - }); - - if (wasTruncated) { - logger.debug( - `[encodeAndFormat] Text content truncated for file: ${file.filename} due to token limits`, - ); - } - - result.text += `${!result.text ? 'Attached document(s):\n```md' : '\n\n---\n\n'}# "${file.filename}"\n${limitedText}\n`; - } if (!file.height) { promises.push([file, null]); @@ -165,10 +142,6 @@ async function encodeAndFormat(req, files, endpoint, mode) { promises.push(preparePayload(req, file)); } - if (result.text) { - result.text += '\n```'; - } - const detail = req.body.imageDetail ?? ImageDetail.auto; /** @type {Array<[MongoFile, string]>} */ diff --git a/packages/api/src/files/context.ts b/packages/api/src/files/context.ts new file mode 100644 index 0000000000..24418ce49d --- /dev/null +++ b/packages/api/src/files/context.ts @@ -0,0 +1,68 @@ +import { logger } from '@librechat/data-schemas'; +import { FileSources, mergeFileConfig } from 'librechat-data-provider'; +import type { fileConfigSchema } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { z } from 'zod'; +import { processTextWithTokenLimit } from '~/utils/text'; + +/** + * Extracts text context from attachments and returns formatted text. + * This handles text that was already extracted from files (OCR, transcriptions, document text, etc.) + * @param params - The parameters object + * @param params.attachments - Array of file attachments + * @param params.req - Express request object for config access + * @param params.tokenCountFn - Function to count tokens in text + * @returns The formatted file context text, or undefined if no text found + */ +export async function extractFileContext({ + attachments, + req, + tokenCountFn, +}: { + attachments: IMongoFile[]; + req?: { + body?: { fileTokenLimit?: number }; + config?: { fileConfig?: z.infer }; + }; + tokenCountFn: (text: string) => number; +}): Promise { + if (!attachments || attachments.length === 0) { + return undefined; + } + + const fileConfig = mergeFileConfig(req?.config?.fileConfig); + const fileTokenLimit = req?.body?.fileTokenLimit ?? fileConfig.fileTokenLimit; + + if (!fileTokenLimit) { + // If no token limit, return undefined (no processing) + return undefined; + } + + let resultText = ''; + + for (const file of attachments) { + const source = file.source ?? FileSources.local; + if (source === FileSources.text && file.text) { + const { text: limitedText, wasTruncated } = await processTextWithTokenLimit({ + text: file.text, + tokenLimit: fileTokenLimit, + tokenCountFn, + }); + + if (wasTruncated) { + logger.debug( + `[extractFileContext] Text content truncated for file: ${file.filename} due to token limits`, + ); + } + + resultText += `${!resultText ? 'Attached document(s):\n```md' : '\n\n---\n\n'}# "${file.filename}"\n${limitedText}\n`; + } + } + + if (resultText) { + resultText += '\n```'; + return resultText; + } + + return undefined; +} diff --git a/packages/api/src/files/index.ts b/packages/api/src/files/index.ts index 3d1a3118e3..9111b8d5e3 100644 --- a/packages/api/src/files/index.ts +++ b/packages/api/src/files/index.ts @@ -1,4 +1,5 @@ export * from './audio'; +export * from './context'; export * from './encode'; export * from './mistral/crud'; export * from './ocr'; From f931731ef862634d9450755dd91ea14d91769bb2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 07:43:11 -0400 Subject: [PATCH 023/272] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#10070)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/ar/translation.json | 1 - client/src/locales/ca/translation.json | 1 - client/src/locales/cs/translation.json | 1 - client/src/locales/da/translation.json | 1 - client/src/locales/de/translation.json | 8 +- client/src/locales/en/translation.json | 42 ++++---- client/src/locales/es/translation.json | 1 - client/src/locales/et/translation.json | 1 - client/src/locales/fa/translation.json | 1 - client/src/locales/fr/translation.json | 106 ++++++++++++++++---- client/src/locales/he/translation.json | 38 ++++++- client/src/locales/hu/translation.json | 1 - client/src/locales/it/translation.json | 1 - client/src/locales/ja/translation.json | 1 - client/src/locales/ko/translation.json | 1 - client/src/locales/lv/translation.json | 29 +++++- client/src/locales/nb/translation.json | 1 - client/src/locales/pl/translation.json | 1 - client/src/locales/pt-BR/translation.json | 1 - client/src/locales/pt-PT/translation.json | 1 - client/src/locales/ru/translation.json | 4 +- client/src/locales/sv/translation.json | 18 +--- client/src/locales/th/translation.json | 1 - client/src/locales/tr/translation.json | 1 - client/src/locales/uk/translation.json | 1 - client/src/locales/zh-Hans/translation.json | 1 - client/src/locales/zh-Hant/translation.json | 1 - 27 files changed, 176 insertions(+), 89 deletions(-) diff --git a/client/src/locales/ar/translation.json b/client/src/locales/ar/translation.json index 783925519d..8aba0b49c1 100644 --- a/client/src/locales/ar/translation.json +++ b/client/src/locales/ar/translation.json @@ -268,7 +268,6 @@ "com_nav_auto_scroll": "التمرير التلقائي إلى أحدث عند الفتح", "com_nav_auto_send_prompts": "إرسال تلقائي للموجهات", "com_nav_auto_send_text": "إرسال النص تلقائيًا", - "com_nav_auto_send_text_disabled": "اضبط القيمة على -1 للتعطيل", "com_nav_auto_transcribe_audio": "النسخ التلقائي للصوت", "com_nav_automatic_playback": "تشغيل تلقائي لآخر رسالة", "com_nav_balance": "توازن", diff --git a/client/src/locales/ca/translation.json b/client/src/locales/ca/translation.json index 9deb6fd305..2ede5df176 100644 --- a/client/src/locales/ca/translation.json +++ b/client/src/locales/ca/translation.json @@ -302,7 +302,6 @@ "com_nav_auto_scroll": "Desplaçament automàtic al darrer missatge en obrir el xat", "com_nav_auto_send_prompts": "Envia automàticament els prompts", "com_nav_auto_send_text": "Envia text automàticament", - "com_nav_auto_send_text_disabled": "estableix -1 per desactivar", "com_nav_auto_transcribe_audio": "Transcriu àudio automàticament", "com_nav_automatic_playback": "Reprodueix automàticament el darrer missatge", "com_nav_balance": "Balanç", diff --git a/client/src/locales/cs/translation.json b/client/src/locales/cs/translation.json index d776f584cc..027027f156 100644 --- a/client/src/locales/cs/translation.json +++ b/client/src/locales/cs/translation.json @@ -216,7 +216,6 @@ "com_nav_auto_scroll": "Automaticky rolovat na nejnovější zprávu po otevření chatu", "com_nav_auto_send_prompts": "Automatické odesílání výzev", "com_nav_auto_send_text": "Automatické odesílání textu", - "com_nav_auto_send_text_disabled": "nastavte -1 pro deaktivaci", "com_nav_auto_transcribe_audio": "Automaticky přepisovat zvuk", "com_nav_automatic_playback": "Automatické přehrávání poslední zprávy", "com_nav_balance": "Zůstatek", diff --git a/client/src/locales/da/translation.json b/client/src/locales/da/translation.json index 67e93245db..9879e3c618 100644 --- a/client/src/locales/da/translation.json +++ b/client/src/locales/da/translation.json @@ -296,7 +296,6 @@ "com_nav_auto_scroll": "Auto-scroll til seneste besked, når chatten er åben", "com_nav_auto_send_prompts": "Automatisk afsendelse af prompte", "com_nav_auto_send_text": "Send tekst automatisk", - "com_nav_auto_send_text_disabled": "sæt -1 for at deaktivere", "com_nav_auto_transcribe_audio": "Automatisk transskribering af lyd", "com_nav_automatic_playback": "Autoplay Seneste besked", "com_nav_balance": "Balance", diff --git a/client/src/locales/de/translation.json b/client/src/locales/de/translation.json index 845e5aa919..58e89591b6 100644 --- a/client/src/locales/de/translation.json +++ b/client/src/locales/de/translation.json @@ -26,6 +26,7 @@ "com_agents_category_sales_description": "Agenten mit Fokus auf Vertriebsprozesse und Kundenbeziehungen", "com_agents_category_tab_label": "Kategorie {{category}}. {{position}} von {{total}}", "com_agents_category_tabs_label": "Agenten-Kategorien", + "com_agents_chat_with": "Chatte mit {{name}}", "com_agents_clear_search": "Suche löschen", "com_agents_code_interpreter": "Wenn aktiviert, ermöglicht es deinem Agenten, die LibreChat Code Interpreter API zu nutzen, um generierten Code sicher auszuführen, einschließlich der Verarbeitung von Dateien. Erfordert einen gültigen API-Schlüssel.", "com_agents_code_interpreter_title": "Code-Interpreter-API", @@ -59,6 +60,7 @@ "com_agents_error_timeout_suggestion": "Bitte überprüfe deine Internetverbindung und versuche es erneut.", "com_agents_error_timeout_title": "Verbindungs-Timeout", "com_agents_error_title": "Es ist ein Fehler aufgetreten", + "com_agents_file_context_description": "Als „Kontext“ hochgeladene Dateien werden als Text analysiert, um die Anweisungen des Agenten zu ergänzen. Wenn OCR verfügbar ist oder für den hochgeladenen Dateityp konfiguriert wurde, wird dieser Prozess zum Extrahieren von Text verwendet. Ideal für Dokumente, Bilder mit Text oder PDFs, bei denen du den vollständigen Textinhalt einer Datei benötigst.", "com_agents_file_context_disabled": "Der Agent muss vor dem Hochladen von Dateien für den Datei-Kontext erstellt werden.", "com_agents_file_context_label": "Dateikontext", "com_agents_file_search_disabled": "Der Agent muss erstellt werden, bevor Dateien für die Dateisuche hochgeladen werden können.", @@ -405,7 +407,6 @@ "com_nav_auto_scroll": "Automatisch zur neuesten Nachricht scrollen, wenn der Chat geöffnet wird", "com_nav_auto_send_prompts": "Prompts automatisch senden", "com_nav_auto_send_text": "Text automatisch senden", - "com_nav_auto_send_text_disabled": "-1 setzen zum Deaktivieren", "com_nav_auto_transcribe_audio": "Audio automatisch transkribieren", "com_nav_automatic_playback": "Automatische Wiedergabe der neuesten Nachricht", "com_nav_balance": "Guthaben", @@ -830,6 +831,7 @@ "com_ui_delete_success": "Erfolgreich gelöscht", "com_ui_delete_tool": "Werkzeug löschen", "com_ui_delete_tool_confirm": "Bist du sicher, dass du dieses Werkzeug löschen möchtest?", + "com_ui_delete_tool_save_reminder": "Tool entfernt. Speichere den Agenten, um die Änderungen zu übernehmen.", "com_ui_deleted": "Gelöscht", "com_ui_deleting_file": "Lösche Datei...", "com_ui_descending": "Absteigend", @@ -1022,6 +1024,7 @@ "com_ui_no_category": "Keine Kategorie", "com_ui_no_changes": "Es wurden keine Änderungen vorgenommen", "com_ui_no_individual_access": "Keine einzelnen Benutzer oder Gruppen haben Zugriff auf diesen Agenten.", + "com_ui_no_memories": "Keine Erinnerungen. Erstelle sie manuell oder fordere die KI auf, sich etwas zu merken.\n", "com_ui_no_personalization_available": "Derzeit sind keine Personalisierungsoptionen verfügbar.", "com_ui_no_read_access": "Du hast keine Berechtigung, Erinnerungen anzuzeigen.", "com_ui_no_results_found": "Keine Ergebnisse gefunden", @@ -1224,6 +1227,7 @@ "com_ui_upload_invalid": "Ungültige Datei zum Hochladen. Muss ein Bild sein und das Limit nicht überschreiten", "com_ui_upload_invalid_var": "Ungültige Datei zum Hochladen. Muss ein Bild sein und {{0}} MB nicht überschreiten", "com_ui_upload_ocr_text": "Hochladen als Text mit OCR", + "com_ui_upload_provider": "Hochladen zum KI-Anbieter", "com_ui_upload_success": "Datei erfolgreich hochgeladen", "com_ui_upload_type": "Upload-Typ auswählen", "com_ui_usage": "Nutzung", @@ -1262,6 +1266,8 @@ "com_ui_web_search_scraper": "Scraper", "com_ui_web_search_scraper_firecrawl": "Firecrawl API\n", "com_ui_web_search_scraper_firecrawl_key": "Einen Firecrawl API Schlüssel holen", + "com_ui_web_search_scraper_serper": "Serper Scrape API", + "com_ui_web_search_scraper_serper_key": "Hole einen Serper API Schlüssel", "com_ui_web_search_searxng_api_key": "SearXNG API Key (optional) einfügen", "com_ui_web_search_searxng_instance_url": "SearXNG Instanz URL", "com_ui_web_searching": "Internetsuche läuft", diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index f3d575e1de..b686580376 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -561,6 +561,7 @@ "com_nav_setting_balance": "Balance", "com_nav_setting_chat": "Chat", "com_nav_setting_data": "Data controls", + "com_nav_setting_delay": "Delay (s)", "com_nav_setting_general": "General", "com_nav_setting_mcp": "MCP Settings", "com_nav_setting_personalization": "Personalization", @@ -573,7 +574,6 @@ "com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard", "com_nav_speech_to_text": "Speech to Text", "com_nav_stop_generating": "Stop generating", - "com_nav_setting_delay": "Delay (s)", "com_nav_text_to_speech": "Text to Speech", "com_nav_theme": "Theme", "com_nav_theme_dark": "Dark", @@ -761,8 +761,8 @@ "com_ui_client_secret": "Client Secret", "com_ui_close": "Close", "com_ui_close_menu": "Close Menu", - "com_ui_close_window": "Close Window", "com_ui_close_settings": "Close Settings", + "com_ui_close_window": "Close Window", "com_ui_code": "Code", "com_ui_collapse_chat": "Collapse Chat", "com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used", @@ -860,6 +860,7 @@ "com_ui_edit_editing_image": "Editing image", "com_ui_edit_mcp_server": "Edit MCP Server", "com_ui_edit_memory": "Edit Memory", + "com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size", "com_ui_empty_category": "-", "com_ui_endpoint": "Endpoint", "com_ui_endpoint_menu": "LLM Endpoint Menu", @@ -894,6 +895,7 @@ "com_ui_feedback_tag_unjustified_refusal": "Refused without reason", "com_ui_field_max_length": "{{field}} must be less than {{length}} characters", "com_ui_field_required": "This field is required", + "com_ui_file_input_avatar_label": "File input for avatar", "com_ui_file_size": "File Size", "com_ui_file_token_limit": "File Token Limit", "com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage", @@ -952,16 +954,17 @@ "com_ui_image_edited": "Image edited", "com_ui_image_gen": "Image Gen", "com_ui_import": "Import", - "com_ui_import_conversation_error": "There was an error while importing your conversations", - "com_ui_import_conversation_file_type_error": "Error with file type. Please select a valid JSON file.", - "com_ui_import_conversation_upload_error": "Error uploading file. Please try again.", + "com_ui_import_conversation_error": "There was an error importing your conversations", + "com_ui_import_conversation_file_type_error": "Unsupported import type", "com_ui_import_conversation_info": "Import conversations from a JSON file", "com_ui_import_conversation_success": "Conversations imported successfully", + "com_ui_import_conversation_upload_error": "Error uploading file. Please try again.", "com_ui_include_shadcnui": "Include shadcn/ui components instructions", "com_ui_initializing": "Initializing...", "com_ui_input": "Input", "com_ui_instructions": "Instructions", "com_ui_key": "Key", + "com_ui_key_required": "API key is required", "com_ui_late_night": "Happy late night", "com_ui_latest_footer": "Every AI for Everyone.", "com_ui_latest_production_version": "Latest production version", @@ -976,6 +979,7 @@ "com_ui_manage": "Manage", "com_ui_marketplace": "Marketplace", "com_ui_marketplace_allow_use": "Allow using Marketplace", + "com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})", "com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.", "com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully", "com_ui_mcp_configure_server": "Configure {{0}}", @@ -1070,6 +1074,7 @@ "com_ui_privacy_policy": "Privacy policy", "com_ui_privacy_policy_url": "Privacy Policy URL", "com_ui_prompt": "Prompt", + "com_ui_prompt_groups": "Prompt Groups List", "com_ui_prompt_name": "Prompt Name", "com_ui_prompt_name_required": "Prompt Name is required", "com_ui_prompt_preview_not_shared": "The author has not allowed collaboration for this prompt.", @@ -1080,7 +1085,6 @@ "com_ui_prompts_allow_create": "Allow creating Prompts", "com_ui_prompts_allow_share": "Allow sharing Prompts", "com_ui_prompts_allow_use": "Allow using Prompts", - "com_ui_prompt_groups": "Prompt Groups List", "com_ui_provider": "Provider", "com_ui_quality": "Quality", "com_ui_read_aloud": "Read aloud", @@ -1100,6 +1104,8 @@ "com_ui_rename_failed": "Failed to rename conversation", "com_ui_rename_prompt": "Rename Prompt", "com_ui_requires_auth": "Requires Authentication", + "com_ui_reset": "Reset", + "com_ui_reset_adjustments": "Reset adjustments", "com_ui_reset_var": "Reset {{0}}", "com_ui_reset_zoom": "Reset Zoom", "com_ui_resource": "resource", @@ -1108,6 +1114,8 @@ "com_ui_revoke_info": "Revoke all user provided credentials", "com_ui_revoke_key_confirm": "Are you sure you want to revoke this key?", "com_ui_revoke_key_endpoint": "Revoke Key for {{0}}", + "com_ui_revoke_key_error": "Failed to revoke API key. Please try again.", + "com_ui_revoke_key_success": "API key revoked successfully", "com_ui_revoke_keys": "Revoke Keys", "com_ui_revoke_keys_confirm": "Are you sure you want to revoke all keys?", "com_ui_role": "Role", @@ -1121,11 +1129,15 @@ "com_ui_role_viewer": "Viewer", "com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it", "com_ui_roleplay": "Roleplay", + "com_ui_rotate": "Rotate", + "com_ui_rotate_90": "Rotate 90 degrees", "com_ui_run_code": "Run Code", "com_ui_run_code_error": "There was an error running the code", "com_ui_save": "Save", "com_ui_save_badge_changes": "Save badge changes?", "com_ui_save_changes": "Save Changes", + "com_ui_save_key_error": "Failed to save API key. Please try again.", + "com_ui_save_key_success": "API key saved successfully", "com_ui_save_submit": "Save & Submit", "com_ui_saved": "Saved!", "com_ui_saving": "Saving...", @@ -1222,6 +1234,7 @@ "com_ui_update_mcp_success": "Successfully created or updated MCP", "com_ui_upload": "Upload", "com_ui_upload_agent_avatar": "Successfully updated agent avatar", + "com_ui_upload_avatar_label": "Upload avatar image", "com_ui_upload_code_files": "Upload for Code Interpreter", "com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.", "com_ui_upload_error": "There was an error uploading your file", @@ -1283,21 +1296,8 @@ "com_ui_x_selected": "{{0}} selected", "com_ui_yes": "Yes", "com_ui_zoom": "Zoom", - "com_user_message": "You", - "com_ui_rotate": "Rotate", - "com_ui_reset": "Reset", "com_ui_zoom_in": "Zoom in", - "com_ui_zoom_out": "Zoom out", "com_ui_zoom_level": "Zoom level", - "com_ui_rotate_90": "Rotate 90 degrees", - "com_ui_reset_adjustments": "Reset adjustments", - "com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size", - "com_ui_save_key_success": "API key saved successfully", - "com_ui_save_key_error": "Failed to save API key. Please try again.", - "com_ui_revoke_key_success": "API key revoked successfully", - "com_ui_revoke_key_error": "Failed to revoke API key. Please try again.", - "com_ui_key_required": "API key is required", - "com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})", - "com_ui_upload_avatar_label": "Upload avatar image", - "com_ui_file_input_avatar_label": "File input for avatar" + "com_ui_zoom_out": "Zoom out", + "com_user_message": "You" } diff --git a/client/src/locales/es/translation.json b/client/src/locales/es/translation.json index 7a0b6f4731..bc914e50b6 100644 --- a/client/src/locales/es/translation.json +++ b/client/src/locales/es/translation.json @@ -336,7 +336,6 @@ "com_nav_auto_scroll": "Desplazamiento automático al más reciente al abrir", "com_nav_auto_send_prompts": "Envío automático de mensajes", "com_nav_auto_send_text": "Envío automático de texto", - "com_nav_auto_send_text_disabled": "Establecer -1 para deshabilitar", "com_nav_auto_transcribe_audio": "Transcribir audio automáticamente", "com_nav_automatic_playback": "Reproducción automática del último mensaje", "com_nav_balance": "Balance", diff --git a/client/src/locales/et/translation.json b/client/src/locales/et/translation.json index c190ba59df..f7907d27d3 100644 --- a/client/src/locales/et/translation.json +++ b/client/src/locales/et/translation.json @@ -296,7 +296,6 @@ "com_nav_auto_scroll": "Automaatne kerimine vestluse avamisel viimase sõnumini", "com_nav_auto_send_prompts": "Saada vihjed automaatselt", "com_nav_auto_send_text": "Saada tekst automaatselt", - "com_nav_auto_send_text_disabled": "Keelamiseks määra -1", "com_nav_auto_transcribe_audio": "Transkribeeri heli automaatselt", "com_nav_automatic_playback": "Esita viimane sõnum automaatselt", "com_nav_balance": "Saldo", diff --git a/client/src/locales/fa/translation.json b/client/src/locales/fa/translation.json index ea46f6dbc0..f194209d2c 100644 --- a/client/src/locales/fa/translation.json +++ b/client/src/locales/fa/translation.json @@ -291,7 +291,6 @@ "com_nav_auto_scroll": "اسکرول خودکار به آخرین پیام در گپ باز است", "com_nav_auto_send_prompts": "درخواست ارسال خودکار", "com_nav_auto_send_text": "ارسال خودکار متن", - "com_nav_auto_send_text_disabled": "-1 را برای غیرفعال کردن تنظیم کنید", "com_nav_auto_transcribe_audio": "رونویسی خودکار صدا", "com_nav_automatic_playback": "پخش خودکار آخرین پیام", "com_nav_balance": "تعادل", diff --git a/client/src/locales/fr/translation.json b/client/src/locales/fr/translation.json index 9ac0005196..2321acfdd1 100644 --- a/client/src/locales/fr/translation.json +++ b/client/src/locales/fr/translation.json @@ -1,35 +1,60 @@ { - "chat_direction_left_to_right": "Zone de saisie orientée de gauche à droite", - "chat_direction_right_to_left": "Zone de saisie orientée de droite à gauche", + "chat_direction_left_to_right": "il faut mettre quelque chose ici. c'était vide.", + "chat_direction_right_to_left": "Il faut mettre quelque chose ici. C'était vide.", "com_a11y_ai_composing": "L'IA est en train de composer", "com_a11y_end": "L'IA a terminé sa réponse", "com_a11y_start": "L'IA a commencé sa réponse", + "com_agents_agent_card_label": "{{nom}} agent. {{description}}", "com_agents_all": "Tous les agents", "com_agents_all_category": "Tous", "com_agents_all_description": "Parcourir tous les agents partagés à travers toutes les catégories", "com_agents_by_librechat": "par LibreChat", "com_agents_category_aftersales_description": "Agents spécialisés en support après-vente, maintenance et service clients", + "com_agents_category_finance": "Finance", + "com_agents_category_finance_description": "Agents spécialisés dans l'analyse financière, la budgétisation et la comptabilité", "com_agents_category_general": "Générale", + "com_agents_category_general_description": "Agents génériques pour tâches et requêtes standards", "com_agents_category_hr": "Ressources Humaines", + "com_agents_category_it_description": "Agents pour le support informatique, le dépannage technique et l'administration des systèmes", "com_agents_category_rd": "Recherche & Développement", + "com_agents_category_rd_description": "Agent dédié au process R&D, à l'innovation et à la recherche", "com_agents_category_sales": "Ventes", + "com_agents_category_sales_description": "Agents dédié au process de vente et à la relation client", + "com_agents_category_tab_label": "{{category}} catégorie, {{position}} sur {{total}}", "com_agents_category_tabs_label": "Catégories d'agent", "com_agents_chat_with": "Chatter avec {{name}}", "com_agents_clear_search": "Effacer la recherche", "com_agents_code_interpreter": "Lorsqu'activé, permet à votre agent d'utiliser l'API d'interpréteur de code LibreChat pour exécuter du code généré de manière sécurisée, y compris le traitement de fichiers. Nécessite une clé API valide.", "com_agents_code_interpreter_title": "API d'interpréteur de code", + "com_agents_copy_link": "Copier le lien", "com_agents_create_error": "Une erreur s'est produite lors de la création de votre agent.", "com_agents_created_by": "par", "com_agents_description_placeholder": "Décrivez votre Agent ici (facultatif)", + "com_agents_empty_state_heading": "Aucun agent trouvé", "com_agents_enable_file_search": "Activer la recherche de fichiers", + "com_agents_error_bad_request_message": "La demande ne peut pas être traitée", + "com_agents_error_bad_request_suggestion": "Vérifiez votre connexion Internet et réessayez.", + "com_agents_error_category_title": "Erreur de catégorie", + "com_agents_error_generic": "Nous avons rencontrez un problème lors du chargement du contenu", + "com_agents_error_invalid_request": "Requête invalide", + "com_agents_error_loading": "Error lors du chargement des agents", + "com_agents_error_network_message": "Impossible de se connecter au serveur", + "com_agents_error_network_suggestion": "Vérifier votre connexion internet et réessayer.", "com_agents_error_network_title": "Problème de connexion", "com_agents_error_not_found_message": "Le contenu demandé ne peut être trouvé", "com_agents_error_not_found_title": "Non trouvé", "com_agents_error_retry": "Essayer encore", + "com_agents_error_searching": "Erreur lors de la recherche d'agents\n", + "com_agents_error_server_title": "Erreur serveur", + "com_agents_error_title": "Une erreur est survenue", "com_agents_file_context_disabled": "L'agent doit être créé avant de charger des fichiers pour le contexte de fichiers.", "com_agents_file_search_disabled": "L'agent doit être créé avant de pouvoir télécharger des fichiers pour la Recherche de Fichiers.", "com_agents_file_search_info": "Lorsque cette option est activée, l'agent sera informé des noms exacts des fichiers listés ci-dessous, lui permettant d'extraire le contexte pertinent de ces fichiers.", "com_agents_instructions_placeholder": "Les instructions système que l'agent utilise", + "com_agents_link_copied": "Lien copié", + "com_agents_loading": "Chargement...", + "com_agents_marketplace": "Marketplace d'Agents", + "com_agents_marketplace_subtitle": "Découvres et profites d'agents IA puissants pour améliorer tes processus et ta productivité", "com_agents_mcp_description_placeholder": "Décrivez ce qu'il fait en quelques mots", "com_agents_mcp_icon_size": "Taille minimale de 128 x 128 pixels", "com_agents_mcp_info": "Ajoutez des serveurs MCP à votre agent pour lui permettre d'accomplir des tâches et d'interagir avec des services externes", @@ -41,14 +66,19 @@ "com_agents_no_access": "Vous n'avez pas l'autorisation de modifier cet agent.", "com_agents_no_agent_id_error": "Aucun identifiant (ID) d'agent trouvé. Assurez-vous que l'agent existe.", "com_agents_not_available": "Agent non disponible", + "com_agents_results_for": "Résultats pour '{{query}}'", + "com_agents_search_aria": "Rechercher un agent", "com_agents_search_info": "Lorsque cette option est activée, votre agent est autorisé à rechercher des informations récentes sur le web. Nécessite une clé API valide.", "com_agents_search_name": "Rechercher des agents par nom", + "com_agents_start_chat": "Débuter la conversation", + "com_agents_top_picks": "Meilleurs choix", "com_agents_update_error": "Une erreur s'est produite lors de la mise à jour de votre agent", "com_assistants_action_attempt": "L'assistant souhaite échanger avec {{0}}", "com_assistants_actions": "Actions", "com_assistants_actions_disabled": "Vous devez créer un assistant avant d'ajouter des actions.", "com_assistants_actions_info": "Permettez à votre Assistant de récupérer des informations ou d'effectuer des actions via des API", "com_assistants_add_actions": "Ajouter des actions", + "com_assistants_add_mcp_server_tools": "Ajouter des outils MCP Server", "com_assistants_add_tools": "Ajouter des outils", "com_assistants_allow_sites_you_trust": "Autoriser seulement les sites de confiance.", "com_assistants_append_date": "Ajouter la date et l'heure actuelles", @@ -211,6 +241,7 @@ "com_endpoint_deprecated": "Obsolète", "com_endpoint_deprecated_info": "Ce point de terminaison est obsolète et pourrait être supprimé dans les versions futures, veuillez utiliser le point de terminaison de l'agent à la place.", "com_endpoint_deprecated_info_a11y": "Le point de terminaison du plugin est obsolète et pourrait être supprimé dans les versions futures, veuillez utiliser le point de terminaison de l'agent à la place.", + "com_endpoint_disable_streaming_label": "Désactiver le streaming", "com_endpoint_examples": " Exemples", "com_endpoint_export": "Exporter", "com_endpoint_export_share": "Exporter/Partager", @@ -297,6 +328,8 @@ "com_endpoint_use_active_assistant": "Utiliser l'assistant actif", "com_endpoint_use_responses_api": "Utilise l'API de Réponses", "com_endpoint_use_search_grounding": "Ancrage avec les recherches Google", + "com_endpoint_verbosity": "Verbosité", + "com_error_endpoint_models_not_loaded": "Les modeles pour {{0}} ne peuvent pas être chargés, merci de rafraichir la page et réessayer.", "com_error_expired_user_key": "La clé fournie pour {{0}} a expiré à {{1}}. Veuillez fournir une clé et réessayer.", "com_error_files_dupe": "Fichier en double détecté.", "com_error_files_empty": "Les fichiers vides ne sont pas autorisés", @@ -306,16 +339,27 @@ "com_error_files_validation": "Une erreur s'est produite lors de la validation du fichier.", "com_error_google_tool_conflict": "L'utilisation combinée des outils intégrés de Google et d'outils externes n'est pas prise en charge. Veuillez désactiver soit les outils intégrés soit les outils externes.", "com_error_heic_conversion": "La conversion de l'image HEIC en JPEG a échoué. Essayez de convertir l'image manuellement ou utilisez un autre format.", + "com_error_illegal_model_request": "Le modèle \"{{0}}\" n'est pas disponible pour {{1}}. Veuillez sélectionner un autre modèle.", "com_error_input_length": "Le nombre de jetons du dernier message est trop élevé et dépasse la limite autorisée ({{0}}). Veuillez raccourcir votre message, ajuster la taille maximale du contexte dans les paramètres de conversation, ou créer une nouvelle conversation pour continuer.", "com_error_invalid_agent_provider": "Le \"fournisseur {{0}} \" n'est pas disponible pour les agents. Veuillez vous rendre dans les paramètres de votre agent et sélectionner un fournisseur actuellement disponible.", "com_error_invalid_user_key": "Clé fournie non valide. Veuillez fournir une clé valide et réessayer.", + "com_error_missing_model": "Aucun modele pour {{0}}, Veuillez sélectionner un modèle et réessayer. ", "com_error_moderation": "Il semble que le contenu soumis ait été signalé par notre système de modération pour ne pas être conforme à nos lignes directrices communautaires. Nous ne pouvons pas procéder avec ce sujet spécifique. Si vous avez d'autres questions ou sujets que vous souhaitez explorer, veuillez modifier votre message ou créer une nouvelle conversation.", "com_error_no_base_url": "Aucune URL de base trouvée. Veuillez en fournir une et réessayer.", "com_error_no_user_key": "Aucune clé trouvée. Veuillez fournir une clé et réessayer.", + "com_file_source": "Fichier", + "com_file_unknown": "Fichier Inconnu\n", + "com_files_download_failed": "{{0}} fichiers échouées", + "com_files_download_percent_complete": "Terminé à {{0}}%", + "com_files_download_progress": "{{0}} fichiers sur {{1}}", "com_files_filter": "Filtrer les fichiers...", "com_files_no_results": "Aucun résultat.", "com_files_number_selected": "{{0}} sur {{1}} fichier(s) sélectionné(s)", + "com_files_preparing_download": "Préparation du téléchargement", + "com_files_sharepoint_picker_title": "Sélectionner un fichier", "com_files_table": "quelquechose doit être renseigné ici. c'était vide", + "com_files_upload_local_machine": "Depuis ordinateur local", + "com_files_upload_sharepoint": "Depuis Sharepoint", "com_generated_files": "Fichiers générés :", "com_hide_examples": "Masquer les exemples", "com_info_heic_converting": "Convertir les images HEIC en JPEG...", @@ -332,7 +376,6 @@ "com_nav_auto_scroll": "Défilement automatique jusqu'au plus récent à l'ouverture", "com_nav_auto_send_prompts": "Envoi automatique des prompts", "com_nav_auto_send_text": "Envoi automatique du texte (après 3 sec)", - "com_nav_auto_send_text_disabled": "définir sur -1 pour désactiver", "com_nav_auto_transcribe_audio": "Transcription audio automatique", "com_nav_automatic_playback": "Lecture automatique du dernier message (externe seulement)", "com_nav_balance": "Équilibre", @@ -454,7 +497,7 @@ "com_nav_log_out": "Se déconnecter", "com_nav_long_audio_warning": "Les textes plus longs prendront plus de temps à traiter.", "com_nav_maximize_chat_space": "Maximiser l'espace de discussion", - "com_nav_mcp_vars_update_error": "Erreur lors de l'actualisation des variables de l'utilisateur MCP personnalisé : {{0}}", + "com_nav_mcp_vars_update_error": "Erreur lors de l'actualisation des variables MCP de l'utilisateur", "com_nav_mcp_vars_updated": "Actualisation réussie des variables de l'utilisateur MCP personnalisé.", "com_nav_modular_chat": "Activer le changement de points de terminaison en cours de conversation", "com_nav_my_files": "Mes fichiers", @@ -498,6 +541,7 @@ "com_nav_tool_dialog": "Outils de l'assistant", "com_nav_tool_dialog_agents": "Outils de l'agent", "com_nav_tool_dialog_description": "L'assistant doit être sauvegardé pour conserver les sélections d'outils.", + "com_nav_tool_dialog_mcp_server_tools": "Outils MCP Server", "com_nav_tool_remove": "Supprimer", "com_nav_tool_search": "Outils de recherche", "com_nav_user": "UTILISATEUR", @@ -515,9 +559,14 @@ "com_sidepanel_manage_files": "Gérer les fichiers", "com_sidepanel_mcp_no_servers_with_vars": "Aucun serveur MCP dont les variables sont configurables.", "com_sidepanel_parameters": "Paramètres", + "com_sources_agent_file": "Document source", + "com_sources_error_fallback": "Impossible de charger la source", "com_sources_image_alt": "Image de résultat de recherche", + "com_sources_more_files": "+ {{count}} fichiers", "com_sources_more_sources": "+{{count}} sources", + "com_sources_reload_page": "Recharger la page", "com_sources_tab_all": "Tous", + "com_sources_tab_files": "Fichiers", "com_sources_tab_images": "Images", "com_sources_tab_news": "Actualités", "com_sources_title": "Sources", @@ -533,6 +582,7 @@ "com_ui_2fa_verified": "Vérification de l'authentification à deux facteurs réussie", "com_ui_accept": "J'accepte", "com_ui_action_button": "Bouton d'action", + "com_ui_active": "Actif", "com_ui_add": "Ajouter", "com_ui_add_mcp": "Ajouter MCP", "com_ui_add_mcp_server": "Ajouter un server MCP", @@ -545,6 +595,9 @@ "com_ui_advanced": "Avancé", "com_ui_advanced_settings": "Réglages avancés", "com_ui_agent": "Agent", + "com_ui_agent_category_finance": "Finance", + "com_ui_agent_category_general": "Général", + "com_ui_agent_category_rd": "R&D", "com_ui_agent_chain": "Chaîne d'agents (mélange d'agents)", "com_ui_agent_chain_info": "Active la création des séquences d'agents. Chaque agent peut accéder aux résultats des agents précédents de la chaîne. Basé sur l'architecture \"mélange d'agents\" où les agents utilisent les résultats précédents comme information auxiliaire.", "com_ui_agent_chain_max": "Vous avez atteint le maximum de {{0}} d'agents.", @@ -552,8 +605,10 @@ "com_ui_agent_deleted": "Agent supprimé avec succès", "com_ui_agent_duplicate_error": "Une erreur s'est produite lors de la duplication de l'agent", "com_ui_agent_duplicated": "Agent dupliqué avec succès", + "com_ui_agent_name_is_required": "Le nom de l'agent est obligatoire.", "com_ui_agent_recursion_limit": "Nombre maximal d'étapes de l'agent", "com_ui_agent_recursion_limit_info": "Limite le nombre d'étapes que l'agent peut exécuter avant de donner son résultat final. Par défaut, la limite est de 25 étapes. Une étape est soit une requête API soit une utilisation d'un outil. Par exemple, une utilisation simple d'un outil demande 3 étapes : requête initiale, utilisation de l'outil et envoi de la réponse.", + "com_ui_agent_url_copied": "URL de l'agent copiée dans le presse-papiers", "com_ui_agent_var": "agent {{0}}", "com_ui_agent_version": "Version", "com_ui_agent_version_active": "Version active", @@ -570,6 +625,7 @@ "com_ui_agent_version_unknown_date": "Date inconnue", "com_ui_agents": "Agents", "com_ui_agents_allow_create": "Autoriser la création d'Agents", + "com_ui_agents_allow_share": "Autoriser le partage Agents", "com_ui_agents_allow_use": "Autoriser l'utilisation des Agents", "com_ui_all": "tout", "com_ui_all_proper": "Tout", @@ -590,6 +646,7 @@ "com_ui_assistant_deleted": "Assistant supprimé avec succès", "com_ui_assistants": "Assistants virtuels", "com_ui_assistants_output": "Sortie des assistants", + "com_ui_at_least_one_owner_required": "Au moins un propriétaire est requis.", "com_ui_attach_error": "Impossible de joindre le fichier. Créez ou sélectionnez une conversation, ou essayez d'actualiser la page.", "com_ui_attach_error_openai": "Impossible de joindre les fichiers de l'Assistant à d'autres points d'accès", "com_ui_attach_error_size": "Limite de taille de fichier dépassée pour le point de terminaison :", @@ -599,6 +656,7 @@ "com_ui_attachment": "Pièce jointe", "com_ui_auth_type": "Type d'auth", "com_ui_auth_url": "Adresse URL d'authentification", + "com_ui_authenticate": "Authentifier", "com_ui_authentication": "Authentification", "com_ui_authentication_type": "Type d'authentification", "com_ui_auto": "Automatique", @@ -611,6 +669,7 @@ "com_ui_backup_codes": "Codes de sauvegarde", "com_ui_backup_codes_regenerate_error": "Une erreur est survenue lors du renouvellement des codes de sauvegarde", "com_ui_backup_codes_regenerated": "Codes de sauvegarde renouvelé avec succès", + "com_ui_backup_codes_security_info": "Pour des raisons de sécurité, les codes de secours ne s'affichent qu'une seule fois lorsqu'ils sont générés. Veuillez les conserver dans un endroit sûr.", "com_ui_basic": "Simple", "com_ui_basic_auth_header": "En-tête d'autorisation simple", "com_ui_bearer": "Porteur", @@ -667,7 +726,7 @@ "com_ui_copy_to_clipboard": "Copier dans le presse-papier", "com_ui_create": "Créer", "com_ui_create_link": "Créer un lien", - "com_ui_create_memory": "Créer un Souvenir", + "com_ui_create_memory": "Créer une Mémoire", "com_ui_create_prompt": "Créer un prompt", "com_ui_creating_image": "Création de l'image en cours. Cela peut prendre un moment", "com_ui_current": "Actuel", @@ -707,7 +766,7 @@ "com_ui_delete_mcp_confirm": "Êtes-vous sûr de vouloir supprimer ce serveur MCP ?", "com_ui_delete_mcp_error": "Suppression de serveur MCP échouée", "com_ui_delete_mcp_success": "Suppression de serveur MCP réussie", - "com_ui_delete_memory": "Supprimer les Souvenirs", + "com_ui_delete_memory": "Supprimer les données mémorisées ", "com_ui_delete_prompt": "Supprimer le Prompt?", "com_ui_delete_shared_link": "Supprimer le lien partagé ?", "com_ui_delete_tool": "Supprimer l'outil", @@ -724,7 +783,7 @@ "com_ui_download_backup": "Télécharger les codes de sauvegarde", "com_ui_download_backup_tooltip": "Avant de continuer, téléchargez vos codes de sauvegarde. Vous en aurez besoin pour récupérer un accès si vous perdez votre appareil authenticator", "com_ui_download_error": "Erreur lors du téléchargement du fichier. Le fichier a peut-être été supprimé.", - "com_ui_drag_drop": "Glisser et déposer", + "com_ui_drag_drop": "Déposer des fichiers ici afin de les ajouter à la conversation", "com_ui_dropdown_variables": "Variables déroulantes :", "com_ui_dropdown_variables_info": "Créez des menus déroulants personnalisés pour vos prompts : `{{nom_variable:option1|option2|option3}}`", "com_ui_duplicate": "Dupliquer", @@ -734,7 +793,7 @@ "com_ui_edit": "Modifier", "com_ui_edit_editing_image": "Edition de l'image", "com_ui_edit_mcp_server": "Editer le serveur MCP", - "com_ui_edit_memory": "Editer les Souvenirs", + "com_ui_edit_memory": "Editer la Mémoire", "com_ui_empty_category": "-", "com_ui_endpoint": "Point de terminaison", "com_ui_endpoint_menu": "Menu des points de terminaison LLM", @@ -809,7 +868,7 @@ "com_ui_go_back": "Revenir en arrière", "com_ui_go_to_conversation": "Aller à la conversation", "com_ui_good_afternoon": "Bon après-midi", - "com_ui_good_evening": "Bonne soirée", + "com_ui_good_evening": "Bonsoir", "com_ui_good_morning": "Bonjour", "com_ui_happy_birthday": "C'est mon premier anniversaire !", "com_ui_hide_image_details": "Cacher les informations de l'images", @@ -832,7 +891,7 @@ "com_ui_input": "Entrée", "com_ui_instructions": "Instructions", "com_ui_key": "Clé", - "com_ui_late_night": "Bonne nocturne", + "com_ui_late_night": "Bonne fin de nuit", "com_ui_latest_footer": "Chaque IA pour tout le monde.", "com_ui_latest_production_version": "Dernière version de production", "com_ui_latest_version": "Dernière version", @@ -844,28 +903,32 @@ "com_ui_logo": "Logo {{0}}", "com_ui_low": "Faible", "com_ui_manage": "Gérer", + "com_ui_marketplace": "Marketplace", + "com_ui_marketplace_allow_use": "Autoriser l'utilisation de la Marketplace", "com_ui_max_tags": "Le nombre maximum autorisé est {{0}}, en utilisant les dernières valeurs.", "com_ui_mcp_enter_var": "Saisissez la valeur de {{0}}", "com_ui_mcp_server_not_found": "Le serveur n'a pas été trouvé.", "com_ui_mcp_servers": "Serveurs MCP", "com_ui_mcp_url": "Adresse URL du serveur MCP", "com_ui_medium": "Modéré", - "com_ui_memories": "Souvenirs", + "com_ui_memories": "Mémoires", "com_ui_memories_allow_create": "Autoriser la création de Souvenirs", "com_ui_memories_allow_opt_out": "Autoriser les utilisateurs à désactiver les Souvenirs", - "com_ui_memories_allow_read": "Autoriser la lecture des Souvenirs", + "com_ui_memories_allow_read": "Autoriser la lecture de la Mémoire", "com_ui_memories_allow_update": "Autoriser la mise à jour des Souvenirs", "com_ui_memories_allow_use": "Autoriser l'utilisation des Souvenirs", "com_ui_memories_filter": "Filtrer les Souvenirs", - "com_ui_memory": "Souvenir", - "com_ui_memory_created": "Souvenir créé avec succès", - "com_ui_memory_deleted": "Souvenir supprimé", - "com_ui_memory_deleted_items": "Souvenirs supprimés", - "com_ui_memory_key_exists": "Un Souvenir existe déjà avec cette clé. Veuillez utiliser une autre clé.", - "com_ui_memory_updated": "Actualiser le Souvenir enregistré", + "com_ui_memory": "Mémoire", + "com_ui_memory_created": "Mémoire créée avec succès", + "com_ui_memory_deleted": "Mémoire supprimée", + "com_ui_memory_deleted_items": "Mémoires supprimées", + "com_ui_memory_key_exists": "Une Mémoire existe déjà avec cette clé. Veuillez utiliser une autre clé.", + "com_ui_memory_storage_full": "Stockage memory plein", + "com_ui_memory_updated": "Actualiser la Mémoire", "com_ui_memory_updated_items": "Souvenirs enregistrés", "com_ui_mention": "Mentionnez un point de terminaison, un assistant ou un préréglage pour basculer rapidement vers celui-ci", "com_ui_min_tags": "Impossible de supprimer plus de valeurs, un minimum de {{0}} est requis.", + "com_ui_minimal": "Minimal", "com_ui_misc": "Divers", "com_ui_model": "Modèle", "com_ui_model_parameters": "Paramètres du modèle", @@ -878,6 +941,7 @@ "com_ui_next": "Suivant", "com_ui_no": "Non", "com_ui_no_bookmarks": "Il semble que vous n'ayez pas encore de favoris. Cliquez sur une discussion pour en ajouter un", + "com_ui_no_categories": "Aucune catégorie disponible", "com_ui_no_category": "Aucune catégorie", "com_ui_no_personalization_available": "Aucune personnalisation disponible", "com_ui_no_read_access": "Vous n'avez pas l'authorisation de voir les Souvenirs.", @@ -997,6 +1061,9 @@ "com_ui_stop": "Arrêt ", "com_ui_storage": "Stockage", "com_ui_submit": "Soumettre", + "com_ui_support_contact_email": "Email", + "com_ui_support_contact_email_invalid": "Veuillez entrer un email valide", + "com_ui_support_contact_name": "Nom", "com_ui_teach_or_explain": "Apprendre", "com_ui_temporary": "Message éphémère", "com_ui_terms_and_conditions": "Conditions d'utilisation", @@ -1040,13 +1107,14 @@ "com_ui_use_memory": "Utiliser le Souvenir", "com_ui_use_micrphone": "Utiliser le microphone", "com_ui_used": "Déjà utilisé", + "com_ui_user": "Utilisateur", "com_ui_value": "Valeur", "com_ui_variables": "Variables", "com_ui_variables_info": "Utilisez des doubles accolades dans votre texte pour créer des variables, par exemple {{exemple de variable}}, à remplir ultérieurement lors de l'utilisation du prompt.", "com_ui_verify": "Vérifier", "com_ui_version_var": "Version {{0}}", "com_ui_versions": "Versions", - "com_ui_view_memory": "Voir le Souvenir", + "com_ui_view_memory": "Voir la Mémoire", "com_ui_view_source": "Voir le message d'origine", "com_ui_web_search": "Recherche web", "com_ui_web_search_cohere_key": "Entrez la clé API de Cohere", diff --git a/client/src/locales/he/translation.json b/client/src/locales/he/translation.json index ccd7d482b8..6425f444ad 100644 --- a/client/src/locales/he/translation.json +++ b/client/src/locales/he/translation.json @@ -24,6 +24,7 @@ "com_agents_category_sales_description": "סוכנים המתמקדים בתהליכי מכירה וקשרי לקוחות", "com_agents_category_tab_label": "{{category}} קטגוריות {{position}} מתוך {{total}}", "com_agents_category_tabs_label": "קטגוריות סוכנים", + "com_agents_chat_with": "צ׳אט עם {{name}}", "com_agents_clear_search": "נקה חיפוש", "com_agents_code_interpreter": "כאשר מופעל, מאפשר לסוכן שלך למנף את ה-API של מפענח הקוד כדי להריץ את הקוד שנוצר, כולל עיבוד קבצים, בצורה מאובטחת. דורש מפתח API חוקי.", "com_agents_code_interpreter_title": "מפענח קוד API", @@ -57,7 +58,9 @@ "com_agents_error_timeout_suggestion": "אנא בדוק את חיבור האינטרנט שלך ונסה שוב", "com_agents_error_timeout_title": "זמן התפוגה של החיבור", "com_agents_error_title": "משהו השתבש", + "com_agents_file_context_description": "קבצים שהועלו כ\"הקשר\" (Context) מעובדים כטקסט כדי להשלים את ההוראות של הסוכן. אם OCR זמין, או אם הוא הוגדר עבור סוג הקובץ שהועלה, התהליך משמש לחילוץ טקסט. אידיאלי עבור מסמכים, תמונות עם טקסט, או קבצי PDF שבהם נדרש תוכן הטקסט המלא של הקובץ.", "com_agents_file_context_disabled": "יש ליצור סוכן לפני שמעלים קבצים עבור הקשר קבצים", + "com_agents_file_context_label": "קובץ הקשר", "com_agents_file_search_disabled": "יש ליצור את הסוכן לפני העלאת קבצים לחיפוש", "com_agents_file_search_info": "כאשר הסוכן מופעל הוא יקבל מידע על שמות הקבצים המפורטים להלן, כדי שהוא יוכל לאחזר את הקשר רלוונטי.", "com_agents_grid_announcement": "מציג {{count}} סוכנים מהקטגוריה {{category}}", @@ -99,6 +102,7 @@ "com_assistants_actions_disabled": "עליך ליצור סייען לפני הוספת פעולות.", "com_assistants_actions_info": "אפשר לסייען לאחזר מידע או לבצע פעולות באמצעות API", "com_assistants_add_actions": "הוסף פעולות", + "com_assistants_add_mcp_server_tools": "הוסף כלים משרתי MCP", "com_assistants_add_tools": "הוסף כלים", "com_assistants_allow_sites_you_trust": "אפשר רק אתרים שאתה סומך עליהם.", "com_assistants_append_date": "הוסף תאריך ושעה נוכחיים", @@ -402,7 +406,6 @@ "com_nav_auto_scroll": "בפתיחת צ׳אט גלול אוטומטית להודעה האחרונה", "com_nav_auto_send_prompts": "שליחת הנחיות (פרומפטים) אוטומטית", "com_nav_auto_send_text": "טקסט לשליחה אוטומטית", - "com_nav_auto_send_text_disabled": "הגדר -1 כדי להשבית", "com_nav_auto_transcribe_audio": "תמלול אוטומטי של אודיו", "com_nav_automatic_playback": "הפעלה אוטומטית של ההודעה האחרונה", "com_nav_balance": "לְאַזֵן", @@ -510,8 +513,11 @@ "com_nav_lang_spanish": "ספרדית (Español)", "com_nav_lang_swedish": "שוודית (Svenska)", "com_nav_lang_thai": "ไทย", + "com_nav_lang_tibetan": "טיבטית (བོད་སྐད་)", "com_nav_lang_traditional_chinese": "סינית מסורתית (繁體中文)", "com_nav_lang_turkish": "טורקית (Türkçe)", + "com_nav_lang_ukrainian": "אוקראינית (Українська)", + "com_nav_lang_uyghur": "אויגורית (Uyƣur tili)", "com_nav_lang_vietnamese": "וייטנאמית (Tiếng Việt)", "com_nav_language": "שפה", "com_nav_latex_parsing": "ניתוח LaTeX בהודעות (עשוי להשפיע על הביצועים)", @@ -559,11 +565,12 @@ "com_nav_text_to_speech": "טקסט לדיבור", "com_nav_theme": "ערכת נושא (בהיר/כהה)", "com_nav_theme_dark": "כהה", - "com_nav_theme_light": "אור", + "com_nav_theme_light": "בהיר", "com_nav_theme_system": "מערכת", "com_nav_tool_dialog": "כלי סייען", "com_nav_tool_dialog_agents": "כלי סוכנים", "com_nav_tool_dialog_description": "יש לשמור את האסיסטנט כדי להמשיך בבחירת הכלים.", + "com_nav_tool_dialog_mcp_server_tools": "כלי שרתי MCP", "com_nav_tool_remove": "הסר", "com_nav_tool_search": "כלי חיפוש", "com_nav_user": "משתמש", @@ -747,6 +754,7 @@ "com_ui_complete_setup": "ההגדרה הושלמה", "com_ui_concise": "תמציתי", "com_ui_configure_mcp_variables_for": "הגדרת משתנים עבור {{0}}", + "com_ui_confirm": "אישור", "com_ui_confirm_action": "אשר פעולה", "com_ui_confirm_admin_use_change": "שינוי הגדרה זו יחסום גישה למנהלים, כולל אותך. האם אתה בטוח שברצונך להמשיך?", "com_ui_confirm_change": "אשר את השינוי", @@ -811,6 +819,7 @@ "com_ui_delete_success": "נמחק בהצלחה", "com_ui_delete_tool": "מחק כלי", "com_ui_delete_tool_confirm": "האת אתה בטוח שאתה רוצה למחוק את הכלי הזה?", + "com_ui_delete_tool_save_reminder": "הכלי הוסר. שמור את הסוכן כדי להחיל את השינויים", "com_ui_deleted": "נמחק", "com_ui_deleting_file": "מוחק קובץ...", "com_ui_descending": "תיאור", @@ -824,7 +833,7 @@ "com_ui_download_backup": "הורד קודי גיבוי", "com_ui_download_backup_tooltip": "לפני שתמשיך, הורד את קודי הגיבוי שלך. תזדקק להם כדי לשחזר גישה במקרה שתאבד את מכשיר האימות שלך", "com_ui_download_error": "וזה: שגיאה בהורדת הקובץ. ייתכן שהקובץ נמחק", - "com_ui_drag_drop": "השדה חייב להכיל תוכן, הוא אינו יכול להישאר ריק", + "com_ui_drag_drop": "גרור קובץ לכאן כדי להוסיף אותו לשיחה", "com_ui_dropdown_variables": "רשימה נפתחת של משתנים", "com_ui_dropdown_variables_info": "צור תפריטי רשימה נפתחת מותאמים אישית עבור ההנחיות שלך:\n{{variable_name:option1|option2|option3}}", "com_ui_duplicate": "שכפל", @@ -870,6 +879,8 @@ "com_ui_field_max_length": "{{field}} חייב להיות קצר מ-{{length}} תווים", "com_ui_field_required": "שדה זה נדרש", "com_ui_file_size": "גודל הקובץ", + "com_ui_file_token_limit": "מגבלת טוקנים לקובץ", + "com_ui_file_token_limit_desc": "הגדר מגבלת טוקנים מקסימלית לעיבוד קבצים כדי לשלוט בעלויות ובשימוש במשאבים", "com_ui_files": "קבצים", "com_ui_filter_prompts": "סינון הנחיות (פרומפטים)", "com_ui_filter_prompts_name": "סינון הנחיות (פרומפטים) לפי שם", @@ -909,8 +920,8 @@ "com_ui_go_back": "חזור", "com_ui_go_to_conversation": "חזור לצ'אט", "com_ui_good_afternoon": "צהריים טובים", - "com_ui_good_evening": "ערב ", - "com_ui_good_morning": "ערב טוב", + "com_ui_good_evening": "ערב טוב", + "com_ui_good_morning": "בוקר טוב", "com_ui_group": "קבוצה", "com_ui_happy_birthday": "זה יום ההולדת הראשון שלי!", "com_ui_hide_image_details": "הסתר פרטי תמונה", @@ -930,6 +941,7 @@ "com_ui_import_conversation_info": "ייבא שיחות מקובץ JSON", "com_ui_import_conversation_success": "השיחות יובאו בהצלחה", "com_ui_include_shadcnui": "יש לכלול הוראות לשימוש ברכיבי ממשק המשתמש של shadcn/ui", + "com_ui_initializing": "מאתחל...", "com_ui_input": "קלט", "com_ui_instructions": "הוראות", "com_ui_key": "מפתח", @@ -949,7 +961,10 @@ "com_ui_marketplace_allow_use": "אפשר שימוש במרכז הסוכנים", "com_ui_max_tags": "המספר המקסימלי המותר על פי הערכים העדכניים הוא {{0}}.", "com_ui_mcp_authenticated_success": "{{0}} שרתי MCP אומתו בהצלחה", + "com_ui_mcp_configure_server": "הגדר את {{0}}", + "com_ui_mcp_configure_server_description": "הגדר משתנים מותאמים אישית עבור {{0}}", "com_ui_mcp_enter_var": "הזן ערך עבור {{0}}", + "com_ui_mcp_init_failed": "אתחול שרת MCP נכשל", "com_ui_mcp_initialize": "אתחול", "com_ui_mcp_initialized_success": "{{0}} שרתי MCP אותחלו בהצלחה", "com_ui_mcp_oauth_cancelled": "התחברות באמצעות OAuth בוטלה עבור {{0}}", @@ -997,6 +1012,7 @@ "com_ui_no_category": "אין קטגוריה", "com_ui_no_changes": "לא בוצע שום שינוי", "com_ui_no_individual_access": "אין גישה לסוכן זה למשתמשים או לקבוצות בודדות", + "com_ui_no_memories": "אין זיכרונות. ניתן ליצור אותם ידנית או לבקש מה-AI לזכור משהו", "com_ui_no_personalization_available": "אין אפשרויות התאמה אישית זמינות כרגע", "com_ui_no_read_access": "אין לך הרשאה לצפות בזיכרונות", "com_ui_no_results_found": "לא נמצאו תוצאות", @@ -1013,6 +1029,7 @@ "com_ui_oauth_error_missing_code": "פרמטר המצב (state) חסר. אנא נסה שוב.", "com_ui_oauth_error_missing_state": "פרמטר המצב (state) חסר. אנא נסה שוב.", "com_ui_oauth_error_title": "האימות נכשל", + "com_ui_oauth_revoke": "בטל", "com_ui_oauth_success_description": "האימות בוצע בהצלחה. חלון זה ייסגר בעוד", "com_ui_oauth_success_title": "האימות בוצע בהצלחה", "com_ui_of": "של", @@ -1083,6 +1100,7 @@ "com_ui_role_owner_desc": "בעל שליטה מלאה על הסוכן כולל שיתוף", "com_ui_role_select": "תפקיד", "com_ui_role_viewer": "צופה", + "com_ui_role_viewer_desc": "אפשר לצפות ולהשתמש בסוכן אך לא לשנות אותו", "com_ui_roleplay": "משחק תפקידים", "com_ui_run_code": "הרץ קוד", "com_ui_run_code_error": "אירעה שגיאה בהרצת הקוד", @@ -1095,6 +1113,9 @@ "com_ui_schema": "סכמה", "com_ui_scope": "תחום", "com_ui_search": "חיפוש", + "com_ui_search_above_to_add": "חפש למעלה כדי להוסיף משתמשים או קבוצות", + "com_ui_search_above_to_add_all": "חפש למעלה כדי להוסיף משתמשים, קבוצות או תפקידים", + "com_ui_search_above_to_add_people": "חפש למעלה כדי להוסיף אנשים", "com_ui_search_agent_category": "חיפוש קטגוריות...", "com_ui_search_default_placeholder": "חיפוש לפי שם או דוא\"ל (מינימום 2 תווים)", "com_ui_search_people_placeholder": "חיפוש אנשים או קבוצות לפי שם או דוא\"ל", @@ -1150,6 +1171,8 @@ "com_ui_support_contact_email": "דוא\"ל", "com_ui_support_contact_email_invalid": "אנא הזן כתובת דוא\"ל חוקית", "com_ui_support_contact_name": "שם", + "com_ui_support_contact_name_min_length": "השם חייב להכיל לפחות {{minLength}} תווים", + "com_ui_support_contact_name_placeholder": "שם איש קשר לתמיכה", "com_ui_teach_or_explain": "למידה", "com_ui_temporary": "צ'אט זמני", "com_ui_terms_and_conditions": "תנאים והגבלות", @@ -1167,8 +1190,10 @@ "com_ui_travel": "מסע", "com_ui_trust_app": "אני סומך על האפליקציה הזו", "com_ui_try_adjusting_search": "נסה להתאים את מונחי החיפוש שלך", + "com_ui_ui_resources": "רכיבי UI", "com_ui_unarchive": "הוצא מהארכיון", "com_ui_unarchive_error": "הוצאת השיחה מהארכיון נכשלה", + "com_ui_unavailable": "לא זמין", "com_ui_unknown": "לא ידוע", "com_ui_unset": "בטל הגדרה", "com_ui_untitled": "ללא כותר", @@ -1210,13 +1235,16 @@ "com_ui_web_search_cohere_key": "הכנס מפתח API של Cohere", "com_ui_web_search_firecrawl_url": "כתובת URL של ממשק ה-API של Firecrawl (אופציונלי)", "com_ui_web_search_jina_key": "הזן את מפתח ה-API של Jina", + "com_ui_web_search_jina_url": "כתובת URL של מפתח API של Jina (אופציונלי)", "com_ui_web_search_processing": "עיבוד התוצאות", "com_ui_web_search_provider": "ספק החיפוש", "com_ui_web_search_provider_serper": "ממשק ה-API של Serper", "com_ui_web_search_provider_serper_key": "קבל מפתח API של Serper ", "com_ui_web_search_reading": "קריאת התוצאות", + "com_ui_web_search_reranker": "למד עוד על דירוג מחדש באמצעות Jina", "com_ui_web_search_reranker_cohere_key": "קבל מפתח API של Cohere", "com_ui_web_search_reranker_jina_key": "קבל מפתח API של Jina", + "com_ui_web_search_reranker_jina_url_help": "למד עוד על דירוג מחדש באמצעות Jina", "com_ui_web_search_scraper_firecrawl_key": "קבל מפתח API של Firecrawl", "com_ui_web_searching": "חיפוש ברשת", "com_ui_web_searching_again": "חיפוש נוסף ברשת", diff --git a/client/src/locales/hu/translation.json b/client/src/locales/hu/translation.json index 78f4fdf08a..2293e657a2 100644 --- a/client/src/locales/hu/translation.json +++ b/client/src/locales/hu/translation.json @@ -291,7 +291,6 @@ "com_nav_auto_scroll": "Automatikus görgetés a legutóbbi üzenetre a csevegés megnyitásakor", "com_nav_auto_send_prompts": "Promptok automatikus küldése", "com_nav_auto_send_text": "Szöveg automatikus küldése", - "com_nav_auto_send_text_disabled": "-1 beállítása a letiltáshoz", "com_nav_auto_transcribe_audio": "Audió automatikus átírása", "com_nav_automatic_playback": "Legutóbbi üzenet automatikus lejátszása", "com_nav_balance": "Egyenleg", diff --git a/client/src/locales/it/translation.json b/client/src/locales/it/translation.json index b25c19cff2..5f4181a07c 100644 --- a/client/src/locales/it/translation.json +++ b/client/src/locales/it/translation.json @@ -356,7 +356,6 @@ "com_nav_auto_scroll": "Scorri automaticamente ai nuovi messaggi all'apertura", "com_nav_auto_send_prompts": "Invio automatico dei prompt", "com_nav_auto_send_text": "Invio automatico del testo", - "com_nav_auto_send_text_disabled": "imposta -1 per disabilitare", "com_nav_auto_transcribe_audio": "Trascrivi audio automaticamente", "com_nav_automatic_playback": "Riproduzione automatica ultimo messaggio", "com_nav_balance": "Bilancio", diff --git a/client/src/locales/ja/translation.json b/client/src/locales/ja/translation.json index ea83224d15..33f0d7d768 100644 --- a/client/src/locales/ja/translation.json +++ b/client/src/locales/ja/translation.json @@ -325,7 +325,6 @@ "com_nav_auto_scroll": "チャットを開いたときに最新まで自動でスクロール", "com_nav_auto_send_prompts": "プロンプト自動送信", "com_nav_auto_send_text": "テキストを自動送信", - "com_nav_auto_send_text_disabled": "無効にするには-1を設定", "com_nav_auto_transcribe_audio": "オーディオを自動で書き起こす", "com_nav_automatic_playback": "最新メッセージを自動再生", "com_nav_balance": "バランス", diff --git a/client/src/locales/ko/translation.json b/client/src/locales/ko/translation.json index f143066c92..d99a4d09f6 100644 --- a/client/src/locales/ko/translation.json +++ b/client/src/locales/ko/translation.json @@ -318,7 +318,6 @@ "com_nav_auto_scroll": "채팅 열렸을 때 최신 메시지로 자동 스크롤", "com_nav_auto_send_prompts": "프롬프트 자동 전송", "com_nav_auto_send_text": "자동 메시지 전송", - "com_nav_auto_send_text_disabled": "자동 전송 비활성화는 -1로 설정", "com_nav_auto_transcribe_audio": "오디오 자동 변환", "com_nav_automatic_playback": "최신 메시지 자동 재생", "com_nav_balance": "잔고", diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index 22aaeae987..ac3fe25876 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -1,6 +1,6 @@ { - "chat_direction_left_to_right": "Nav rezultātu", - "chat_direction_right_to_left": "Nav rezultātu", + "chat_direction_left_to_right": "No kreisās uz labo", + "chat_direction_right_to_left": "No labās uz kreiso", "com_a11y_ai_composing": "Mākslīgais intelekts joprojām veido.", "com_a11y_end": "Mākslīgais intelekts ir pabeidzis atbildi.", "com_a11y_start": "Mākslīgais intelekts ir sācis savu atbildi.", @@ -365,6 +365,7 @@ "com_error_files_process": "Apstrādājot failu, radās kļūda.", "com_error_files_upload": "Augšupielādējot failu, radās kļūda.", "com_error_files_upload_canceled": "Faila augšupielādes pieprasījums tika atcelts. Piezīme. Iespējams, ka faila augšupielāde joprojām tiek apstrādāta, un tā būs manuāli jādzēš.", + "com_error_files_upload_too_large": "Faili ir pārāk lieli. Lūdzu, augšupielādējiet failu, kas ir mazāks par {{0}} MB", "com_error_files_validation": "Validējot failu, radās kļūda.", "com_error_google_tool_conflict": "Iebūvēto Google rīku lietošana netiek atbalstīta ar ārējiem rīkiem. Lūdzu, atspējojiet vai nu iebūvētos rīkus, vai ārējos rīkus.", "com_error_heic_conversion": "Neizdevās konvertēt HEIC attēlu uz JPEG. Lūdzu, mēģiniet konvertēt attēlu manuāli vai izmantojiet citu formātu.", @@ -408,7 +409,6 @@ "com_nav_auto_scroll": "Automātiski iet uz jaunāko ziņu, atverot sarunu", "com_nav_auto_send_prompts": "Automātiski sūtīt uzvednes", "com_nav_auto_send_text": "Automātiski nosūtīt tekstu", - "com_nav_auto_send_text_disabled": "iestatiet -1, lai atspējotu", "com_nav_auto_transcribe_audio": "Automātiski transkribēt audio", "com_nav_automatic_playback": "Automātiski atskaņot jaunāko ziņu", "com_nav_balance": "Bilance", @@ -561,6 +561,7 @@ "com_nav_setting_balance": "Bilance", "com_nav_setting_chat": "Saruna", "com_nav_setting_data": "Datu kontrole", + "com_nav_setting_delay": "Aizkave (-es)", "com_nav_setting_general": "Vispārīgi", "com_nav_setting_mcp": "MCP iestatījumi", "com_nav_setting_personalization": "Personalizācija", @@ -760,6 +761,7 @@ "com_ui_client_secret": "Klienta noslēpums", "com_ui_close": "Aizvērt", "com_ui_close_menu": "Aizvērt izvēlni", + "com_ui_close_settings": "Aizvērt iestatījumus", "com_ui_close_window": "Aizvērt logu", "com_ui_code": "Kods", "com_ui_collapse_chat": "Sakļaut sarunas logu", @@ -858,6 +860,7 @@ "com_ui_edit_editing_image": "Attēla rediģēšana", "com_ui_edit_mcp_server": "Rediģēt MCP serveri", "com_ui_edit_memory": "Rediģēt atmiņu", + "com_ui_editor_instructions": "Velciet attēlu, lai mainītu tā atrašanās vietu - Izmantojiet tālummaiņas slīdni vai pogas, lai pielāgotu izmēru.", "com_ui_empty_category": "-", "com_ui_endpoint": "Galapunkts", "com_ui_endpoint_menu": "LLM galapunkta izvēlne", @@ -894,6 +897,7 @@ "com_ui_feedback_tag_zero": "Cita problēma", "com_ui_field_max_length": "{{field}} jābūt mazākam par {{length}} rakstzīmēm", "com_ui_field_required": "Šis lauks ir obligāts", + "com_ui_file_input_avatar_label": "Faila ievade avatāram", "com_ui_file_size": "Faila lielums", "com_ui_file_token_limit": "Failu tokenu ierobežojums", "com_ui_file_token_limit_desc": "Iestatiet maksimālo tokenu ierobežojumu failu apstrādei, lai kontrolētu izmaksas un resursu izmantošanu", @@ -956,11 +960,13 @@ "com_ui_import_conversation_file_type_error": "Neatbalstīts importēšanas veids", "com_ui_import_conversation_info": "Sarunu importēšana no JSON faila", "com_ui_import_conversation_success": "Sarunas ir veiksmīgi importētas", + "com_ui_import_conversation_upload_error": "Kļūda augšupielādējot failu. Lūdzu, mēģiniet vēlreiz.", "com_ui_include_shadcnui": "Iekļaujiet shadcn/ui komponentu instrukcijas", "com_ui_initializing": "Inicializē...", "com_ui_input": "Ievade", "com_ui_instructions": "Instrukcijas", "com_ui_key": "Atslēga", + "com_ui_key_required": "Nepieciešama API atslēga", "com_ui_late_night": "Priecīgu vēlu nakti", "com_ui_latest_footer": "Mākslīgais intelekts ikvienam.", "com_ui_latest_production_version": "Jaunākā produkcijas versija", @@ -975,6 +981,7 @@ "com_ui_manage": "Pārvaldīt", "com_ui_marketplace": "Katalogs", "com_ui_marketplace_allow_use": "Atļaut izmantot katalogu", + "com_ui_max_file_size": "PNG, JPG vai JPEG (maks. {{0}})", "com_ui_max_tags": "Maksimālais atļautais skaits ir {{0}}, izmantojot jaunākās vērtības.", "com_ui_mcp_authenticated_success": "MCP serveris '{{0}}' veiksmīgi autentificēts", "com_ui_mcp_configure_server": "Konfigurēt {{0}}", @@ -1069,6 +1076,7 @@ "com_ui_privacy_policy": "Privātuma politika", "com_ui_privacy_policy_url": "Privātuma politika web adrese", "com_ui_prompt": "Uzvedne", + "com_ui_prompt_groups": "Uzvedņu grupu saraksts", "com_ui_prompt_name": "Uzvednes nosaukums", "com_ui_prompt_name_required": "Uzvednes nosaukums ir obligāts", "com_ui_prompt_preview_not_shared": "Autors nav atļāvis sadarbību šajā uzvednē.", @@ -1098,6 +1106,8 @@ "com_ui_rename_failed": "Neizdevās pārdēvēt sarunu", "com_ui_rename_prompt": "Pārdēvēt uzvedni", "com_ui_requires_auth": "Nepieciešama autentifikācija", + "com_ui_reset": "Attiestatīt", + "com_ui_reset_adjustments": "Atiestatīt korekcijas", "com_ui_reset_var": "Atiestatīt {{0}}", "com_ui_reset_zoom": "Atiestatīt tālummaiņu", "com_ui_resource": "resurss", @@ -1106,6 +1116,8 @@ "com_ui_revoke_info": "Atcelt visus lietotāja sniegtos lietotāja datus", "com_ui_revoke_key_confirm": "Vai tiešām vēlaties atsaukt šo atslēgu?", "com_ui_revoke_key_endpoint": "Atsaukt atslēgu priekš {{0}}", + "com_ui_revoke_key_error": "Neizdevās atsaukt API atslēgu. Lūdzu, mēģiniet vēlreiz.", + "com_ui_revoke_key_success": "API atslēga veiksmīgi atsaukta", "com_ui_revoke_keys": "Atsaukt atslēgas", "com_ui_revoke_keys_confirm": "Vai tiešām vēlaties atsaukt visas atslēgas?", "com_ui_role": "Loma", @@ -1119,11 +1131,15 @@ "com_ui_role_viewer": "Skatītājs", "com_ui_role_viewer_desc": "Var skatīt un izmantot aģentu, bet nevar to rediģēt", "com_ui_roleplay": "Lomu spēle", + "com_ui_rotate": "Pagriezt", + "com_ui_rotate_90": "Pagriezt par 90 grādiem", "com_ui_run_code": "Palaist kodu", "com_ui_run_code_error": "Radās kļūda, izpildot kodu", "com_ui_save": "Saglabāt", "com_ui_save_badge_changes": "Vai saglabāt emblēmas izmaiņas?", "com_ui_save_changes": "Saglabāt izmaiņas", + "com_ui_save_key_error": "Neizdevās saglabāt API atslēgu. Lūdzu, mēģiniet vēlreiz.", + "com_ui_save_key_success": "API atslēga veiksmīgi saglabāta", "com_ui_save_submit": "Saglabāt un nosūtīt", "com_ui_saved": "Saglabāts!", "com_ui_saving": "Saglabā...", @@ -1220,6 +1236,7 @@ "com_ui_update_mcp_success": "Veiksmīgi izveidots vai atjaunināts MCP", "com_ui_upload": "Augšupielādēt", "com_ui_upload_agent_avatar": "Aģenta avatars veiksmīgi atjaunināts", + "com_ui_upload_avatar_label": "Augšupielādēt avatāra attēlu", "com_ui_upload_code_files": "Augšupielādēt failu koda interpretētājam", "com_ui_upload_delay": "Augšupielāde \"{{0}}\" aizņem vairāk laika nekā paredzēts. Lūdzu, uzgaidiet, kamēr faila indeksēšana ir pabeigta izguvei.", "com_ui_upload_error": "Augšupielādējot failu, radās kļūda.", @@ -1231,6 +1248,7 @@ "com_ui_upload_invalid": "Nederīgs augšupielādējamais fails. Attēlam jābūt tādam, kas nepārsniedz ierobežojumu.", "com_ui_upload_invalid_var": "Nederīgs augšupielādējams fails. Attēlam jābūt ne lielākam par {{0}} MB", "com_ui_upload_ocr_text": "Augšupielādēt failu kā tekstu", + "com_ui_upload_provider": "Augšupielādēt pakalpojumu sniedzējam", "com_ui_upload_success": "Fails veiksmīgi augšupielādēts", "com_ui_upload_type": "Izvēlieties augšupielādes veidu", "com_ui_usage": "Izmantošana", @@ -1269,6 +1287,8 @@ "com_ui_web_search_scraper": "Scraper", "com_ui_web_search_scraper_firecrawl": "Firecrawl API", "com_ui_web_search_scraper_firecrawl_key": "Iegūstiet savu Firecrawl API atslēgu", + "com_ui_web_search_scraper_serper": "Serper Scrape API", + "com_ui_web_search_scraper_serper_key": "Iegūst savu Serper API atslēgu", "com_ui_web_search_searxng_api_key": "Ievadiet SearXNG API atslēgu (pēc izvēles)", "com_ui_web_search_searxng_instance_url": "SearXNG Instance URL", "com_ui_web_searching": "Meklēšana tīmeklī", @@ -1278,5 +1298,8 @@ "com_ui_x_selected": "{{0}} atlasīts", "com_ui_yes": "Jā", "com_ui_zoom": "Tālummaiņa", + "com_ui_zoom_in": "Pietuvināt", + "com_ui_zoom_level": "Pietuvināšanas līmenis", + "com_ui_zoom_out": "Attālināt", "com_user_message": "Tu" } diff --git a/client/src/locales/nb/translation.json b/client/src/locales/nb/translation.json index 6dbbf60e2b..c34347a2df 100644 --- a/client/src/locales/nb/translation.json +++ b/client/src/locales/nb/translation.json @@ -405,7 +405,6 @@ "com_nav_auto_scroll": "Rull automatisk til siste melding når samtalen åpnes", "com_nav_auto_send_prompts": "Send prompter automatisk", "com_nav_auto_send_text": "Send tekst automatisk", - "com_nav_auto_send_text_disabled": "sett -1 for å deaktivere", "com_nav_auto_transcribe_audio": "Transkriber lyd automatisk", "com_nav_automatic_playback": "Spill av siste melding automatisk", "com_nav_balance": "Saldo", diff --git a/client/src/locales/pl/translation.json b/client/src/locales/pl/translation.json index 43ae7d9d3c..8c69ea9cab 100644 --- a/client/src/locales/pl/translation.json +++ b/client/src/locales/pl/translation.json @@ -266,7 +266,6 @@ "com_nav_auto_scroll": "Automatyczne przewijanie do najnowszej wiadomości przy otwarciu czatu", "com_nav_auto_send_prompts": "Automatycznie wysyłaj prompty", "com_nav_auto_send_text": "Automatycznie wysyłaj tekst", - "com_nav_auto_send_text_disabled": "ustaw -1 aby wyłączyć", "com_nav_auto_transcribe_audio": "Automatycznie transkrybuj audio", "com_nav_automatic_playback": "Automatyczne odtwarzanie najnowszej wiadomości", "com_nav_balance": "Balansować", diff --git a/client/src/locales/pt-BR/translation.json b/client/src/locales/pt-BR/translation.json index 0e004d19e1..e7968fd719 100644 --- a/client/src/locales/pt-BR/translation.json +++ b/client/src/locales/pt-BR/translation.json @@ -381,7 +381,6 @@ "com_nav_auto_scroll": "Rolagem Automática para a última mensagem ao abrir o chat", "com_nav_auto_send_prompts": "Enviar prompts automaticamente", "com_nav_auto_send_text": "Enviar texto automaticamente", - "com_nav_auto_send_text_disabled": "definir -1 para desativar", "com_nav_auto_transcribe_audio": "Transcrever áudio automaticamente", "com_nav_automatic_playback": "Reprodução Automática da Última Mensagem", "com_nav_balance": "Crédito", diff --git a/client/src/locales/pt-PT/translation.json b/client/src/locales/pt-PT/translation.json index c71240e46c..eebebdd270 100644 --- a/client/src/locales/pt-PT/translation.json +++ b/client/src/locales/pt-PT/translation.json @@ -378,7 +378,6 @@ "com_nav_auto_scroll": "Rolagem Automática para a última mensagem ao abrir o chat", "com_nav_auto_send_prompts": "Enviar prompts automaticamente", "com_nav_auto_send_text": "Enviar texto automaticamente", - "com_nav_auto_send_text_disabled": "definir -1 para desativar", "com_nav_auto_transcribe_audio": "Transcrever áudio automaticamente", "com_nav_automatic_playback": "Reprodução Automática da Última Mensagem", "com_nav_balance": "Equilíbrio", diff --git a/client/src/locales/ru/translation.json b/client/src/locales/ru/translation.json index 86f73dec4c..4025d4a95c 100644 --- a/client/src/locales/ru/translation.json +++ b/client/src/locales/ru/translation.json @@ -4,7 +4,6 @@ "com_a11y_ai_composing": "ИИ продолжает составлять ответ", "com_a11y_end": "ИИ закончил свой ответ", "com_a11y_start": "ИИ начал отвечать", - "com_agents_agent_card_label": "{{name}} агент. {{description}}", "com_agents_all": "Все агенты", "com_agents_all_category": "Все", "com_agents_all_description": "Посмотреть всех общих агентов по всем категориям", @@ -407,7 +406,6 @@ "com_nav_auto_scroll": "Автоматически проматывать к самым новым сообщениям при открытии", "com_nav_auto_send_prompts": "Автоотправка промптов", "com_nav_auto_send_text": "Автоотправка сообщений", - "com_nav_auto_send_text_disabled": "установите -1 для отключения", "com_nav_auto_transcribe_audio": "Автоматическая транскрипция", "com_nav_automatic_playback": "Автовоспроизведение последнего сообщения", "com_nav_balance": "Баланс", @@ -1025,6 +1023,7 @@ "com_ui_no_changes": "Изменения не вносились", "com_ui_no_data": "здесь должно быть что-то. было пусто", "com_ui_no_individual_access": "Ни один отдельный пользователь или группа не имеют доступа к этому агенту.", + "com_ui_no_memories": "Нет воспоминаний. Создайте их вручную или попросите ИИ что-нибудь запомнить.", "com_ui_no_personalization_available": "В настоящее время нет доступных опций персонализации.", "com_ui_no_read_access": "У вас нет разрешения на просмотр воспоминаний", "com_ui_no_results_found": "Результаты не найдены", @@ -1254,7 +1253,6 @@ "com_ui_web_search_provider_serper": "Serper API", "com_ui_web_search_provider_serper_key": "Получите свой ключ API Serper", "com_ui_web_search_reading": "Результаты чтения", - "com_ui_web_search_reranker_cohere": "Cohere", "com_ui_web_search_reranker_cohere_key": "Получите свой ключ API Cohere", "com_ui_web_search_reranker_jina": "Jina AI", "com_ui_web_search_reranker_jina_key": "Получите свой ключ API Jina", diff --git a/client/src/locales/sv/translation.json b/client/src/locales/sv/translation.json index ec31e5b793..abb227704b 100644 --- a/client/src/locales/sv/translation.json +++ b/client/src/locales/sv/translation.json @@ -542,21 +542,5 @@ "com_ui_terms_and_conditions": "Villkor för användning", "com_ui_unarchive": "Avarkivera", "com_ui_unarchive_error": "Kunde inte avarkivera chatt", - "com_ui_upload_success": "Uppladdningen av filen lyckades", - "com_ui_web_search_reranker": "Reranker", - "com_ui_web_search_reranker_cohere": "Cohere", - "com_ui_web_search_reranker_cohere_key": "Hämta din Cohere API-nyckel", - "com_ui_web_search_reranker_jina": "Jina AI", - "com_ui_web_search_reranker_jina_key": "Hämta din Jina API-nyckel", - "com_ui_web_search_scraper": "Skrapare", - "com_ui_web_search_scraper_firecrawl": "Firecrawl API", - "com_ui_web_search_scraper_firecrawl_key": "Hämta din Firecrawl API-nyckel", - "com_ui_web_search_searxng_api_key": "Ange SearXNG API-nyckel (valfritt)", - "com_ui_web_searching_again": "Söker på webben igen", - "com_ui_weekend_morning": "Trevlig helg", - "com_ui_write": "Skrift", - "com_ui_x_selected": "{{0}} utvalda", - "com_ui_yes": "Ja", - "com_ui_zoom": "Zoom", - "com_user_message": "Du" + "com_ui_upload_success": "Uppladdningen av filen lyckades" } diff --git a/client/src/locales/th/translation.json b/client/src/locales/th/translation.json index 24b28e1ac6..a5afdb1283 100644 --- a/client/src/locales/th/translation.json +++ b/client/src/locales/th/translation.json @@ -301,7 +301,6 @@ "com_nav_auto_scroll": "เลื่อนอัตโนมัติไปที่ข้อความล่าสุดเมื่อเปิดแชท", "com_nav_auto_send_prompts": "ส่งพรอมต์อัตโนมัติ", "com_nav_auto_send_text": "ส่งข้อความอัตโนมัติ", - "com_nav_auto_send_text_disabled": "ตั้งค่าเป็น -1 เพื่อปิดใช้งาน", "com_nav_auto_transcribe_audio": "ถอดเสียงอัตโนมัติ", "com_nav_automatic_playback": "เล่นข้อความล่าสุดอัตโนมัติ", "com_nav_balance": "ยอดคงเหลือ", diff --git a/client/src/locales/tr/translation.json b/client/src/locales/tr/translation.json index 6e1fb5653a..8cdf3c6d0b 100644 --- a/client/src/locales/tr/translation.json +++ b/client/src/locales/tr/translation.json @@ -268,7 +268,6 @@ "com_nav_auto_scroll": "Sohbet açıldığında otomatik olarak son mesaja kaydır", "com_nav_auto_send_prompts": "İstemleri Otomatik Gönder", "com_nav_auto_send_text": "Metni otomatik gönder (3 sn sonra)", - "com_nav_auto_send_text_disabled": "devre dışı bırakmak için -1 ayarlayın", "com_nav_auto_transcribe_audio": "Sesi otomatik olarak yazıya dök", "com_nav_automatic_playback": "Son Mesajı Otomatik Çal (yalnızca dış)", "com_nav_balance": "Denge", diff --git a/client/src/locales/uk/translation.json b/client/src/locales/uk/translation.json index 6f9a95a6f4..c786ebc7ef 100644 --- a/client/src/locales/uk/translation.json +++ b/client/src/locales/uk/translation.json @@ -405,7 +405,6 @@ "com_nav_auto_scroll": "Автоматично прокручувати до найновіших повідомлень при відкритті", "com_nav_auto_send_prompts": "Автовідправка підказок", "com_nav_auto_send_text": "Автовідправка повідомлень", - "com_nav_auto_send_text_disabled": "встановіть -1 для вимкнення", "com_nav_auto_transcribe_audio": "Автоматична транскрипція", "com_nav_automatic_playback": "Автовідтворення останнього повідомлення", "com_nav_balance": "Баланс", diff --git a/client/src/locales/zh-Hans/translation.json b/client/src/locales/zh-Hans/translation.json index 0385cb6ea1..736caa7dba 100644 --- a/client/src/locales/zh-Hans/translation.json +++ b/client/src/locales/zh-Hans/translation.json @@ -408,7 +408,6 @@ "com_nav_auto_scroll": "打开对话时自动滚动到最新消息", "com_nav_auto_send_prompts": "自动发送提示词", "com_nav_auto_send_text": "自动发送文本", - "com_nav_auto_send_text_disabled": "设置为 -1 以禁用", "com_nav_auto_transcribe_audio": "自动转录音频", "com_nav_automatic_playback": "自动播放最新消息", "com_nav_balance": "余额", diff --git a/client/src/locales/zh-Hant/translation.json b/client/src/locales/zh-Hant/translation.json index 995f88a313..2de11c381e 100644 --- a/client/src/locales/zh-Hant/translation.json +++ b/client/src/locales/zh-Hant/translation.json @@ -335,7 +335,6 @@ "com_nav_auto_scroll": "開啟時自動捲動至最新內容", "com_nav_auto_send_prompts": "自動傳送提示", "com_nav_auto_send_text": "自動傳送訊息", - "com_nav_auto_send_text_disabled": "設定為 -1 以停用", "com_nav_auto_transcribe_audio": "自動轉錄語音", "com_nav_automatic_playback": "自動播放最新訊息", "com_nav_balance": "餘額", From 7792fcee17dc8a2b0483d4882f89f8b8cf3e74a3 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 10 Oct 2025 04:43:56 -0700 Subject: [PATCH 024/272] =?UTF-8?q?=F0=9F=91=86=F0=9F=8F=BC=20fix:=20Agent?= =?UTF-8?q?=20Support=20for=20Upload=20to=20Provider=20in=20DragDropModal?= =?UTF-8?q?=20(#10063)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Chat/Input/Files/DragDropModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index 209972e4a8..4b828d201d 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -56,7 +56,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD const currentProvider = provider || endpoint; // Check if provider supports document upload - if (isDocumentSupportedProvider(endpointType || currentProvider)) { + if (isDocumentSupportedProvider(currentProvider || endpointType)) { _options.push({ label: localize('com_ui_upload_provider'), value: undefined, From ded3f2e99892e6f7b2f55a3a12b7a9dc0d90265f Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 10 Oct 2025 04:48:31 -0700 Subject: [PATCH 025/272] =?UTF-8?q?=F0=9F=95=B8=EF=B8=8F=20fix:=20Upload?= =?UTF-8?q?=20to=20Provider=20Filetype=20Filtering=20for=20DragDropModal?= =?UTF-8?q?=20(#10064)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Chat/Input/Files/DragDropModal.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index 4b828d201d..d9003de3dc 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil'; import { OGDialog, OGDialogTemplate } from '@librechat/client'; import { EToolResources, + EModelEndpoint, defaultAgentCapabilities, isDocumentSupportedProvider, } from 'librechat-data-provider'; @@ -57,11 +58,22 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD // Check if provider supports document upload if (isDocumentSupportedProvider(currentProvider || endpointType)) { + const isGoogleProvider = currentProvider === EModelEndpoint.google; + const validFileTypes = isGoogleProvider + ? files.every( + (file) => + file.type?.startsWith('image/') || + file.type?.startsWith('video/') || + file.type?.startsWith('audio/') || + file.type === 'application/pdf', + ) + : files.every((file) => file.type?.startsWith('image/') || file.type === 'application/pdf'); + _options.push({ label: localize('com_ui_upload_provider'), value: undefined, icon: , - condition: true, // Allow for both images and documents + condition: validFileTypes, }); } else { // Only show image upload option if all files are images and provider doesn't support documents From 5566cc499ef3e6f34d2e329a4069ed07f4ec848e Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 10 Oct 2025 14:42:05 +0200 Subject: [PATCH 026/272] =?UTF-8?q?=F0=9F=94=97=20fix:=20Add=20branch-spec?= =?UTF-8?q?ific=20shared=20links=20(targetMessageId)=20(#10016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Enhance shared link functionality with target message support * refactor: Remove comment on compound index in share schema * chore: Reorganize imports in ShareButton component for clarity * refactor: Integrate Recoil for latest message tracking in ShareButton component --------- Co-authored-by: Danny Avila --- api/server/routes/share.js | 3 +- .../ConvoOptions/ShareButton.tsx | 6 +- .../ConvoOptions/SharedLinkButton.tsx | 4 +- client/src/data-provider/mutations.ts | 16 +++- packages/data-provider/src/data-service.ts | 7 +- packages/data-schemas/src/methods/share.ts | 96 ++++++++++++++++++- packages/data-schemas/src/schema/share.ts | 8 ++ packages/data-schemas/src/types/share.ts | 1 + 8 files changed, 129 insertions(+), 12 deletions(-) diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 14c25271fc..6400b8b637 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -99,7 +99,8 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { router.post('/:conversationId', requireJwtAuth, async (req, res) => { try { - const created = await createSharedLink(req.user.id, req.params.conversationId); + const { targetMessageId } = req.body; + const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId); if (created) { res.status(200).json(created); } else { diff --git a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx index 177dd5ae5f..46310268f0 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; import { QRCodeSVG } from 'qrcode.react'; import { Copy, CopyCheck } from 'lucide-react'; import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query'; @@ -6,6 +7,7 @@ import { OGDialogTemplate, Button, Spinner, OGDialog } from '@librechat/client'; import { useLocalize, useCopyToClipboard } from '~/hooks'; import SharedLinkButton from './SharedLinkButton'; import { cn } from '~/utils'; +import store from '~/store'; export default function ShareButton({ conversationId, @@ -24,8 +26,9 @@ export default function ShareButton({ const [showQR, setShowQR] = useState(false); const [sharedLink, setSharedLink] = useState(''); const [isCopying, setIsCopying] = useState(false); - const { data: share, isLoading } = useGetSharedLinkQuery(conversationId); const copyLink = useCopyToClipboard({ text: sharedLink }); + const latestMessage = useRecoilValue(store.latestMessageFamily(0)); + const { data: share, isLoading } = useGetSharedLinkQuery(conversationId); useEffect(() => { if (share?.shareId !== undefined) { @@ -39,6 +42,7 @@ export default function ShareButton({ >; showQR: boolean; setShowQR: (showQR: boolean) => void; @@ -86,7 +88,7 @@ export default function SharedLinkButton({ }; const createShareLink = async () => { - const share = await mutate({ conversationId }); + const share = await mutate({ conversationId, targetMessageId }); const newLink = generateShareLink(share.shareId); setSharedLink(newLink); }; diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 21fb765fb1..7abea71187 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -168,18 +168,26 @@ export const useArchiveConvoMutation = ( }; export const useCreateSharedLinkMutation = ( - options?: t.MutationOptions, -): UseMutationResult => { + options?: t.MutationOptions< + t.TCreateShareLinkRequest, + { conversationId: string; targetMessageId?: string } + >, +): UseMutationResult< + t.TSharedLinkResponse, + unknown, + { conversationId: string; targetMessageId?: string }, + unknown +> => { const queryClient = useQueryClient(); const { onSuccess, ..._options } = options || {}; return useMutation( - ({ conversationId }: { conversationId: string }) => { + ({ conversationId, targetMessageId }: { conversationId: string; targetMessageId?: string }) => { if (!conversationId) { throw new Error('Conversation ID is required'); } - return dataService.createSharedLink(conversationId); + return dataService.createSharedLink(conversationId, targetMessageId); }, { onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index c45ab4e0b4..c7d1a1c052 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -42,8 +42,11 @@ export function getSharedLink(conversationId: string): Promise { - return request.post(endpoints.createSharedLink(conversationId)); +export function createSharedLink( + conversationId: string, + targetMessageId?: string, +): Promise { + return request.post(endpoints.createSharedLink(conversationId), { targetMessageId }); } export function updateSharedLink(shareId: string): Promise { diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 8ff71fd718..11e893ff9c 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -82,6 +82,77 @@ function anonymizeMessages(messages: t.IMessage[], newConvoId: string): t.IMessa }); } +/** + * Filter messages up to and including the target message (branch-specific) + * Similar to getMessagesUpToTargetLevel from fork utilities + */ +function getMessagesUpToTarget(messages: t.IMessage[], targetMessageId: string): t.IMessage[] { + if (!messages || messages.length === 0) { + return []; + } + + // If only one message and it's the target, return it + if (messages.length === 1 && messages[0]?.messageId === targetMessageId) { + return messages; + } + + // Create a map of parentMessageId to children messages + const parentToChildrenMap = new Map(); + for (const message of messages) { + const parentId = message.parentMessageId || Constants.NO_PARENT; + if (!parentToChildrenMap.has(parentId)) { + parentToChildrenMap.set(parentId, []); + } + parentToChildrenMap.get(parentId)?.push(message); + } + + // Find the target message + const targetMessage = messages.find((msg) => msg.messageId === targetMessageId); + if (!targetMessage) { + // If target not found, return all messages for backwards compatibility + return messages; + } + + const visited = new Set(); + const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || []; + let currentLevel = rootMessages.length > 0 ? [...rootMessages] : [targetMessage]; + const results = new Set(currentLevel); + + // Check if the target message is at the root level + if ( + currentLevel.some((msg) => msg.messageId === targetMessageId) && + targetMessage.parentMessageId === Constants.NO_PARENT + ) { + return Array.from(results); + } + + // Iterate level by level until the target is found + let targetFound = false; + while (!targetFound && currentLevel.length > 0) { + const nextLevel: t.IMessage[] = []; + for (const node of currentLevel) { + if (visited.has(node.messageId)) { + continue; + } + visited.add(node.messageId); + const children = parentToChildrenMap.get(node.messageId) || []; + for (const child of children) { + if (visited.has(child.messageId)) { + continue; + } + nextLevel.push(child); + results.add(child); + if (child.messageId === targetMessageId) { + targetFound = true; + } + } + } + currentLevel = nextLevel; + } + + return Array.from(results); +} + /** Factory function that takes mongoose instance and returns the methods */ export function createShareMethods(mongoose: typeof import('mongoose')) { /** @@ -102,6 +173,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { return null; } + // Filter messages based on targetMessageId if present (branch-specific sharing) + let messagesToShare = share.messages; + if (share.targetMessageId) { + messagesToShare = getMessagesUpToTarget(share.messages, share.targetMessageId); + } + const newConvoId = anonymizeConvoId(share.conversationId); const result: t.SharedMessagesResult = { shareId: share.shareId || shareId, @@ -110,7 +187,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { createdAt: share.createdAt, updatedAt: share.updatedAt, conversationId: newConvoId, - messages: anonymizeMessages(share.messages, newConvoId), + messages: anonymizeMessages(messagesToShare, newConvoId), }; return result; @@ -239,6 +316,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { async function createSharedLink( user: string, conversationId: string, + targetMessageId?: string, ): Promise { if (!user || !conversationId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); @@ -249,7 +327,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { const Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods; const [existingShare, conversationMessages] = await Promise.all([ - SharedLink.findOne({ conversationId, user, isPublic: true }) + SharedLink.findOne({ + conversationId, + user, + isPublic: true, + ...(targetMessageId && { targetMessageId }), + }) .select('-_id -__v -user') .lean() as Promise, Message.find({ conversationId, user }).sort({ createdAt: 1 }).lean(), @@ -259,10 +342,15 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { logger.error('[createSharedLink] Share already exists', { user, conversationId, + targetMessageId, }); throw new ShareServiceError('Share already exists', 'SHARE_EXISTS'); } else if (existingShare) { - await SharedLink.deleteOne({ conversationId, user }); + await SharedLink.deleteOne({ + conversationId, + user, + ...(targetMessageId && { targetMessageId }), + }); } const conversation = (await Conversation.findOne({ conversationId, user }).lean()) as { @@ -291,6 +379,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { messages: conversationMessages, title, user, + ...(targetMessageId && { targetMessageId }), }); return { shareId, conversationId }; @@ -302,6 +391,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { error: error instanceof Error ? error.message : 'Unknown error', user, conversationId, + targetMessageId, }); throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR'); } diff --git a/packages/data-schemas/src/schema/share.ts b/packages/data-schemas/src/schema/share.ts index 62347beb56..987dd10fc2 100644 --- a/packages/data-schemas/src/schema/share.ts +++ b/packages/data-schemas/src/schema/share.ts @@ -6,6 +6,7 @@ export interface ISharedLink extends Document { user?: string; messages?: Types.ObjectId[]; shareId?: string; + targetMessageId?: string; isPublic: boolean; createdAt?: Date; updatedAt?: Date; @@ -30,6 +31,11 @@ const shareSchema: Schema = new Schema( type: String, index: true, }, + targetMessageId: { + type: String, + required: false, + index: true, + }, isPublic: { type: Boolean, default: true, @@ -38,4 +44,6 @@ const shareSchema: Schema = new Schema( { timestamps: true }, ); +shareSchema.index({ conversationId: 1, user: 1, targetMessageId: 1 }); + export default shareSchema; diff --git a/packages/data-schemas/src/types/share.ts b/packages/data-schemas/src/types/share.ts index 3db1a360c6..8b54990cf4 100644 --- a/packages/data-schemas/src/types/share.ts +++ b/packages/data-schemas/src/types/share.ts @@ -8,6 +8,7 @@ export interface ISharedLink { user?: string; messages?: Types.ObjectId[]; shareId?: string; + targetMessageId?: string; isPublic: boolean; createdAt?: Date; updatedAt?: Date; From f61afc1124afe5f8ca4b4f12da7e568bc3efa175 Mon Sep 17 00:00:00 2001 From: Karthikeyan N <84800257+KarthiDreamr@users.noreply.github.com> Date: Sat, 11 Oct 2025 17:23:28 +0530 Subject: [PATCH 027/272] =?UTF-8?q?=F0=9F=92=B8=20chore:=20Update=20Gemini?= =?UTF-8?q?=202.5=20Flash=20Lite=20Input=20Pricing=20(#10062)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update prompt value for gemini-2.5-flash-lite New Input price (text, image, video) --> $0.10 https://ai.google.dev/gemini-api/docs/pricing * Fix formatting of gemini-2.5-flash-lite values changed 0.10 to 0.1 to follow standards --- api/models/tx.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/models/tx.js b/api/models/tx.js index 282d58c8fc..462396d860 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -125,7 +125,7 @@ const tokenValues = Object.assign( 'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing 'gemini-2.5-pro': { prompt: 1.25, completion: 10 }, 'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 }, - 'gemini-2.5-flash-lite': { prompt: 0.075, completion: 0.4 }, + 'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 }, 'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time 'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 }, 'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 }, From e9a85d5c65dafc7298a4b5dd49835aca19203dca Mon Sep 17 00:00:00 2001 From: Peter Nancarrow <37298202+pnancarrow@users.noreply.github.com> Date: Sat, 11 Oct 2025 06:55:06 -0500 Subject: [PATCH 028/272] =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20feat:=20Add=20O?= =?UTF-8?q?ptional=20Group=20Field=20to=20ModelSpecs=20Configuration=20(#9?= =?UTF-8?q?996)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add group field to modelSpecs for flexible grouping * resolve lint issues * fix test * docs: enhance modelSpecs group field documentation for clarity --------- Co-authored-by: Danny Avila --- .../services/Config/loadConfigModels.js | 4 +- .../services/Config/loadConfigModels.spec.js | 4 +- .../Chat/Menus/Endpoints/ModelSelector.tsx | 16 ++++- .../Endpoints/components/CustomGroup.tsx | 66 +++++++++++++++++++ .../Endpoints/components/EndpointItem.tsx | 30 +++++++-- .../components/EndpointModelItem.tsx | 46 +++++++------ .../Chat/Menus/Endpoints/components/index.ts | 1 + librechat.example.yaml | 66 +++++++++++++++++-- packages/data-provider/src/config.ts | 13 +++- packages/data-provider/src/models.ts | 8 +++ 10 files changed, 215 insertions(+), 39 deletions(-) create mode 100644 client/src/components/Chat/Menus/Endpoints/components/CustomGroup.tsx diff --git a/api/server/services/Config/loadConfigModels.js b/api/server/services/Config/loadConfigModels.js index 9ef8994241..840d957fa1 100644 --- a/api/server/services/Config/loadConfigModels.js +++ b/api/server/services/Config/loadConfigModels.js @@ -85,7 +85,9 @@ async function loadConfigModels(req) { } if (Array.isArray(models.default)) { - modelsConfig[name] = models.default; + modelsConfig[name] = models.default.map((model) => + typeof model === 'string' ? model : model.name, + ); } } diff --git a/api/server/services/Config/loadConfigModels.spec.js b/api/server/services/Config/loadConfigModels.spec.js index b8d577667a..1e0e8780a7 100644 --- a/api/server/services/Config/loadConfigModels.spec.js +++ b/api/server/services/Config/loadConfigModels.spec.js @@ -254,8 +254,8 @@ describe('loadConfigModels', () => { // For groq and ollama, since the apiKey is "user_provided", models should not be fetched // Depending on your implementation's behavior regarding "default" models without fetching, // you may need to adjust the following assertions: - expect(result.groq).toBe(exampleConfig.endpoints.custom[2].models.default); - expect(result.ollama).toBe(exampleConfig.endpoints.custom[3].models.default); + expect(result.groq).toEqual(exampleConfig.endpoints.custom[2].models.default); + expect(result.ollama).toEqual(exampleConfig.endpoints.custom[3].models.default); // Verifying fetchModels was not called for groq and ollama expect(fetchModels).not.toHaveBeenCalledWith( diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx index 01d96432a7..d9464182b9 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx @@ -2,7 +2,12 @@ import React, { useMemo } from 'react'; import type { ModelSelectorProps } from '~/common'; import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext'; import { ModelSelectorChatProvider } from './ModelSelectorChatContext'; -import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components'; +import { + renderModelSpecs, + renderEndpoints, + renderSearchResults, + renderCustomGroups, +} from './components'; import { getSelectedIcon, getDisplayValue } from './utils'; import { CustomMenu as Menu } from './CustomMenu'; import DialogManager from './DialogManager'; @@ -86,8 +91,15 @@ function ModelSelectorContent() { renderSearchResults(searchResults, localize, searchValue) ) : ( <> - {renderModelSpecs(modelSpecs, selectedValues.modelSpec || '')} + {/* Render ungrouped modelSpecs (no group field) */} + {renderModelSpecs( + modelSpecs?.filter((spec) => !spec.group) || [], + selectedValues.modelSpec || '', + )} + {/* Render endpoints (will include grouped specs matching endpoint names) */} {renderEndpoints(mappedEndpoints ?? [])} + {/* Render custom groups (specs with group field not matching any endpoint) */} + {renderCustomGroups(modelSpecs || [], mappedEndpoints ?? [])} )} diff --git a/client/src/components/Chat/Menus/Endpoints/components/CustomGroup.tsx b/client/src/components/Chat/Menus/Endpoints/components/CustomGroup.tsx new file mode 100644 index 0000000000..80d049cce7 --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/CustomGroup.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import type { TModelSpec } from 'librechat-data-provider'; +import { CustomMenu as Menu } from '../CustomMenu'; +import { ModelSpecItem } from './ModelSpecItem'; +import { useModelSelectorContext } from '../ModelSelectorContext'; + +interface CustomGroupProps { + groupName: string; + specs: TModelSpec[]; +} + +export function CustomGroup({ groupName, specs }: CustomGroupProps) { + const { selectedValues } = useModelSelectorContext(); + const { modelSpec: selectedSpec } = selectedValues; + + if (!specs || specs.length === 0) { + return null; + } + + return ( + +
+ {groupName} +
+
+ } + > + {specs.map((spec: TModelSpec) => ( + + ))} + + ); +} + +export function renderCustomGroups( + modelSpecs: TModelSpec[], + mappedEndpoints: Array<{ value: string }>, +) { + // Get all endpoint values to exclude them from custom groups + const endpointValues = new Set(mappedEndpoints.map((ep) => ep.value)); + + // Group specs by their group field (excluding endpoint-matched groups and ungrouped) + const customGroups = modelSpecs.reduce( + (acc, spec) => { + if (!spec.group || endpointValues.has(spec.group)) { + return acc; + } + if (!acc[spec.group]) { + acc[spec.group] = []; + } + acc[spec.group].push(spec); + return acc; + }, + {} as Record, + ); + + // Render each custom group + return Object.entries(customGroups).map(([groupName, specs]) => ( + + )); +} diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx index 6541383f39..52c3fc8367 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx @@ -2,10 +2,12 @@ import { useMemo } from 'react'; import { SettingsIcon } from 'lucide-react'; import { TooltipAnchor, Spinner } from '@librechat/client'; import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; +import type { TModelSpec } from 'librechat-data-provider'; import type { Endpoint } from '~/common'; import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { renderEndpointModels } from './EndpointModelItem'; +import { ModelSpecItem } from './ModelSpecItem'; import { filterModels } from '../utils'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -57,6 +59,7 @@ export function EndpointItem({ endpoint }: EndpointItemProps) { const { agentsMap, assistantsMap, + modelSpecs, selectedValues, handleOpenKeyDialog, handleSelectEndpoint, @@ -64,7 +67,19 @@ export function EndpointItem({ endpoint }: EndpointItemProps) { setEndpointSearchValue, endpointRequiresUserKey, } = useModelSelectorContext(); - const { model: selectedModel, endpoint: selectedEndpoint } = selectedValues; + const { + model: selectedModel, + endpoint: selectedEndpoint, + modelSpec: selectedSpec, + } = selectedValues; + + // Filter modelSpecs for this endpoint (by group matching endpoint value) + const endpointSpecs = useMemo(() => { + if (!modelSpecs || !modelSpecs.length) { + return []; + } + return modelSpecs.filter((spec: TModelSpec) => spec.group === endpoint.value); + }, [modelSpecs, endpoint.value]); const searchValue = endpointSearchValues[endpoint.value] || ''; const isUserProvided = useMemo(() => endpointRequiresUserKey(endpoint.value), [endpoint.value]); @@ -138,10 +153,17 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
- ) : filteredModels ? ( - renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels) ) : ( - endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel) + <> + {/* Render modelSpecs for this endpoint */} + {endpointSpecs.map((spec: TModelSpec) => ( + + ))} + {/* Render endpoint models */} + {filteredModels + ? renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels) + : endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel)} + )} ); diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx index 6a9b6fd336..eeefdba598 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx @@ -36,38 +36,42 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod handleSelectModel(endpoint, modelId ?? '')} - className="flex h-8 w-full cursor-pointer items-center justify-start rounded-lg px-3 py-2 text-sm" + className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm" > -
+
{avatarUrl ? ( -
+
{modelName
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) && endpoint.icon ? ( -
+
{endpoint.icon}
) : null} - {modelName} + {modelName} + {isGlobal && ( + + )}
- {isGlobal && } {isSelected && ( - - - +
+ + + +
)} ); diff --git a/client/src/components/Chat/Menus/Endpoints/components/index.ts b/client/src/components/Chat/Menus/Endpoints/components/index.ts index d39ad4276f..bc08e6a8a1 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/index.ts +++ b/client/src/components/Chat/Menus/Endpoints/components/index.ts @@ -2,3 +2,4 @@ export * from './ModelSpecItem'; export * from './EndpointModelItem'; export * from './EndpointItem'; export * from './SearchResults'; +export * from './CustomGroup'; diff --git a/librechat.example.yaml b/librechat.example.yaml index 6f034910dc..04e088aa38 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -229,13 +229,11 @@ endpoints: baseURL: 'https://api.groq.com/openai/v1/' models: default: - [ - 'llama3-70b-8192', - 'llama3-8b-8192', - 'llama2-70b-4096', - 'mixtral-8x7b-32768', - 'gemma-7b-it', - ] + - 'llama3-70b-8192' + - 'llama3-8b-8192' + - 'llama2-70b-4096' + - 'mixtral-8x7b-32768' + - 'gemma-7b-it' fetch: false titleConvo: true titleModel: 'mixtral-8x7b-32768' @@ -320,6 +318,60 @@ endpoints: forcePrompt: false modelDisplayLabel: 'Portkey' iconURL: https://images.crunchbase.com/image/upload/c_pad,f_auto,q_auto:eco,dpr_1/rjqy7ghvjoiu4cd1xjbf +# Example modelSpecs configuration showing grouping options +# The 'group' field organizes model specs in the UI selector: +# - If 'group' matches an endpoint name (e.g., "openAI", "groq"), the spec appears nested under that endpoint +# - If 'group' is a custom name (doesn't match any endpoint), it creates a separate collapsible section +# - If 'group' is omitted, the spec appears as a standalone item at the top level +# modelSpecs: +# list: +# # Example 1: Nested under an endpoint (grouped with openAI endpoint) +# - name: "gpt-4o" +# label: "GPT-4 Optimized" +# description: "Most capable GPT-4 model with multimodal support" +# group: "openAI" # String value matching the endpoint name +# preset: +# endpoint: "openAI" +# model: "gpt-4o" +# +# # Example 2: Nested under a custom endpoint (grouped with groq endpoint) +# - name: "llama3-70b-8192" +# label: "Llama 3 70B" +# description: "Fastest inference available - great for quick responses" +# group: "groq" # String value matching your custom endpoint name from endpoints.custom +# preset: +# endpoint: "groq" +# model: "llama3-70b-8192" +# +# # Example 3: Custom group (creates a separate collapsible section) +# - name: "coding-assistant" +# label: "Coding Assistant" +# description: "Specialized for coding tasks" +# group: "my-assistants" # Custom string - doesn't match any endpoint, so creates its own group +# preset: +# endpoint: "openAI" +# model: "gpt-4o" +# instructions: "You are an expert coding assistant..." +# temperature: 0.3 +# +# - name: "writing-assistant" +# label: "Writing Assistant" +# description: "Specialized for creative writing" +# group: "my-assistants" # Same custom group name - both specs appear in same section +# preset: +# endpoint: "anthropic" +# model: "claude-sonnet-4" +# instructions: "You are a creative writing expert..." +# +# # Example 4: Standalone (no group - appears at top level) +# - name: "general-assistant" +# label: "General Assistant" +# description: "General purpose assistant" +# # No 'group' field - appears as standalone item at top level (not nested) +# preset: +# endpoint: "openAI" +# model: "gpt-4o-mini" + # fileConfig: # endpoints: # assistants: diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 2b78453be4..c3f872eaec 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -214,6 +214,14 @@ export const bedrockEndpointSchema = baseEndpointSchema.merge( }), ); +const modelItemSchema = z.union([ + z.string(), + z.object({ + name: z.string(), + description: z.string().optional(), + }), +]); + export const assistantEndpointSchema = baseEndpointSchema.merge( z.object({ /* assistants specific */ @@ -239,7 +247,7 @@ export const assistantEndpointSchema = baseEndpointSchema.merge( apiKey: z.string().optional(), models: z .object({ - default: z.array(z.string()).min(1), + default: z.array(modelItemSchema).min(1), fetch: z.boolean().optional(), userIdQuery: z.boolean().optional(), }) @@ -299,7 +307,7 @@ export const endpointSchema = baseEndpointSchema.merge( apiKey: z.string(), baseURL: z.string(), models: z.object({ - default: z.array(z.string()).min(1), + default: z.array(modelItemSchema).min(1), fetch: z.boolean().optional(), userIdQuery: z.boolean().optional(), }), @@ -636,6 +644,7 @@ export type TStartupConfig = { helpAndFaqURL: string; customFooter?: string; modelSpecs?: TSpecsConfig; + modelDescriptions?: Record>; sharedLinksEnabled: boolean; publicSharedLinksEnabled: boolean; analyticsGtmId?: string; diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index c925781bff..78ba1237fc 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -15,6 +15,13 @@ export type TModelSpec = { order?: number; default?: boolean; description?: string; + /** + * Optional group name for organizing specs in the UI selector. + * - If it matches an endpoint name (e.g., "openAI", "groq"), the spec appears nested under that endpoint + * - If it's a custom name (doesn't match any endpoint), it creates a separate collapsible group + * - If omitted, the spec appears as a standalone item at the top level + */ + group?: string; showIconInMenu?: boolean; showIconInHeader?: boolean; iconURL?: string | EModelEndpoint; // Allow using project-included icons @@ -28,6 +35,7 @@ export const tModelSpecSchema = z.object({ order: z.number().optional(), default: z.boolean().optional(), description: z.string().optional(), + group: z.string().optional(), showIconInMenu: z.boolean().optional(), showIconInHeader: z.boolean().optional(), iconURL: z.union([z.string(), eModelEndpointSchema]).optional(), From 7c9a868d34d7b65a39a45d8f35a66d53d73c6437 Mon Sep 17 00:00:00 2001 From: Sebastien Bruel <93573440+sbruel@users.noreply.github.com> Date: Sat, 11 Oct 2025 23:37:35 +0900 Subject: [PATCH 029/272] =?UTF-8?q?=F0=9F=93=9D=20feat:=20Add=20Markdown?= =?UTF-8?q?=20Rendering=20Support=20for=20Artifacts=20(#10049)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Markdown rendering support for artifacts * Add tests * Remove custom code for mermaid * Remove unnecessary dark mode hook * refactor: optimize mermaid dependencies - Added support for additional MIME types in artifact templates. - Updated mermaidDependencies to include new packages: class-variance-authority, clsx, tailwind-merge, and @radix-ui/react-slot. - Refactored zoom and refresh icons in MermaidDiagram component for improved clarity and maintainability. * fix: add Markdown support for artifacts rendering * feat: support 'text/md' as an additional MIME type for Markdown artifacts * refactor: simplify markdownDependencies structure in artifacts utility --------- Co-authored-by: Danny Avila --- api/app/clients/prompts/artifacts.js | 10 + .../Artifacts/ArtifactCodeEditor.tsx | 3 +- .../components/Artifacts/ArtifactPreview.tsx | 12 - .../src/components/Artifacts/ArtifactTabs.tsx | 24 +- client/src/components/Artifacts/Artifacts.tsx | 2 - .../components/Artifacts/MermaidMarkdown.tsx | 11 - .../__tests__/useArtifactProps.test.ts | 219 +++++++++++++++ .../src/hooks/Artifacts/useArtifactProps.ts | 12 +- client/src/hooks/Artifacts/useArtifacts.ts | 9 - client/src/utils/__tests__/markdown.test.ts | 185 +++++++++++++ client/src/utils/artifacts.ts | 63 ++--- client/src/utils/markdown.ts | 256 ++++++++++++++++++ client/src/utils/mermaid.ts | 42 ++- 13 files changed, 760 insertions(+), 88 deletions(-) delete mode 100644 client/src/components/Artifacts/MermaidMarkdown.tsx create mode 100644 client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts create mode 100644 client/src/utils/__tests__/markdown.test.ts create mode 100644 client/src/utils/markdown.ts diff --git a/api/app/clients/prompts/artifacts.js b/api/app/clients/prompts/artifacts.js index b907a16b56..915ccae629 100644 --- a/api/app/clients/prompts/artifacts.js +++ b/api/app/clients/prompts/artifacts.js @@ -3,6 +3,7 @@ const { EModelEndpoint, ArtifactModes } = require('librechat-data-provider'); const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate'); const { components } = require('~/app/clients/prompts/shadcn-docs/components'); +/** @deprecated */ // eslint-disable-next-line no-unused-vars const artifactsPromptV1 = dedent`The assistant can create and reference artifacts during conversations. @@ -115,6 +116,7 @@ Here are some examples of correct usage of artifacts: `; + const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations. Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity. @@ -165,6 +167,10 @@ Artifacts are for substantial, self-contained content that users might modify or - SVG: "image/svg+xml" - The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags. - The assistant should specify the viewbox of the SVG rather than defining a width/height + - Markdown: "text/markdown" or "text/md" + - The user interface will render Markdown content placed within the artifact tags. + - Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more. + - Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content. - Mermaid Diagrams: "application/vnd.mermaid" - The user interface will render Mermaid diagrams placed within the artifact tags. - React Components: "application/vnd.react" @@ -366,6 +372,10 @@ Artifacts are for substantial, self-contained content that users might modify or - SVG: "image/svg+xml" - The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags. - The assistant should specify the viewbox of the SVG rather than defining a width/height + - Markdown: "text/markdown" or "text/md" + - The user interface will render Markdown content placed within the artifact tags. + - Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more. + - Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content. - Mermaid Diagrams: "application/vnd.mermaid" - The user interface will render Mermaid diagrams placed within the artifact tags. - React Components: "application/vnd.react" diff --git a/client/src/components/Artifacts/ArtifactCodeEditor.tsx b/client/src/components/Artifacts/ArtifactCodeEditor.tsx index 62f2dc4e85..f4f25280f9 100644 --- a/client/src/components/Artifacts/ArtifactCodeEditor.tsx +++ b/client/src/components/Artifacts/ArtifactCodeEditor.tsx @@ -144,9 +144,10 @@ export const ArtifactCodeEditor = function ({ } return { ...sharedOptions, + activeFile: '/' + fileKey, bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL, }; - }, [config, template]); + }, [config, template, fileKey]); const [readOnly, setReadOnly] = useState(isSubmitting ?? false); useEffect(() => { setReadOnly(isSubmitting ?? false); diff --git a/client/src/components/Artifacts/ArtifactPreview.tsx b/client/src/components/Artifacts/ArtifactPreview.tsx index d5114ceafc..3764119f3a 100644 --- a/client/src/components/Artifacts/ArtifactPreview.tsx +++ b/client/src/components/Artifacts/ArtifactPreview.tsx @@ -13,7 +13,6 @@ export const ArtifactPreview = memo(function ({ files, fileKey, template, - isMermaid, sharedProps, previewRef, currentCode, @@ -21,7 +20,6 @@ export const ArtifactPreview = memo(function ({ }: { files: ArtifactFiles; fileKey: string; - isMermaid: boolean; template: SandpackProviderProps['template']; sharedProps: Partial; previewRef: React.MutableRefObject; @@ -56,15 +54,6 @@ export const ArtifactPreview = memo(function ({ return _options; }, [startupConfig, template]); - const style: PreviewProps['style'] | undefined = useMemo(() => { - if (isMermaid) { - return { - backgroundColor: '#282C34', - }; - } - return; - }, [isMermaid]); - if (Object.keys(artifactFiles).length === 0) { return null; } @@ -84,7 +73,6 @@ export const ArtifactPreview = memo(function ({ showRefreshButton={false} tabIndex={0} ref={previewRef} - style={style} /> ); diff --git a/client/src/components/Artifacts/ArtifactTabs.tsx b/client/src/components/Artifacts/ArtifactTabs.tsx index a463aca792..8a5b14d556 100644 --- a/client/src/components/Artifacts/ArtifactTabs.tsx +++ b/client/src/components/Artifacts/ArtifactTabs.tsx @@ -8,17 +8,14 @@ import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll'; import { ArtifactCodeEditor } from './ArtifactCodeEditor'; import { useGetStartupConfig } from '~/data-provider'; import { ArtifactPreview } from './ArtifactPreview'; -import { MermaidMarkdown } from './MermaidMarkdown'; import { cn } from '~/utils'; export default function ArtifactTabs({ artifact, - isMermaid, editorRef, previewRef, }: { artifact: Artifact; - isMermaid: boolean; editorRef: React.MutableRefObject; previewRef: React.MutableRefObject; }) { @@ -46,25 +43,20 @@ export default function ArtifactTabs({ className={cn('flex-grow overflow-auto')} tabIndex={-1} > - {isMermaid ? ( - - ) : ( - - )} + {/* Content */} } previewRef={previewRef as React.MutableRefObject} diff --git a/client/src/components/Artifacts/MermaidMarkdown.tsx b/client/src/components/Artifacts/MermaidMarkdown.tsx deleted file mode 100644 index 780b0d74da..0000000000 --- a/client/src/components/Artifacts/MermaidMarkdown.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { CodeMarkdown } from './Code'; - -export function MermaidMarkdown({ - content, - isSubmitting, -}: { - content: string; - isSubmitting: boolean; -}) { - return ; -} diff --git a/client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts b/client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts new file mode 100644 index 0000000000..f9f29e0c56 --- /dev/null +++ b/client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts @@ -0,0 +1,219 @@ +import { renderHook } from '@testing-library/react'; +import useArtifactProps from '../useArtifactProps'; +import type { Artifact } from '~/common'; + +describe('useArtifactProps', () => { + const createArtifact = (partial: Partial): Artifact => ({ + id: 'test-id', + lastUpdateTime: Date.now(), + ...partial, + }); + + describe('markdown artifacts', () => { + it('should handle text/markdown type with content.md as fileKey', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '# Hello World\n\nThis is markdown.', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.fileKey).toBe('content.md'); + expect(result.current.template).toBe('react-ts'); + }); + + it('should handle text/plain type with content.md as fileKey', () => { + const artifact = createArtifact({ + type: 'text/plain', + content: '# Plain text as markdown', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.fileKey).toBe('content.md'); + expect(result.current.template).toBe('react-ts'); + }); + + it('should include content.md in files with original markdown', () => { + const markdownContent = '# Test\n\n- Item 1\n- Item 2'; + const artifact = createArtifact({ + type: 'text/markdown', + content: markdownContent, + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.files['content.md']).toBe(markdownContent); + }); + + it('should include App.tsx with wrapped markdown renderer', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '# Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.files['App.tsx']).toContain('MarkdownRenderer'); + expect(result.current.files['App.tsx']).toContain('import React from'); + }); + + it('should include all required markdown files', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '# Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + // Check all required files are present + expect(result.current.files['content.md']).toBeDefined(); + expect(result.current.files['App.tsx']).toBeDefined(); + expect(result.current.files['index.tsx']).toBeDefined(); + expect(result.current.files['/components/ui/MarkdownRenderer.tsx']).toBeDefined(); + expect(result.current.files['markdown.css']).toBeDefined(); + }); + + it('should escape special characters in markdown content', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: 'Code: `const x = 1;`\nPath: C:\\Users', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + // Original content should be preserved in content.md + expect(result.current.files['content.md']).toContain('`const x = 1;`'); + expect(result.current.files['content.md']).toContain('C:\\Users'); + + // App.tsx should have escaped content + expect(result.current.files['App.tsx']).toContain('\\`'); + expect(result.current.files['App.tsx']).toContain('\\\\'); + }); + + it('should handle empty markdown content', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.files['content.md']).toBe('# No content provided'); + }); + + it('should handle undefined markdown content', () => { + const artifact = createArtifact({ + type: 'text/markdown', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.files['content.md']).toBe('# No content provided'); + }); + + it('should provide marked-react dependency', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '# Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.sharedProps.customSetup?.dependencies).toHaveProperty('marked-react'); + }); + + it('should update files when content changes', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '# Original', + }); + + const { result, rerender } = renderHook(({ artifact }) => useArtifactProps({ artifact }), { + initialProps: { artifact }, + }); + + expect(result.current.files['content.md']).toBe('# Original'); + + // Update the artifact content + const updatedArtifact = createArtifact({ + ...artifact, + content: '# Updated', + }); + + rerender({ artifact: updatedArtifact }); + + expect(result.current.files['content.md']).toBe('# Updated'); + }); + }); + + describe('mermaid artifacts', () => { + it('should handle mermaid type with content.md as fileKey', () => { + const artifact = createArtifact({ + type: 'application/vnd.mermaid', + content: 'graph TD\n A-->B', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.fileKey).toBe('diagram.mmd'); + expect(result.current.template).toBe('react-ts'); + }); + }); + + describe('react artifacts', () => { + it('should handle react type with App.tsx as fileKey', () => { + const artifact = createArtifact({ + type: 'application/vnd.react', + content: 'export default () =>
Test
', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.fileKey).toBe('App.tsx'); + expect(result.current.template).toBe('react-ts'); + }); + }); + + describe('html artifacts', () => { + it('should handle html type with index.html as fileKey', () => { + const artifact = createArtifact({ + type: 'text/html', + content: 'Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.fileKey).toBe('index.html'); + expect(result.current.template).toBe('static'); + }); + }); + + describe('edge cases', () => { + it('should handle artifact with language parameter', () => { + const artifact = createArtifact({ + type: 'text/markdown', + language: 'en', + content: '# Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + // Language parameter should not affect markdown handling + // It checks the type directly, not the key + expect(result.current.fileKey).toBe('content.md'); + expect(result.current.files['content.md']).toBe('# Test'); + }); + + it('should handle artifact with undefined type', () => { + const artifact = createArtifact({ + content: '# Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + // Should use default behavior + expect(result.current.template).toBe('static'); + }); + }); +}); diff --git a/client/src/hooks/Artifacts/useArtifactProps.ts b/client/src/hooks/Artifacts/useArtifactProps.ts index 6de90f5893..2b898934c4 100644 --- a/client/src/hooks/Artifacts/useArtifactProps.ts +++ b/client/src/hooks/Artifacts/useArtifactProps.ts @@ -3,11 +3,19 @@ import { removeNullishValues } from 'librechat-data-provider'; import type { Artifact } from '~/common'; import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts'; import { getMermaidFiles } from '~/utils/mermaid'; +import { getMarkdownFiles } from '~/utils/markdown'; export default function useArtifactProps({ artifact }: { artifact: Artifact }) { const [fileKey, files] = useMemo(() => { - if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) { - return ['App.tsx', getMermaidFiles(artifact.content ?? '')]; + const key = getKey(artifact.type ?? '', artifact.language); + const type = artifact.type ?? ''; + + if (key.includes('mermaid')) { + return ['diagram.mmd', getMermaidFiles(artifact.content ?? '')]; + } + + if (type === 'text/markdown' || type === 'text/md' || type === 'text/plain') { + return ['content.md', getMarkdownFiles(artifact.content ?? '')]; } const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language); diff --git a/client/src/hooks/Artifacts/useArtifacts.ts b/client/src/hooks/Artifacts/useArtifacts.ts index 55248ffa44..5eb0d4ee73 100644 --- a/client/src/hooks/Artifacts/useArtifacts.ts +++ b/client/src/hooks/Artifacts/useArtifacts.ts @@ -122,17 +122,8 @@ export default function useArtifacts() { setCurrentArtifactId(orderedArtifactIds[newIndex]); }; - const isMermaid = useMemo(() => { - if (currentArtifact?.type == null) { - return false; - } - const key = getKey(currentArtifact.type, currentArtifact.language); - return key.includes('mermaid'); - }, [currentArtifact?.type, currentArtifact?.language]); - return { activeTab, - isMermaid, setActiveTab, currentIndex, cycleArtifact, diff --git a/client/src/utils/__tests__/markdown.test.ts b/client/src/utils/__tests__/markdown.test.ts new file mode 100644 index 0000000000..fcc0f169e6 --- /dev/null +++ b/client/src/utils/__tests__/markdown.test.ts @@ -0,0 +1,185 @@ +import { getMarkdownFiles } from '../markdown'; + +describe('markdown artifacts', () => { + describe('getMarkdownFiles', () => { + it('should return content.md with the original markdown content', () => { + const markdown = '# Hello World\n\nThis is a test.'; + const files = getMarkdownFiles(markdown); + + expect(files['content.md']).toBe(markdown); + }); + + it('should return default content when markdown is empty', () => { + const files = getMarkdownFiles(''); + + expect(files['content.md']).toBe('# No content provided'); + }); + + it('should include App.tsx with MarkdownRenderer component', () => { + const markdown = '# Test'; + const files = getMarkdownFiles(markdown); + + expect(files['App.tsx']).toContain('import React from'); + expect(files['App.tsx']).toContain( + "import MarkdownRenderer from '/components/ui/MarkdownRenderer'", + ); + expect(files['App.tsx']).toContain(' { + const markdown = '# Test'; + const files = getMarkdownFiles(markdown); + + expect(files['index.tsx']).toContain('import App from "./App"'); + expect(files['index.tsx']).toContain('import "./styles.css"'); + expect(files['index.tsx']).toContain('import "./markdown.css"'); + expect(files['index.tsx']).toContain('createRoot'); + }); + + it('should include MarkdownRenderer component file', () => { + const markdown = '# Test'; + const files = getMarkdownFiles(markdown); + + expect(files['/components/ui/MarkdownRenderer.tsx']).toContain('import Markdown from'); + expect(files['/components/ui/MarkdownRenderer.tsx']).toContain('MarkdownRendererProps'); + expect(files['/components/ui/MarkdownRenderer.tsx']).toContain( + 'export default MarkdownRenderer', + ); + }); + + it('should include markdown.css with styling', () => { + const markdown = '# Test'; + const files = getMarkdownFiles(markdown); + + expect(files['markdown.css']).toContain('.markdown-body'); + expect(files['markdown.css']).toContain('list-style-type: disc'); + expect(files['markdown.css']).toContain('prefers-color-scheme: dark'); + }); + + describe('content escaping', () => { + it('should escape backticks in markdown content', () => { + const markdown = 'Here is some `inline code`'; + const files = getMarkdownFiles(markdown); + + expect(files['App.tsx']).toContain('\\`'); + }); + + it('should escape backslashes in markdown content', () => { + const markdown = 'Path: C:\\Users\\Test'; + const files = getMarkdownFiles(markdown); + + expect(files['App.tsx']).toContain('\\\\'); + }); + + it('should escape dollar signs in markdown content', () => { + const markdown = 'Price: $100'; + const files = getMarkdownFiles(markdown); + + expect(files['App.tsx']).toContain('\\$'); + }); + + it('should handle code blocks with backticks', () => { + const markdown = '```js\nconsole.log("test");\n```'; + const files = getMarkdownFiles(markdown); + + // Should be escaped + expect(files['App.tsx']).toContain('\\`\\`\\`'); + }); + }); + + describe('list indentation normalization', () => { + it('should normalize 2-space indented lists to 4-space', () => { + const markdown = '- Item 1\n - Subitem 1\n - Subitem 2'; + const files = getMarkdownFiles(markdown); + + // The indentation normalization happens in wrapMarkdownRenderer + // It converts 2 spaces before list markers to 4 spaces + // Check that content.md preserves the original, but App.tsx has normalized content + expect(files['content.md']).toBe(markdown); + expect(files['App.tsx']).toContain('- Item 1'); + expect(files['App.tsx']).toContain('Subitem 1'); + }); + + it('should handle numbered lists with 2-space indents', () => { + const markdown = '1. First\n 2. Second nested'; + const files = getMarkdownFiles(markdown); + + // Verify normalization occurred + expect(files['content.md']).toBe(markdown); + expect(files['App.tsx']).toContain('1. First'); + expect(files['App.tsx']).toContain('2. Second nested'); + }); + + it('should not affect already 4-space indented lists', () => { + const markdown = '- Item 1\n - Subitem 1'; + const files = getMarkdownFiles(markdown); + + // Already normalized, should be preserved + expect(files['content.md']).toBe(markdown); + expect(files['App.tsx']).toContain('- Item 1'); + expect(files['App.tsx']).toContain('Subitem 1'); + }); + }); + + describe('edge cases', () => { + it('should handle very long markdown content', () => { + const longMarkdown = '# Test\n\n' + 'Lorem ipsum '.repeat(1000); + const files = getMarkdownFiles(longMarkdown); + + expect(files['content.md']).toBe(longMarkdown); + expect(files['App.tsx']).toContain('Lorem ipsum'); + }); + + it('should handle markdown with special characters', () => { + const markdown = '# Test & < > " \''; + const files = getMarkdownFiles(markdown); + + expect(files['content.md']).toBe(markdown); + }); + + it('should handle markdown with unicode characters', () => { + const markdown = '# 你好 世界 🌍'; + const files = getMarkdownFiles(markdown); + + expect(files['content.md']).toBe(markdown); + }); + + it('should handle markdown with only whitespace', () => { + const markdown = ' \n\n '; + const files = getMarkdownFiles(markdown); + + expect(files['content.md']).toBe(markdown); + }); + + it('should handle markdown with mixed line endings', () => { + const markdown = '# Line 1\r\n## Line 2\n### Line 3'; + const files = getMarkdownFiles(markdown); + + expect(files['content.md']).toBe(markdown); + }); + }); + }); + + describe('markdown component structure', () => { + it('should generate a MarkdownRenderer component that uses marked-react', () => { + const files = getMarkdownFiles('# Test'); + const rendererCode = files['/components/ui/MarkdownRenderer.tsx']; + + // Verify the component imports and uses Markdown from marked-react + expect(rendererCode).toContain("import Markdown from 'marked-react'"); + expect(rendererCode).toContain('{content}'); + }); + + it('should pass markdown content to the Markdown component', () => { + const testContent = '# Heading\n- List item'; + const files = getMarkdownFiles(testContent); + const appCode = files['App.tsx']; + + // The App.tsx should pass the content to MarkdownRenderer + expect(appCode).toContain(' = { 'text/html': 'static', 'application/vnd.react': 'react-ts', 'application/vnd.mermaid': 'react-ts', 'application/vnd.code-html': 'static', + 'text/markdown': 'react-ts', + 'text/md': 'react-ts', + 'text/plain': 'react-ts', default: 'static', // 'css': 'css', // 'javascript': 'js', @@ -34,27 +41,6 @@ const artifactTemplate: Record< // 'tsx': 'tsx', }; -export function getFileExtension(language?: string): string { - switch (language) { - case 'application/vnd.react': - return 'tsx'; - case 'application/vnd.mermaid': - return 'mermaid'; - case 'text/html': - return 'html'; - // case 'jsx': - // return 'jsx'; - // case 'tsx': - // return 'tsx'; - // case 'html': - // return 'html'; - // case 'css': - // return 'css'; - default: - return 'txt'; - } -} - export function getKey(type: string, language?: string): string { return `${type}${(language?.length ?? 0) > 0 ? `-${language}` : ''}`; } @@ -109,19 +95,34 @@ const standardDependencies = { vaul: '^0.9.1', }; -const mermaidDependencies = Object.assign( - { - mermaid: '^11.4.1', - 'react-zoom-pan-pinch': '^3.6.1', - }, - standardDependencies, -); +const mermaidDependencies = { + mermaid: '^11.4.1', + 'react-zoom-pan-pinch': '^3.6.1', + 'class-variance-authority': '^0.6.0', + clsx: '^1.2.1', + 'tailwind-merge': '^1.9.1', + '@radix-ui/react-slot': '^1.1.0', +}; -const dependenciesMap: Record = { +const markdownDependencies = { + 'marked-react': '^2.0.0', +}; + +const dependenciesMap: Record< + | keyof typeof artifactFilename + | 'application/vnd.mermaid' + | 'text/markdown' + | 'text/md' + | 'text/plain', + Record +> = { 'application/vnd.mermaid': mermaidDependencies, 'application/vnd.react': standardDependencies, 'text/html': standardDependencies, 'application/vnd.code-html': standardDependencies, + 'text/markdown': markdownDependencies, + 'text/md': markdownDependencies, + 'text/plain': markdownDependencies, default: standardDependencies, }; diff --git a/client/src/utils/markdown.ts b/client/src/utils/markdown.ts new file mode 100644 index 0000000000..12556c1a24 --- /dev/null +++ b/client/src/utils/markdown.ts @@ -0,0 +1,256 @@ +import dedent from 'dedent'; + +const markdownRenderer = dedent(`import React, { useEffect, useState } from 'react'; +import Markdown from 'marked-react'; + +interface MarkdownRendererProps { + content: string; +} + +const MarkdownRenderer: React.FC = ({ content }) => { + return ( +
+ {content} +
+ ); +}; + +export default MarkdownRenderer;`); + +const wrapMarkdownRenderer = (content: string) => { + // Normalize indentation: convert 2-space indents to 4-space for proper nesting + const normalizedContent = content.replace(/^( {2})(-|\d+\.)/gm, ' $2'); + + // Escape backticks, backslashes, and dollar signs in the content + const escapedContent = normalizedContent + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$/g, '\\$'); + + return dedent(`import React from 'react'; +import MarkdownRenderer from '/components/ui/MarkdownRenderer'; + +const App = () => { + return ; +}; + +export default App; +`); +}; + +const markdownCSS = ` +/* GitHub Markdown CSS - Light theme base */ +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + line-height: 1.5; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; + color: #24292f; + background-color: #ffffff; +} + +.markdown-body h1, .markdown-body h2 { + border-bottom: 1px solid #d0d7de; + margin: 0.6em 0; +} + +.markdown-body h1 { font-size: 2em; margin: 0.67em 0; } +.markdown-body h2 { font-size: 1.5em; } +.markdown-body h3 { font-size: 1.25em; } +.markdown-body h4 { font-size: 1em; } +.markdown-body h5 { font-size: 0.875em; } +.markdown-body h6 { font-size: 0.85em; } + +.markdown-body ul, .markdown-body ol { + list-style: revert !important; + padding-left: 2em !important; + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body ul { list-style-type: disc !important; } +.markdown-body ol { list-style-type: decimal !important; } +.markdown-body ul ul { list-style-type: circle !important; } +.markdown-body ul ul ul { list-style-type: square !important; } + +.markdown-body li { margin-top: 0.25em; } + +.markdown-body li:has(> input[type="checkbox"]) { + list-style-type: none !important; +} + +.markdown-body li > input[type="checkbox"] { + margin-right: 0.75em; + margin-left: -1.5em; + vertical-align: middle; + pointer-events: none; + width: 16px; + height: 16px; +} + +.markdown-body .task-list-item { + list-style-type: none !important; +} + +.markdown-body .task-list-item > input[type="checkbox"] { + margin-right: 0.75em; + margin-left: -1.5em; + vertical-align: middle; + pointer-events: none; + width: 16px; + height: 16px; +} + +.markdown-body code { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + border-radius: 6px; + background-color: rgba(175, 184, 193, 0.2); + color: #24292f; + font-family: ui-monospace, monospace; + white-space: pre-wrap; +} + +.markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + border-radius: 6px; + margin-top: 0; + margin-bottom: 16px; + background-color: #f6f8fa; + color: #24292f; +} + +.markdown-body pre code { + display: inline-block; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body a { + text-decoration: none; + color: #0969da; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; +} + +.markdown-body table thead { + background-color: #f6f8fa; +} + +.markdown-body table th, .markdown-body table td { + padding: 6px 13px; + border: 1px solid #d0d7de; +} + +.markdown-body blockquote { + padding: 0 1em; + border-left: 0.25em solid #d0d7de; + margin: 0 0 16px 0; + color: #57606a; +} + +.markdown-body hr { + height: 0.25em; + padding: 0; + margin: 24px 0; + border: 0; + background-color: #d0d7de; +} + +.markdown-body img { + max-width: 100%; + box-sizing: content-box; +} + +/* Dark theme */ +@media (prefers-color-scheme: dark) { + .markdown-body { + color: #c9d1d9; + background-color: #0d1117; + } + + .markdown-body h1, .markdown-body h2 { + border-bottom-color: #21262d; + } + + .markdown-body code { + background-color: rgba(110, 118, 129, 0.4); + color: #c9d1d9; + } + + .markdown-body pre { + background-color: #161b22; + color: #c9d1d9; + } + + .markdown-body a { + color: #58a6ff; + } + + .markdown-body table thead { + background-color: #161b22; + } + + .markdown-body table th, .markdown-body table td { + border-color: #30363d; + } + + .markdown-body blockquote { + border-left-color: #3b434b; + color: #8b949e; + } + + .markdown-body hr { + background-color: #21262d; + } +} +`; + +export const getMarkdownFiles = (content: string) => { + return { + 'content.md': content || '# No content provided', + 'App.tsx': wrapMarkdownRenderer(content), + 'index.tsx': dedent(`import React, { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./styles.css"; +import "./markdown.css"; + +import App from "./App"; + +const root = createRoot(document.getElementById("root")); +root.render(); +;`), + '/components/ui/MarkdownRenderer.tsx': markdownRenderer, + 'markdown.css': markdownCSS, + }; +}; diff --git a/client/src/utils/mermaid.ts b/client/src/utils/mermaid.ts index bc95eb1f1d..7930d9ab1e 100644 --- a/client/src/utils/mermaid.ts +++ b/client/src/utils/mermaid.ts @@ -7,9 +7,34 @@ import { ReactZoomPanPinchRef, } from "react-zoom-pan-pinch"; import mermaid from "mermaid"; -import { ZoomIn, ZoomOut, RefreshCw } from "lucide-react"; import { Button } from "/components/ui/button"; +const ZoomIn = () => ( + + + + + + +); + +const ZoomOut = () => ( + + + + + +); + +const RefreshCw = () => ( + + + + + + +); + interface MermaidDiagramProps { content: string; } @@ -181,21 +206,21 @@ const MermaidDiagram: React.FC = ({ content }) => {
@@ -217,12 +242,20 @@ export default App = () => ( `); }; +const mermaidCSS = ` +body { + background-color: #282C34; +} +`; + export const getMermaidFiles = (content: string) => { return { + 'diagram.mmd': content || '# No mermaid diagram content provided', 'App.tsx': wrapMermaidDiagram(content), 'index.tsx': dedent(`import React, { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./styles.css"; +import "./mermaid.css"; import App from "./App"; @@ -230,5 +263,6 @@ const root = createRoot(document.getElementById("root")); root.render(); ;`), '/components/ui/MermaidDiagram.tsx': mermaid, + 'mermaid.css': mermaidCSS, }; }; From d990fe1d5f6d9286300d74d7cd81dd828cdf5aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Sat, 11 Oct 2025 10:39:33 -0400 Subject: [PATCH 030/272] =?UTF-8?q?=F0=9F=93=96=20feat:=20Word=20Wrapping?= =?UTF-8?q?=20for=20Text=20and=20Markdown=20Code=20Blocks=20(#10055)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables word wrapping for text, markdown, and plaintext code blocks to prevent horizontal scrolling on long lines. Improves readability for prose content in fenced code blocks. --- client/src/style.css | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/src/style.css b/client/src/style.css index 5f3b2df311..1a3747c80c 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -1243,8 +1243,12 @@ pre { Ubuntu Mono, monospace !important; } -code[class='language-plaintext'] { - white-space: pre-line; +code.language-text, +code.language-txt, +code.language-plaintext, +code.language-markdown, +code.language-md { + white-space: pre-wrap !important; } code.hljs, code[class*='language-'], From cbd217efae45814c3a22dd63039178b536195b1a Mon Sep 17 00:00:00 2001 From: WhammyLeaf Date: Sat, 11 Oct 2025 17:15:35 +0200 Subject: [PATCH 031/272] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20feat:=20Add=20C?= =?UTF-8?q?ustom=20Deployment=20Labels=20and=20Annotations=20for=20Helm=20?= =?UTF-8?q?(#10076)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helm/librechat/templates/deployment.yaml | 7 +++++++ helm/librechat/values.yaml | 2 ++ 2 files changed, 9 insertions(+) diff --git a/helm/librechat/templates/deployment.yaml b/helm/librechat/templates/deployment.yaml index 5dc28a0484..e046bc507a 100755 --- a/helm/librechat/templates/deployment.yaml +++ b/helm/librechat/templates/deployment.yaml @@ -4,6 +4,13 @@ metadata: name: {{ include "librechat.fullname" $ }} labels: {{- include "librechat.labels" . | nindent 4 }} + {{- with .Values.deploymentLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + annotations: + {{- with .Values.deploymentAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} spec: replicas: {{ .Values.replicaCount }} {{- if .Values.updateStrategy }} diff --git a/helm/librechat/values.yaml b/helm/librechat/values.yaml index 7dacb1386b..2560535504 100755 --- a/helm/librechat/values.yaml +++ b/helm/librechat/values.yaml @@ -153,6 +153,8 @@ lifecycle: {} podAnnotations: {} podLabels: {} +deploymentAnnotations: {} +deploymentLabels: {} podSecurityContext: fsGroup: 2000 From 5ce67b5b717da62ca252ff71996c3f492f1db1c6 Mon Sep 17 00:00:00 2001 From: Federico Ruggi Date: Sat, 11 Oct 2025 17:17:12 +0200 Subject: [PATCH 032/272] =?UTF-8?q?=F0=9F=93=AE=20feat:=20Custom=20OAuth?= =?UTF-8?q?=20Headers=20Support=20for=20MCP=20Server=20Config=20(#10014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add oauth_headers field to mcp options * wrap fetch to pass oauth headers * fix order * consolidate headers passing * fix tests --- api/server/controllers/UserController.js | 41 ++-- api/server/routes/__tests__/mcp.spec.js | 16 ++ api/server/routes/mcp.js | 14 +- packages/api/src/mcp/MCPConnectionFactory.ts | 3 + .../__tests__/MCPConnectionFactory.test.ts | 1 + .../api/src/mcp/__tests__/handler.test.ts | 205 ++++++++++++++++-- packages/api/src/mcp/oauth/handler.ts | 57 ++++- packages/data-provider/src/mcp.ts | 2 + 8 files changed, 304 insertions(+), 35 deletions(-) diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index dc38c59721..31295387ed 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -327,16 +327,23 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => { const revocationEndpointAuthMethodsSupported = serverConfig.oauth?.revocation_endpoint_auth_methods_supported ?? clientMetadata.revocation_endpoint_auth_methods_supported; + const oauthHeaders = serverConfig.oauth_headers ?? {}; if (tokens?.access_token) { try { - await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.access_token, 'access', { - serverUrl: serverConfig.url, - clientId: clientInfo.client_id, - clientSecret: clientInfo.client_secret ?? '', - revocationEndpoint, - revocationEndpointAuthMethodsSupported, - }); + await MCPOAuthHandler.revokeOAuthToken( + serverName, + tokens.access_token, + 'access', + { + serverUrl: serverConfig.url, + clientId: clientInfo.client_id, + clientSecret: clientInfo.client_secret ?? '', + revocationEndpoint, + revocationEndpointAuthMethodsSupported, + }, + oauthHeaders, + ); } catch (error) { logger.error(`Error revoking OAuth access token for ${serverName}:`, error); } @@ -344,13 +351,19 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => { if (tokens?.refresh_token) { try { - await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.refresh_token, 'refresh', { - serverUrl: serverConfig.url, - clientId: clientInfo.client_id, - clientSecret: clientInfo.client_secret ?? '', - revocationEndpoint, - revocationEndpointAuthMethodsSupported, - }); + await MCPOAuthHandler.revokeOAuthToken( + serverName, + tokens.refresh_token, + 'refresh', + { + serverUrl: serverConfig.url, + clientId: clientInfo.client_id, + clientSecret: clientInfo.client_secret ?? '', + revocationEndpoint, + revocationEndpointAuthMethodsSupported, + }, + oauthHeaders, + ); } catch (error) { logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error); } diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index 0df28d7b10..64c95c58ee 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -127,8 +127,13 @@ describe('MCP Routes', () => { }), }; + const mockMcpManager = { + getRawConfig: jest.fn().mockReturnValue({}), + }; + getLogStores.mockReturnValue({}); require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + require('~/config').getMCPManager.mockReturnValue(mockMcpManager); MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({ authorizationUrl: 'https://oauth.example.com/auth', @@ -146,6 +151,7 @@ describe('MCP Routes', () => { 'test-server', 'https://test-server.com', 'test-user-id', + {}, { clientId: 'test-client-id' }, ); }); @@ -314,6 +320,7 @@ describe('MCP Routes', () => { }; const mockMcpManager = { getUserConnection: jest.fn().mockResolvedValue(mockUserConnection), + getRawConfig: jest.fn().mockReturnValue({}), }; require('~/config').getMCPManager.mockReturnValue(mockMcpManager); @@ -336,6 +343,7 @@ describe('MCP Routes', () => { 'test-flow-id', 'test-auth-code', mockFlowManager, + {}, ); expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith( expect.objectContaining({ @@ -392,6 +400,11 @@ describe('MCP Routes', () => { getLogStores.mockReturnValue({}); require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + const mockMcpManager = { + getRawConfig: jest.fn().mockReturnValue({}), + }; + require('~/config').getMCPManager.mockReturnValue(mockMcpManager); + const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({ code: 'test-auth-code', state: 'test-flow-id', @@ -427,6 +440,7 @@ describe('MCP Routes', () => { const mockMcpManager = { getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')), + getRawConfig: jest.fn().mockReturnValue({}), }; require('~/config').getMCPManager.mockReturnValue(mockMcpManager); @@ -1234,6 +1248,7 @@ describe('MCP Routes', () => { getUserConnection: jest.fn().mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]), }), + getRawConfig: jest.fn().mockReturnValue({}), }; require('~/config').getMCPManager.mockReturnValue(mockMcpManager); @@ -1281,6 +1296,7 @@ describe('MCP Routes', () => { .fn() .mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]), }), + getRawConfig: jest.fn().mockReturnValue({}), }; require('~/config').getMCPManager.mockReturnValue(mockMcpManager); diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index b1022136e3..e8415fd801 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -65,6 +65,7 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => { serverName, serverUrl, userId, + getOAuthHeaders(serverName), oauthConfig, ); @@ -132,7 +133,12 @@ router.get('/:serverName/oauth/callback', async (req, res) => { }); logger.debug('[MCP OAuth] Completing OAuth flow'); - const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager); + const tokens = await MCPOAuthHandler.completeOAuthFlow( + flowId, + code, + flowManager, + getOAuthHeaders(serverName), + ); logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route'); /** Persist tokens immediately so reconnection uses fresh credentials */ @@ -538,4 +544,10 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => { } }); +function getOAuthHeaders(serverName) { + const mcpManager = getMCPManager(); + const serverConfig = mcpManager.getRawConfig(serverName); + return serverConfig?.oauth_headers ?? {}; +} + module.exports = router; diff --git a/packages/api/src/mcp/MCPConnectionFactory.ts b/packages/api/src/mcp/MCPConnectionFactory.ts index 6785de748f..5f4447b2bd 100644 --- a/packages/api/src/mcp/MCPConnectionFactory.ts +++ b/packages/api/src/mcp/MCPConnectionFactory.ts @@ -142,6 +142,7 @@ export class MCPConnectionFactory { serverName: metadata.serverName, clientInfo: metadata.clientInfo, }, + this.serverConfig.oauth_headers ?? {}, this.serverConfig.oauth, ); }; @@ -161,6 +162,7 @@ export class MCPConnectionFactory { this.serverName, data.serverUrl || '', this.userId!, + config?.oauth_headers ?? {}, config?.oauth, ); @@ -358,6 +360,7 @@ export class MCPConnectionFactory { this.serverName, serverUrl, this.userId!, + this.serverConfig.oauth_headers ?? {}, this.serverConfig.oauth, ); diff --git a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts index e96d207f29..9ee05dfb27 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts @@ -255,6 +255,7 @@ describe('MCPConnectionFactory', () => { 'test-server', 'https://api.example.com', 'user123', + {}, undefined, ); expect(oauthOptions.oauthStart).toHaveBeenCalledWith('https://auth.example.com'); diff --git a/packages/api/src/mcp/__tests__/handler.test.ts b/packages/api/src/mcp/__tests__/handler.test.ts index 01794fe4db..24e8c5ddb4 100644 --- a/packages/api/src/mcp/__tests__/handler.test.ts +++ b/packages/api/src/mcp/__tests__/handler.test.ts @@ -1,6 +1,6 @@ import type { MCPOptions } from 'librechat-data-provider'; import type { AuthorizationServerMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'; -import { MCPOAuthHandler } from '~/mcp/oauth'; +import { MCPOAuthFlowMetadata, MCPOAuthHandler, MCPOAuthTokens } from '~/mcp/oauth'; jest.mock('@librechat/data-schemas', () => ({ logger: { @@ -14,18 +14,33 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ startAuthorization: jest.fn(), discoverAuthorizationServerMetadata: jest.fn(), + discoverOAuthProtectedResourceMetadata: jest.fn(), + registerClient: jest.fn(), + exchangeAuthorization: jest.fn(), })); import { startAuthorization, discoverAuthorizationServerMetadata, + discoverOAuthProtectedResourceMetadata, + registerClient, + exchangeAuthorization, } from '@modelcontextprotocol/sdk/client/auth.js'; +import { FlowStateManager } from '../../flow/manager'; const mockStartAuthorization = startAuthorization as jest.MockedFunction; const mockDiscoverAuthorizationServerMetadata = discoverAuthorizationServerMetadata as jest.MockedFunction< typeof discoverAuthorizationServerMetadata >; +const mockDiscoverOAuthProtectedResourceMetadata = + discoverOAuthProtectedResourceMetadata as jest.MockedFunction< + typeof discoverOAuthProtectedResourceMetadata + >; +const mockRegisterClient = registerClient as jest.MockedFunction; +const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction< + typeof exchangeAuthorization +>; describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { const mockServerName = 'test-server'; @@ -60,6 +75,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { mockServerName, mockServerUrl, mockUserId, + {}, baseConfig, ); @@ -82,7 +98,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { grant_types_supported: ['authorization_code'], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -100,7 +122,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { token_endpoint_auth_methods_supported: ['client_secret_post'], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -118,7 +146,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { response_types_supported: ['code', 'token'], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -136,7 +170,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { code_challenge_methods_supported: ['S256'], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -157,7 +197,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { code_challenge_methods_supported: ['S256'], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -181,7 +227,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { code_challenge_methods_supported: [], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -251,7 +303,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { }), } as Response); - const result = await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata); + const result = await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}); // Verify the call was made without Authorization header expect(mockFetch).toHaveBeenCalledWith( @@ -314,7 +366,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { }), } as Response); - await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata); + await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}); const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`; expect(mockFetch).toHaveBeenCalledWith( @@ -363,7 +415,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { }), } as Response); - await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata); + await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}); const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`; expect(mockFetch).toHaveBeenCalledWith( @@ -410,7 +462,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { }), } as Response); - await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata); + await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}); const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`; expect(mockFetch).toHaveBeenCalledWith( @@ -457,7 +509,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { }), } as Response); - await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata); + await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}); // Verify the call was made without Authorization header expect(mockFetch).toHaveBeenCalledWith( @@ -498,6 +550,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { await MCPOAuthHandler.refreshOAuthTokens( mockRefreshToken, { serverName: 'test-server' }, + {}, config, ); @@ -539,6 +592,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { await MCPOAuthHandler.refreshOAuthTokens( mockRefreshToken, { serverName: 'test-server' }, + {}, config, ); @@ -575,6 +629,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { await MCPOAuthHandler.refreshOAuthTokens( mockRefreshToken, { serverName: 'test-server' }, + {}, config, ); @@ -617,7 +672,9 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { '{"error":"invalid_request","error_description":"refresh_token.client_id: Field required"}', } as Response); - await expect(MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata)).rejects.toThrow( + await expect( + MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}), + ).rejects.toThrow( 'Token refresh failed: 400 Bad Request - {"error":"invalid_request","error_description":"refresh_token.client_id: Field required"}', ); }); @@ -813,4 +870,126 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { ); }); }); + + describe('Custom OAuth Headers', () => { + const originalFetch = global.fetch; + const mockFetch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = mockFetch as unknown as typeof fetch; + mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) } as Response); + mockDiscoverAuthorizationServerMetadata.mockResolvedValue({ + issuer: 'http://example.com', + authorization_endpoint: 'http://example.com/auth', + token_endpoint: 'http://example.com/token', + response_types_supported: ['code'], + } as AuthorizationServerMetadata); + mockStartAuthorization.mockResolvedValue({ + authorizationUrl: new URL('http://example.com/auth'), + codeVerifier: 'test-verifier', + }); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('passes headers to client registration', async () => { + mockRegisterClient.mockImplementation(async (_, options) => { + await options.fetchFn?.('http://example.com/register', {}); + return { client_id: 'test', redirect_uris: [] }; + }); + + await MCPOAuthHandler.initiateOAuthFlow( + 'test-server', + 'http://example.com', + 'user-123', + { foo: 'bar' }, + {}, + ); + + const headers = mockFetch.mock.calls[0][1]?.headers as Headers; + expect(headers.get('foo')).toBe('bar'); + }); + + it('passes headers to discovery operations', async () => { + mockDiscoverOAuthProtectedResourceMetadata.mockImplementation(async (_, __, fetchFn) => { + await fetchFn?.('http://example.com/.well-known/oauth-protected-resource', {}); + return { + resource: 'http://example.com', + authorization_servers: ['http://auth.example.com'], + }; + }); + + await MCPOAuthHandler.initiateOAuthFlow( + 'test-server', + 'http://example.com', + 'user-123', + { foo: 'bar' }, + {}, + ); + + const allHaveHeader = mockFetch.mock.calls.every((call) => { + const headers = call[1]?.headers as Headers; + return headers?.get('foo') === 'bar'; + }); + expect(allHaveHeader).toBe(true); + }); + + it('passes headers to token exchange', async () => { + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue({ + status: 'PENDING', + metadata: { + serverName: 'test-server', + codeVerifier: 'test-verifier', + clientInfo: {}, + metadata: {}, + } as MCPOAuthFlowMetadata, + }), + completeFlow: jest.fn(), + } as unknown as FlowStateManager; + + mockExchangeAuthorization.mockImplementation(async (_, options) => { + await options.fetchFn?.('http://example.com/token', {}); + return { access_token: 'test-token', token_type: 'Bearer', expires_in: 3600 }; + }); + + await MCPOAuthHandler.completeOAuthFlow('test-flow-id', 'test-auth-code', mockFlowManager, { + foo: 'bar', + }); + + const headers = mockFetch.mock.calls[0][1]?.headers as Headers; + expect(headers.get('foo')).toBe('bar'); + }); + + it('passes headers to token refresh', async () => { + mockDiscoverAuthorizationServerMetadata.mockImplementation(async (_, options) => { + await options?.fetchFn?.('http://example.com/.well-known/oauth-authorization-server', {}); + return { + issuer: 'http://example.com', + token_endpoint: 'http://example.com/token', + } as AuthorizationServerMetadata; + }); + + await MCPOAuthHandler.refreshOAuthTokens( + 'test-refresh-token', + { + serverName: 'test-server', + serverUrl: 'http://example.com', + clientInfo: { client_id: 'test-client', client_secret: 'test-secret' }, + }, + { foo: 'bar' }, + {}, + ); + + const discoveryCall = mockFetch.mock.calls.find((call) => + call[0].toString().includes('.well-known'), + ); + expect(discoveryCall).toBeDefined(); + const headers = discoveryCall![1]?.headers as Headers; + expect(headers.get('foo')).toBe('bar'); + }); + }); }); diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts index a96dae8442..896d199b6d 100644 --- a/packages/api/src/mcp/oauth/handler.ts +++ b/packages/api/src/mcp/oauth/handler.ts @@ -18,6 +18,7 @@ import type { OAuthMetadata, } from './types'; import { sanitizeUrlForLogging } from '~/mcp/utils'; +import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport'; /** Type for the OAuth metadata from the SDK */ type SDKOAuthMetadata = Parameters[1]['metadata']; @@ -26,10 +27,29 @@ export class MCPOAuthHandler { private static readonly FLOW_TYPE = 'mcp_oauth'; private static readonly FLOW_TTL = 10 * 60 * 1000; // 10 minutes + /** + * Creates a fetch function with custom headers injected + */ + private static createOAuthFetch(headers: Record): FetchLike { + return async (url: string | URL, init?: RequestInit): Promise => { + const newHeaders = new Headers(init?.headers ?? {}); + for (const [key, value] of Object.entries(headers)) { + newHeaders.set(key, value); + } + return fetch(url, { + ...init, + headers: newHeaders, + }); + }; + } + /** * Discovers OAuth metadata from the server */ - private static async discoverMetadata(serverUrl: string): Promise<{ + private static async discoverMetadata( + serverUrl: string, + oauthHeaders: Record, + ): Promise<{ metadata: OAuthMetadata; resourceMetadata?: OAuthProtectedResourceMetadata; authServerUrl: URL; @@ -41,12 +61,14 @@ export class MCPOAuthHandler { let authServerUrl = new URL(serverUrl); let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + const fetchFn = this.createOAuthFetch(oauthHeaders); + try { // Try to discover resource metadata first logger.debug( `[MCPOAuth] Attempting to discover protected resource metadata from ${serverUrl}`, ); - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl); + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {}, fetchFn); if (resourceMetadata?.authorization_servers?.length) { authServerUrl = new URL(resourceMetadata.authorization_servers[0]); @@ -66,7 +88,9 @@ export class MCPOAuthHandler { logger.debug( `[MCPOAuth] Discovering OAuth metadata from ${sanitizeUrlForLogging(authServerUrl)}`, ); - const rawMetadata = await discoverAuthorizationServerMetadata(authServerUrl); + const rawMetadata = await discoverAuthorizationServerMetadata(authServerUrl, { + fetchFn, + }); if (!rawMetadata) { logger.error( @@ -92,6 +116,7 @@ export class MCPOAuthHandler { private static async registerOAuthClient( serverUrl: string, metadata: OAuthMetadata, + oauthHeaders: Record, resourceMetadata?: OAuthProtectedResourceMetadata, redirectUri?: string, ): Promise { @@ -159,6 +184,7 @@ export class MCPOAuthHandler { const clientInfo = await registerClient(serverUrl, { metadata: metadata as unknown as SDKOAuthMetadata, clientMetadata, + fetchFn: this.createOAuthFetch(oauthHeaders), }); logger.debug( @@ -181,7 +207,8 @@ export class MCPOAuthHandler { serverName: string, serverUrl: string, userId: string, - config: MCPOptions['oauth'] | undefined, + oauthHeaders: Record, + config?: MCPOptions['oauth'], ): Promise<{ authorizationUrl: string; flowId: string; flowMetadata: MCPOAuthFlowMetadata }> { logger.debug( `[MCPOAuth] initiateOAuthFlow called for ${serverName} with URL: ${sanitizeUrlForLogging(serverUrl)}`, @@ -259,7 +286,10 @@ export class MCPOAuthHandler { logger.debug( `[MCPOAuth] Starting auto-discovery of OAuth metadata from ${sanitizeUrlForLogging(serverUrl)}`, ); - const { metadata, resourceMetadata, authServerUrl } = await this.discoverMetadata(serverUrl); + const { metadata, resourceMetadata, authServerUrl } = await this.discoverMetadata( + serverUrl, + oauthHeaders, + ); logger.debug( `[MCPOAuth] OAuth metadata discovered, auth server URL: ${sanitizeUrlForLogging(authServerUrl)}`, @@ -272,6 +302,7 @@ export class MCPOAuthHandler { const clientInfo = await this.registerOAuthClient( authServerUrl.toString(), metadata, + oauthHeaders, resourceMetadata, redirectUri, ); @@ -365,6 +396,7 @@ export class MCPOAuthHandler { flowId: string, authorizationCode: string, flowManager: FlowStateManager, + oauthHeaders: Record, ): Promise { try { /** Flow state which contains our metadata */ @@ -404,6 +436,7 @@ export class MCPOAuthHandler { codeVerifier: metadata.codeVerifier, authorizationCode, resource, + fetchFn: this.createOAuthFetch(oauthHeaders), }); logger.debug('[MCPOAuth] Raw tokens from exchange:', { @@ -476,6 +509,7 @@ export class MCPOAuthHandler { static async refreshOAuthTokens( refreshToken: string, metadata: { serverName: string; serverUrl?: string; clientInfo?: OAuthClientInformation }, + oauthHeaders: Record, config?: MCPOptions['oauth'], ): Promise { logger.debug(`[MCPOAuth] Refreshing tokens for ${metadata.serverName}`); @@ -509,7 +543,9 @@ export class MCPOAuthHandler { throw new Error('No token URL available for refresh'); } else { /** Auto-discover OAuth configuration for refresh */ - const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl); + const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl, { + fetchFn: this.createOAuthFetch(oauthHeaders), + }); if (!oauthMetadata) { throw new Error('Failed to discover OAuth metadata for token refresh'); } @@ -533,6 +569,7 @@ export class MCPOAuthHandler { const headers: HeadersInit = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', + ...oauthHeaders, }; /** Handle authentication based on server's advertised methods */ @@ -613,6 +650,7 @@ export class MCPOAuthHandler { const headers: HeadersInit = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', + ...oauthHeaders, }; /** Handle authentication based on configured methods */ @@ -684,7 +722,9 @@ export class MCPOAuthHandler { } /** Auto-discover OAuth configuration for refresh */ - const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl); + const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl, { + fetchFn: this.createOAuthFetch(oauthHeaders), + }); if (!oauthMetadata?.token_endpoint) { throw new Error('No token endpoint found in OAuth metadata'); @@ -700,6 +740,7 @@ export class MCPOAuthHandler { const headers: HeadersInit = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', + ...oauthHeaders, }; const response = await fetch(tokenUrl, { @@ -742,6 +783,7 @@ export class MCPOAuthHandler { revocationEndpoint?: string; revocationEndpointAuthMethodsSupported?: string[]; }, + oauthHeaders: Record = {}, ): Promise { // build the revoke URL, falling back to the server URL + /revoke if no revocation endpoint is provided const revokeUrl: URL = @@ -759,6 +801,7 @@ export class MCPOAuthHandler { // init the request headers const headers: Record = { 'Content-Type': 'application/x-www-form-urlencoded', + ...oauthHeaders, }; // init the request body diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 58d70ac118..72299e96a5 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -62,6 +62,8 @@ const BaseOptionsSchema = z.object({ revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(), }) .optional(), + /** Custom headers to send with OAuth requests (registration, discovery, token exchange, etc.) */ + oauth_headers: z.record(z.string(), z.string()).optional(), customUserVars: z .record( z.string(), From bf2567bc8f9d1a99a4d81c5007ce7042d390f1b7 Mon Sep 17 00:00:00 2001 From: Marlon <153027575+marlonka@users.noreply.github.com> Date: Sun, 12 Oct 2025 10:13:17 +0200 Subject: [PATCH 033/272] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20chore:=20update?= =?UTF-8?q?=20OpenAI=20models=20list=20in=20`.env.example`=20(#10085)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove deprecated OpenAI models and add latest GPT-5, o3/o4, and GPT-4.1 series models based on current API offerings as of October 2025. Removed deprecated models: - gpt-4.5-preview (deprecated July 2025) - o1-preview, o1-mini (deprecated July/October 2025) - gpt-4-vision-preview (shut down December 2024) - Dated GPT-3.5 and GPT-4 variants (consolidated into base versions) Added new flagship models: - GPT-5 series: gpt-5, gpt-5-mini, gpt-5-nano - o3/o4 reasoning models: o3, o4-mini, o3-pro, o3-mini - GPT-4.1 series: gpt-4.1, gpt-4.1-mini, gpt-4.1-nano Reorganized list with newest models first for better discoverability. References: - https://platform.openai.com/docs/models - https://platform.openai.com/docs/deprecations --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 78208307ff..f1666fb763 100644 --- a/.env.example +++ b/.env.example @@ -196,7 +196,7 @@ GOOGLE_KEY=user_provided #============# OPENAI_API_KEY=user_provided -# OPENAI_MODELS=o1,o1-mini,o1-preview,gpt-4o,gpt-4.5-preview,chatgpt-4o-latest,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k +# OPENAI_MODELS=gpt-5,gpt-5-codex,gpt-5-mini,gpt-5-nano,o3-pro,o3,o4-mini,gpt-4.1,gpt-4.1-mini,gpt-4.1-nano,o3-mini,o1-pro,o1,gpt-4o,gpt-4o-mini DEBUG_OPENAI=false From 3d1cedb85b782eef64e4b0795ec799109c373392 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 12 Oct 2025 11:15:18 +0300 Subject: [PATCH 034/272] =?UTF-8?q?=F0=9F=93=A1=20refactor:=20Flush=20Redi?= =?UTF-8?q?s=20Cache=20Script=20(#10087)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 feat: Enhance Redis Configuration and Connection Handling in Cache Flush Utility - Added support for Redis username, password, and CA certificate. - Improved Redis client creation to handle both cluster and single instance configurations. - Implemented a helper function to read the Redis CA certificate with error handling. - Updated connection logic to include timeout and error handling for Redis connections. * refactor: flush cache if redis URI is defined --- config/flush-cache.js | 99 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 13 deletions(-) diff --git a/config/flush-cache.js b/config/flush-cache.js index b65339ad18..07c744ca4e 100644 --- a/config/flush-cache.js +++ b/config/flush-cache.js @@ -18,11 +18,38 @@ const fs = require('fs'); // Set up environment require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); -const { USE_REDIS, REDIS_URI, REDIS_KEY_PREFIX } = process.env; +const { + USE_REDIS, + REDIS_URI, + REDIS_USERNAME, + REDIS_PASSWORD, + REDIS_CA, + REDIS_KEY_PREFIX, + USE_REDIS_CLUSTER, + REDIS_USE_ALTERNATIVE_DNS_LOOKUP, +} = process.env; // Simple utility function const isEnabled = (value) => value === 'true' || value === true; +// Helper function to read Redis CA certificate +const getRedisCA = () => { + if (!REDIS_CA) { + return null; + } + try { + if (fs.existsSync(REDIS_CA)) { + return fs.readFileSync(REDIS_CA, 'utf8'); + } else { + console.warn(`⚠️ Redis CA certificate file not found: ${REDIS_CA}`); + return null; + } + } catch (error) { + console.error(`❌ Failed to read Redis CA certificate file '${REDIS_CA}':`, error.message); + return null; + } +}; + async function showHelp() { console.log(` LibreChat Cache Flush Utility @@ -67,21 +94,67 @@ async function flushRedisCache(dryRun = false, verbose = false) { console.log(` Prefix: ${REDIS_KEY_PREFIX || 'None'}`); } - // Create Redis client directly - const Redis = require('ioredis'); + // Create Redis client using same pattern as main app + const IoRedis = require('ioredis'); let redis; - // Handle cluster vs single Redis - if (process.env.USE_REDIS_CLUSTER === 'true') { - const hosts = REDIS_URI.split(',').map((uri) => { - const [host, port] = uri.split(':'); - return { host, port: parseInt(port) || 6379 }; - }); - redis = new Redis.Cluster(hosts); + // Parse credentials from URI or use environment variables (same as redisClients.ts) + const urls = (REDIS_URI || '').split(',').map((uri) => new URL(uri)); + const username = urls[0]?.username || REDIS_USERNAME; + const password = urls[0]?.password || REDIS_PASSWORD; + const ca = getRedisCA(); + + // Redis options (matching redisClients.ts configuration) + const redisOptions = { + username: username, + password: password, + tls: ca ? { ca } : undefined, + connectTimeout: 10000, + maxRetriesPerRequest: 3, + enableOfflineQueue: true, + lazyConnect: false, + }; + + // Handle cluster vs single Redis (same logic as redisClients.ts) + const useCluster = urls.length > 1 || isEnabled(USE_REDIS_CLUSTER); + + if (useCluster) { + const clusterOptions = { + redisOptions, + enableOfflineQueue: true, + }; + + // Add DNS lookup for AWS ElastiCache if needed (same as redisClients.ts) + if (isEnabled(REDIS_USE_ALTERNATIVE_DNS_LOOKUP)) { + clusterOptions.dnsLookup = (address, callback) => callback(null, address); + } + + redis = new IoRedis.Cluster( + urls.map((url) => ({ host: url.hostname, port: parseInt(url.port, 10) || 6379 })), + clusterOptions, + ); } else { - redis = new Redis(REDIS_URI); + // @ts-ignore - ioredis default export is constructable despite linter warning + redis = new IoRedis(REDIS_URI, redisOptions); } + // Wait for connection + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Connection timeout')); + }, 10000); + + redis.once('ready', () => { + clearTimeout(timeout); + resolve(undefined); + }); + + redis.once('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + if (dryRun) { console.log('🔍 [DRY RUN] Would flush Redis cache'); try { @@ -105,7 +178,7 @@ async function flushRedisCache(dryRun = false, verbose = false) { try { const keys = await redis.keys('*'); keyCount = keys.length; - } catch (error) { + } catch (_error) { // Continue with flush even if we can't count keys } @@ -209,7 +282,7 @@ async function main() { } let success = true; - const isRedisEnabled = isEnabled(USE_REDIS) && REDIS_URI; + const isRedisEnabled = isEnabled(USE_REDIS) || (REDIS_URI != null && REDIS_URI !== ''); // Flush the appropriate cache type if (isRedisEnabled) { From c6020881782e14f756cfe76a2d8645a612e5beb2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 15 Oct 2025 15:12:32 +0300 Subject: [PATCH 035/272] =?UTF-8?q?=F0=9F=93=B1=20fix:=20Improve=20Mobile?= =?UTF-8?q?=20Chat=20Focus=20Detection=20and=20Navigation=20(#10125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/hooks/Chat/useFocusChatEffect.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/client/src/hooks/Chat/useFocusChatEffect.ts b/client/src/hooks/Chat/useFocusChatEffect.ts index fefb2c9584..17c41fef01 100644 --- a/client/src/hooks/Chat/useFocusChatEffect.ts +++ b/client/src/hooks/Chat/useFocusChatEffect.ts @@ -12,12 +12,22 @@ export default function useFocusChatEffect(textAreaRef: React.RefObject Date: Wed, 15 Oct 2025 16:20:54 +0300 Subject: [PATCH 036/272] =?UTF-8?q?=F0=9F=AA=82=20refactor:=20OCR=20Fallba?= =?UTF-8?q?ck=20for=20"Upload=20as=20Text"=20File=20Process=20(#10126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/services/Files/process.js | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 5e945f0e36..701412523d 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -598,11 +598,22 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { if (shouldUseOCR && !(await checkCapability(req, AgentCapabilities.ocr))) { throw new Error('OCR capability is not enabled for Agents'); } else if (shouldUseOCR) { - const { handleFileUpload: uploadOCR } = getStrategyFunctions( - appConfig?.ocr?.strategy ?? FileSources.mistral_ocr, - ); - const { text, bytes, filepath: ocrFileURL } = await uploadOCR({ req, file, loadAuthValues }); - return await createTextFile({ text, bytes, filepath: ocrFileURL }); + try { + const { handleFileUpload: uploadOCR } = getStrategyFunctions( + appConfig?.ocr?.strategy ?? FileSources.mistral_ocr, + ); + const { + text, + bytes, + filepath: ocrFileURL, + } = await uploadOCR({ req, file, loadAuthValues }); + return await createTextFile({ text, bytes, filepath: ocrFileURL }); + } catch (ocrError) { + logger.error( + `[processAgentFileUpload] OCR processing failed for file "${file.originalname}", falling back to text extraction:`, + ocrError, + ); + } } const shouldUseSTT = fileConfig.checkType( From f59daaeecc6299a3f432cf552d92a4159be5f278 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 16 Oct 2025 23:24:14 +0300 Subject: [PATCH 037/272] =?UTF-8?q?=F0=9F=93=84=20feat:=20Context=20Field?= =?UTF-8?q?=20for=20Anthropic=20Documents=20(PDF)=20(#10148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Remove ephemeral cache control from document encoding function * refactor: Improve document encoding types and add file context for anthropic messages api - Added AnthropicDocumentBlock interface to define the structure for documents from the Anthropic provider. - Updated encodeAndFormatDocuments function to utilize the new type and include optional context for filenames. - Refactored DocumentResult to use a union type for various document formats, improving type safety and clarity. --- packages/api/src/files/encode/document.ts | 13 +++-- packages/api/src/types/files.ts | 66 +++++++++++++++-------- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/packages/api/src/files/encode/document.ts b/packages/api/src/files/encode/document.ts index bc1396958c..338a4ddea6 100644 --- a/packages/api/src/files/encode/document.ts +++ b/packages/api/src/files/encode/document.ts @@ -2,7 +2,7 @@ import { Providers } from '@librechat/agents'; import { isOpenAILikeProvider, isDocumentSupportedProvider } from 'librechat-data-provider'; import type { IMongoFile } from '@librechat/data-schemas'; import type { Request } from 'express'; -import type { StrategyFunctions, DocumentResult } from '~/types/files'; +import type { StrategyFunctions, DocumentResult, AnthropicDocumentBlock } from '~/types/files'; import { validatePdf } from '~/files/validation'; import { getFileStream } from './utils'; @@ -69,16 +69,21 @@ export async function encodeAndFormatDocuments( } if (provider === Providers.ANTHROPIC) { - result.documents.push({ + const document: AnthropicDocumentBlock = { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: content, }, - cache_control: { type: 'ephemeral' }, citations: { enabled: true }, - }); + }; + + if (file.filename) { + document.context = `File: "${file.filename}"`; + } + + result.documents.push(document); } else if (useResponsesApi) { result.documents.push({ type: 'input_file', diff --git a/packages/api/src/types/files.ts b/packages/api/src/types/files.ts index dc37410050..5b25f0b3e9 100644 --- a/packages/api/src/types/files.ts +++ b/packages/api/src/types/files.ts @@ -46,29 +46,51 @@ export interface VideoResult { }>; } +/** Anthropic document block format */ +export interface AnthropicDocumentBlock { + type: 'document'; + source: { + type: string; + media_type: string; + data: string; + }; + context?: string; + title?: string; + cache_control?: { type: string }; + citations?: { enabled: boolean }; +} + +/** Google document block format */ +export interface GoogleDocumentBlock { + type: 'document'; + mimeType: string; + data: string; +} + +/** OpenAI file block format */ +export interface OpenAIFileBlock { + type: 'file'; + file: { + filename: string; + file_data: string; + }; +} + +/** OpenAI Responses API file format */ +export interface OpenAIInputFileBlock { + type: 'input_file'; + filename: string; + file_data: string; +} + +export type DocumentBlock = + | AnthropicDocumentBlock + | GoogleDocumentBlock + | OpenAIFileBlock + | OpenAIInputFileBlock; + export interface DocumentResult { - documents: Array<{ - type: 'document' | 'file' | 'input_file'; - /** Anthropic File Format, `document` */ - source?: { - type: string; - media_type: string; - data: string; - }; - cache_control?: { type: string }; - citations?: { enabled: boolean }; - /** Google File Format, `document` */ - mimeType?: string; - data?: string; - /** OpenAI File Format, `file` */ - file?: { - filename?: string; - file_data?: string; - }; - /** OpenAI Responses API File Format, `input_file` */ - filename?: string; - file_data?: string; - }>; + documents: DocumentBlock[]; files: Array<{ file_id?: string; temp_file_id?: string; From 114deecc4ebbc002ac20e289a0058c6663e637bd Mon Sep 17 00:00:00 2001 From: Sean McGrath Date: Fri, 17 Oct 2025 09:26:14 +1300 Subject: [PATCH 038/272] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20chore:=20Add=20?= =?UTF-8?q?`@radix-ui/react-tooltip`=20to=20Artifact=20Dependencies=20(#10?= =?UTF-8?q?112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/utils/artifacts.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/utils/artifacts.ts b/client/src/utils/artifacts.ts index af14ec6dca..e441481848 100644 --- a/client/src/utils/artifacts.ts +++ b/client/src/utils/artifacts.ts @@ -89,6 +89,7 @@ const standardDependencies = { '@radix-ui/react-slot': '^1.1.0', '@radix-ui/react-toggle': '^1.1.0', '@radix-ui/react-toggle-group': '^1.1.0', + '@radix-ui/react-tooltip': '^1.2.8', 'embla-carousel-react': '^8.2.0', 'react-day-picker': '^9.0.8', 'dat.gui': '^0.7.9', From d41b07c0afc97ebfa434ec69d188529166aee5aa Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 18 Oct 2025 11:50:34 +0200 Subject: [PATCH 039/272] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Replac?= =?UTF-8?q?e=20`fontSize`=20Recoil=20atom=20with=20Jotai=20(#10171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: reapply chat font size on load * refactor: streamline font size handling in localStorage * fix: update matchMedia mock to accurately reflect desktop and touchscreen capabilities * refactor: implement Jotai for font size management and initialize on app load - Replaced Recoil with Jotai for font size state management across components. - Added a new `fontSize` atom to handle font size changes and persist them in localStorage. - Implemented `initializeFontSize` function to apply saved font size on app load. - Updated relevant components to utilize the new font size atom. --------- Co-authored-by: ddooochii Co-authored-by: Danny Avila --- client/src/App.jsx | 6 +++ .../components/Chat/Messages/MessageParts.tsx | 4 +- .../components/Chat/Messages/MessagesView.tsx | 4 +- .../Chat/Messages/SearchMessage.tsx | 4 +- .../Chat/Messages/ui/MessageRender.tsx | 4 +- .../src/components/Messages/ContentRender.tsx | 6 ++- .../SettingsTabs/Chat/FontSizeSelector.tsx | 9 ++-- client/src/components/Share/Message.tsx | 6 +-- .../__tests__/useFocusChatEffect.spec.tsx | 8 +-- client/src/store/fontSize.ts | 54 +++++++++++++++++++ client/src/store/settings.ts | 1 - 11 files changed, 87 insertions(+), 19 deletions(-) create mode 100644 client/src/store/fontSize.ts diff --git a/client/src/App.jsx b/client/src/App.jsx index decad9392b..eda775bc71 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { RecoilRoot } from 'recoil'; import { DndProvider } from 'react-dnd'; import { RouterProvider } from 'react-router-dom'; @@ -8,6 +9,7 @@ import { Toast, ThemeProvider, ToastProvider } from '@librechat/client'; import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; import { ScreenshotProvider, useApiErrorBoundary } from './hooks'; import { getThemeFromEnv } from './utils/getThemeFromEnv'; +import { initializeFontSize } from '~/store/fontSize'; import { LiveAnnouncer } from '~/a11y'; import { router } from './routes'; @@ -24,6 +26,10 @@ const App = () => { }), }); + useEffect(() => { + initializeFontSize(); + }, []); + // Load theme from environment variables if available const envTheme = getThemeFromEnv(); diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index a79f0985d9..a993009915 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -1,10 +1,12 @@ import React, { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import ContentParts from './Content/ContentParts'; +import { fontSizeAtom } from '~/store/fontSize'; import SiblingSwitch from './SiblingSwitch'; import MultiMessage from './MultiMessage'; import HoverButtons from './HoverButtons'; @@ -36,7 +38,7 @@ export default function Message(props: TMessageProps) { regenerateMessage, } = useMessageHelpers(props); - const fontSize = useRecoilValue(store.fontSize); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); const { children, messageId = null, isCreatedByUser } = message ?? {}; diff --git a/client/src/components/Chat/Messages/MessagesView.tsx b/client/src/components/Chat/Messages/MessagesView.tsx index 01459203f0..bea6554ff1 100644 --- a/client/src/components/Chat/Messages/MessagesView.tsx +++ b/client/src/components/Chat/Messages/MessagesView.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { CSSTransition } from 'react-transition-group'; import type { TMessage } from 'librechat-data-provider'; import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks'; import ScrollToBottom from '~/components/Messages/ScrollToBottom'; import { MessagesViewProvider } from '~/Providers'; +import { fontSizeAtom } from '~/store/fontSize'; import MultiMessage from './MultiMessage'; import { cn } from '~/utils'; import store from '~/store'; @@ -15,7 +17,7 @@ function MessagesViewContent({ messagesTree?: TMessage[] | null; }) { const localize = useLocalize(); - const fontSize = useRecoilValue(store.fontSize); + const fontSize = useAtomValue(fontSizeAtom); const { screenshotTargetRef } = useScreenshot(); const scrollButtonPreference = useRecoilValue(store.showScrollButton); const [currentEditId, setCurrentEditId] = useState(-1); diff --git a/client/src/components/Chat/Messages/SearchMessage.tsx b/client/src/components/Chat/Messages/SearchMessage.tsx index c7ac2c69c3..982aee06ce 100644 --- a/client/src/components/Chat/Messages/SearchMessage.tsx +++ b/client/src/components/Chat/Messages/SearchMessage.tsx @@ -1,10 +1,12 @@ import { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { useAuthContext, useLocalize } from '~/hooks'; import type { TMessageProps, TMessageIcon } from '~/common'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import Icon from '~/components/Chat/Messages/MessageIcon'; import SearchContent from './Content/SearchContent'; +import { fontSizeAtom } from '~/store/fontSize'; import SearchButtons from './SearchButtons'; import SubRow from './SubRow'; import { cn } from '~/utils'; @@ -34,8 +36,8 @@ const MessageBody = ({ message, messageLabel, fontSize }) => ( ); export default function SearchMessage({ message }: Pick) { + const fontSize = useAtomValue(fontSizeAtom); const UsernameDisplay = useRecoilValue(store.UsernameDisplay); - const fontSize = useRecoilValue(store.fontSize); const { user } = useAuthContext(); const localize = useLocalize(); diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index f056fccc98..179da5942d 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo, memo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { type TMessage } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; @@ -9,6 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import { Plugin } from '~/components/Messages/Content'; import SubRow from '~/components/Chat/Messages/SubRow'; +import { fontSizeAtom } from '~/store/fontSize'; import { MessageContext } from '~/Providers'; import { useMessageActions } from '~/hooks'; import { cn, logger } from '~/utils'; @@ -58,8 +60,8 @@ const MessageRender = memo( isMultiMessage, setCurrentEditId, }); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); - const fontSize = useRecoilValue(store.fontSize); const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const hasNoChildren = !(msg?.children?.length ?? 0); diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index ce88687d23..565dadde11 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -1,5 +1,6 @@ -import { useRecoilValue } from 'recoil'; import { useCallback, useMemo, memo } from 'react'; +import { useAtomValue } from 'jotai'; +import { useRecoilValue } from 'recoil'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; @@ -9,6 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import { useAttachments, useMessageActions } from '~/hooks'; import SubRow from '~/components/Chat/Messages/SubRow'; +import { fontSizeAtom } from '~/store/fontSize'; import { cn, logger } from '~/utils'; import store from '~/store'; @@ -60,8 +62,8 @@ const ContentRender = memo( isMultiMessage, setCurrentEditId, }); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); - const fontSize = useRecoilValue(store.fontSize); const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const isLast = useMemo( diff --git a/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx b/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx index 82fa2e746b..66b3f832ab 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx @@ -1,15 +1,14 @@ -import { useRecoilState } from 'recoil'; -import { Dropdown, applyFontSize } from '@librechat/client'; +import { useAtom } from 'jotai'; +import { Dropdown } from '@librechat/client'; +import { fontSizeAtom } from '~/store/fontSize'; import { useLocalize } from '~/hooks'; -import store from '~/store'; export default function FontSizeSelector() { - const [fontSize, setFontSize] = useRecoilState(store.fontSize); const localize = useLocalize(); + const [fontSize, setFontSize] = useAtom(fontSizeAtom); const handleChange = (val: string) => { setFontSize(val); - applyFontSize(val); }; const options = [ diff --git a/client/src/components/Share/Message.tsx b/client/src/components/Share/Message.tsx index eddd5060e5..e556145481 100644 --- a/client/src/components/Share/Message.tsx +++ b/client/src/components/Share/Message.tsx @@ -1,4 +1,4 @@ -import { useRecoilValue } from 'recoil'; +import { useAtomValue } from 'jotai'; import type { TMessageProps } from '~/common'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; @@ -6,16 +6,16 @@ import SearchContent from '~/components/Chat/Messages/Content/SearchContent'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import { Plugin } from '~/components/Messages/Content'; import SubRow from '~/components/Chat/Messages/SubRow'; +import { fontSizeAtom } from '~/store/fontSize'; import { MessageContext } from '~/Providers'; import { useAttachments } from '~/hooks'; import MultiMessage from './MultiMessage'; import { cn } from '~/utils'; -import store from '~/store'; import Icon from './MessageIcon'; export default function Message(props: TMessageProps) { - const fontSize = useRecoilValue(store.fontSize); + const fontSize = useAtomValue(fontSizeAtom); const { message, siblingIdx, diff --git a/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx b/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx index a3d3b8d67d..e0dbac5a1e 100644 --- a/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx @@ -21,8 +21,8 @@ describe('useFocusChatEffect', () => { (useNavigate as jest.Mock).mockReturnValue(mockNavigate); // Mock window.matchMedia - window.matchMedia = jest.fn().mockImplementation(() => ({ - matches: false, + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query === '(hover: hover)', // Desktop has hover capability media: '', onchange: null, addListener: jest.fn(), @@ -83,8 +83,8 @@ describe('useFocusChatEffect', () => { }); test('should not focus textarea on touchscreen devices', () => { - window.matchMedia = jest.fn().mockImplementation(() => ({ - matches: true, // This indicates a touchscreen + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query === '(pointer: coarse)', // Touchscreen has coarse pointer media: '', onchange: null, addListener: jest.fn(), diff --git a/client/src/store/fontSize.ts b/client/src/store/fontSize.ts new file mode 100644 index 0000000000..4b1a0666f3 --- /dev/null +++ b/client/src/store/fontSize.ts @@ -0,0 +1,54 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { applyFontSize } from '@librechat/client'; + +const DEFAULT_FONT_SIZE = 'text-base'; + +/** + * Base storage atom for font size + */ +const fontSizeStorageAtom = atomWithStorage('fontSize', DEFAULT_FONT_SIZE, undefined, { + getOnInit: true, +}); + +/** + * Derived atom that applies font size changes to the DOM + * Read: returns the current font size + * Write: updates storage and applies the font size to the DOM + */ +export const fontSizeAtom = atom( + (get) => get(fontSizeStorageAtom), + (get, set, newValue: string) => { + set(fontSizeStorageAtom, newValue); + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + applyFontSize(newValue); + } + }, +); + +/** + * Initialize font size on app load + */ +export const initializeFontSize = () => { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + const savedValue = localStorage.getItem('fontSize'); + + if (savedValue !== null) { + try { + const parsedValue = JSON.parse(savedValue); + applyFontSize(parsedValue); + } catch (error) { + console.error( + 'Error parsing localStorage key "fontSize", resetting to default. Error:', + error, + ); + localStorage.setItem('fontSize', JSON.stringify(DEFAULT_FONT_SIZE)); + applyFontSize(DEFAULT_FONT_SIZE); + } + } else { + applyFontSize(DEFAULT_FONT_SIZE); + } +}; diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index 0fe4dccd2c..4e9c2f5cad 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -21,7 +21,6 @@ const localStorageAtoms = { // General settings autoScroll: atomWithLocalStorage('autoScroll', false), hideSidePanel: atomWithLocalStorage('hideSidePanel', false), - fontSize: atomWithLocalStorage('fontSize', 'text-base'), enableUserMsgMarkdown: atomWithLocalStorage( LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN, true, From 589f1193100de0a5d119c67677f3ebb64aed4a9c Mon Sep 17 00:00:00 2001 From: Federico Ruggi Date: Sat, 18 Oct 2025 11:54:05 +0200 Subject: [PATCH 040/272] =?UTF-8?q?=F0=9F=A9=B9=20fix:=20Wrap=20Attempt=20?= =?UTF-8?q?to=20Reconnect=20OAuth=20MCP=20Servers=20(#10172)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/controllers/AuthController.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 249817610e..096727e977 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -116,11 +116,15 @@ const refreshController = async (req, res) => { const token = await setAuthTokens(userId, res, session); // trigger OAuth MCP server reconnection asynchronously (best effort) - void getOAuthReconnectionManager() - .reconnectServers(userId) - .catch((err) => { - logger.error('Error reconnecting OAuth MCP servers:', err); - }); + try { + void getOAuthReconnectionManager() + .reconnectServers(userId) + .catch((err) => { + logger.error('[refreshController] Error reconnecting OAuth MCP servers:', err); + }); + } catch (err) { + logger.warn(`[refreshController] Cannot attempt OAuth MCP servers reconnection:`, err); + } res.status(200).send({ token, user }); } else if (req?.query?.retry) { From 36f0365fd4d1f866c9bbac732d4e14e02963be36 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 19 Oct 2025 09:23:27 -0400 Subject: [PATCH 041/272] =?UTF-8?q?=F0=9F=A7=AE=20feat:=20Enhance=20Model?= =?UTF-8?q?=20Pricing=20Coverage=20and=20Pattern=20Matching=20(#10173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * updated gpt5-pro it is here and on openrouter https://platform.openai.com/docs/models/gpt-5-pro * feat: Add gpt-5-pro pricing - Implemented handling for the new gpt-5-pro model in the getValueKey function. - Updated tests to ensure correct behavior for gpt-5-pro across various scenarios. - Adjusted token limits and multipliers for gpt-5-pro in the tokens utility files. - Enhanced model matching functionality to include gpt-5-pro variations. * refactor: optimize model pricing and validation logic - Added new model pricing entries for llama2, llama3, and qwen variants in tx.js. - Updated tokenValues to include additional models and their pricing structures. - Implemented validation tests in tx.spec.js to ensure all models resolve correctly to pricing. - Refactored getValueKey function to improve model matching and resolution efficiency. - Removed outdated model entries from tokens.ts to streamline pricing management. * fix: add missing pricing * chore: update model pricing for qwen and gemma variants * chore: update model pricing and add validation for context windows - Removed outdated model entries from tx.js and updated tokenValues with new models. - Added a test in tx.spec.js to ensure all models with pricing have corresponding context windows defined in tokens.ts. - Introduced 'command-text' model pricing in tokens.ts to maintain consistency across model definitions. * chore: update model names and pricing for AI21 and Amazon models - Refactored model names in tx.js for AI21 and Amazon models to remove versioning and improve consistency. - Updated pricing values in tokens.ts to reflect the new model names. - Added comprehensive tests in tx.spec.js to validate pricing for both short and full model names across AI21 and Amazon models. * feat: add pricing and validation for Claude Haiku 4.5 model * chore: increase default max context tokens to 18000 for agents * feat: add Qwen3 model pricing and validation tests * chore: reorganize and update Qwen model pricing in tx.js and tokens.ts --------- Co-authored-by: khfung <68192841+khfung@users.noreply.github.com> --- api/models/tx.js | 258 ++++---- api/models/tx.spec.js | 594 ++++++++++++++++++ api/server/services/Endpoints/agents/agent.js | 2 +- api/utils/tokens.spec.js | 180 +++++- packages/api/src/utils/tokens.ts | 62 +- 5 files changed, 964 insertions(+), 132 deletions(-) diff --git a/api/models/tx.js b/api/models/tx.js index 462396d860..92f2432d0e 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -1,4 +1,4 @@ -const { matchModelName } = require('@librechat/api'); +const { matchModelName, findMatchingPattern } = require('@librechat/api'); const defaultRate = 6; /** @@ -6,44 +6,58 @@ const defaultRate = 6; * source: https://aws.amazon.com/bedrock/pricing/ * */ const bedrockValues = { - // Basic llama2 patterns + // Basic llama2 patterns (base defaults to smallest variant) + llama2: { prompt: 0.75, completion: 1.0 }, + 'llama-2': { prompt: 0.75, completion: 1.0 }, 'llama2-13b': { prompt: 0.75, completion: 1.0 }, - 'llama2:13b': { prompt: 0.75, completion: 1.0 }, 'llama2:70b': { prompt: 1.95, completion: 2.56 }, 'llama2-70b': { prompt: 1.95, completion: 2.56 }, - // Basic llama3 patterns + // Basic llama3 patterns (base defaults to smallest variant) + llama3: { prompt: 0.3, completion: 0.6 }, + 'llama-3': { prompt: 0.3, completion: 0.6 }, 'llama3-8b': { prompt: 0.3, completion: 0.6 }, 'llama3:8b': { prompt: 0.3, completion: 0.6 }, 'llama3-70b': { prompt: 2.65, completion: 3.5 }, 'llama3:70b': { prompt: 2.65, completion: 3.5 }, - // llama3-x-Nb pattern + // llama3-x-Nb pattern (base defaults to smallest variant) + 'llama3-1': { prompt: 0.22, completion: 0.22 }, 'llama3-1-8b': { prompt: 0.22, completion: 0.22 }, 'llama3-1-70b': { prompt: 0.72, completion: 0.72 }, 'llama3-1-405b': { prompt: 2.4, completion: 2.4 }, + 'llama3-2': { prompt: 0.1, completion: 0.1 }, 'llama3-2-1b': { prompt: 0.1, completion: 0.1 }, 'llama3-2-3b': { prompt: 0.15, completion: 0.15 }, 'llama3-2-11b': { prompt: 0.16, completion: 0.16 }, 'llama3-2-90b': { prompt: 0.72, completion: 0.72 }, + 'llama3-3': { prompt: 2.65, completion: 3.5 }, + 'llama3-3-70b': { prompt: 2.65, completion: 3.5 }, - // llama3.x:Nb pattern + // llama3.x:Nb pattern (base defaults to smallest variant) + 'llama3.1': { prompt: 0.22, completion: 0.22 }, 'llama3.1:8b': { prompt: 0.22, completion: 0.22 }, 'llama3.1:70b': { prompt: 0.72, completion: 0.72 }, 'llama3.1:405b': { prompt: 2.4, completion: 2.4 }, + 'llama3.2': { prompt: 0.1, completion: 0.1 }, 'llama3.2:1b': { prompt: 0.1, completion: 0.1 }, 'llama3.2:3b': { prompt: 0.15, completion: 0.15 }, 'llama3.2:11b': { prompt: 0.16, completion: 0.16 }, 'llama3.2:90b': { prompt: 0.72, completion: 0.72 }, + 'llama3.3': { prompt: 2.65, completion: 3.5 }, + 'llama3.3:70b': { prompt: 2.65, completion: 3.5 }, - // llama-3.x-Nb pattern + // llama-3.x-Nb pattern (base defaults to smallest variant) + 'llama-3.1': { prompt: 0.22, completion: 0.22 }, 'llama-3.1-8b': { prompt: 0.22, completion: 0.22 }, 'llama-3.1-70b': { prompt: 0.72, completion: 0.72 }, 'llama-3.1-405b': { prompt: 2.4, completion: 2.4 }, + 'llama-3.2': { prompt: 0.1, completion: 0.1 }, 'llama-3.2-1b': { prompt: 0.1, completion: 0.1 }, 'llama-3.2-3b': { prompt: 0.15, completion: 0.15 }, 'llama-3.2-11b': { prompt: 0.16, completion: 0.16 }, 'llama-3.2-90b': { prompt: 0.72, completion: 0.72 }, + 'llama-3.3': { prompt: 2.65, completion: 3.5 }, 'llama-3.3-70b': { prompt: 2.65, completion: 3.5 }, 'mistral-7b': { prompt: 0.15, completion: 0.2 }, 'mistral-small': { prompt: 0.15, completion: 0.2 }, @@ -52,15 +66,19 @@ const bedrockValues = { 'mistral-large-2407': { prompt: 3.0, completion: 9.0 }, 'command-text': { prompt: 1.5, completion: 2.0 }, 'command-light': { prompt: 0.3, completion: 0.6 }, - 'ai21.j2-mid-v1': { prompt: 12.5, completion: 12.5 }, - 'ai21.j2-ultra-v1': { prompt: 18.8, completion: 18.8 }, - 'ai21.jamba-instruct-v1:0': { prompt: 0.5, completion: 0.7 }, - 'amazon.titan-text-lite-v1': { prompt: 0.15, completion: 0.2 }, - 'amazon.titan-text-express-v1': { prompt: 0.2, completion: 0.6 }, - 'amazon.titan-text-premier-v1:0': { prompt: 0.5, completion: 1.5 }, - 'amazon.nova-micro-v1:0': { prompt: 0.035, completion: 0.14 }, - 'amazon.nova-lite-v1:0': { prompt: 0.06, completion: 0.24 }, - 'amazon.nova-pro-v1:0': { prompt: 0.8, completion: 3.2 }, + // AI21 models + 'j2-mid': { prompt: 12.5, completion: 12.5 }, + 'j2-ultra': { prompt: 18.8, completion: 18.8 }, + 'jamba-instruct': { prompt: 0.5, completion: 0.7 }, + // Amazon Titan models + 'titan-text-lite': { prompt: 0.15, completion: 0.2 }, + 'titan-text-express': { prompt: 0.2, completion: 0.6 }, + 'titan-text-premier': { prompt: 0.5, completion: 1.5 }, + // Amazon Nova models + 'nova-micro': { prompt: 0.035, completion: 0.14 }, + 'nova-lite': { prompt: 0.06, completion: 0.24 }, + 'nova-pro': { prompt: 0.8, completion: 3.2 }, + 'nova-premier': { prompt: 2.5, completion: 12.5 }, 'deepseek.r1': { prompt: 1.35, completion: 5.4 }, }; @@ -71,100 +89,136 @@ const bedrockValues = { */ const tokenValues = Object.assign( { + // Legacy token size mappings (generic patterns - check LAST) '8k': { prompt: 30, completion: 60 }, '32k': { prompt: 60, completion: 120 }, '4k': { prompt: 1.5, completion: 2 }, '16k': { prompt: 3, completion: 4 }, + // Generic fallback patterns (check LAST) + 'claude-': { prompt: 0.8, completion: 2.4 }, + deepseek: { prompt: 0.28, completion: 0.42 }, + command: { prompt: 0.38, completion: 0.38 }, + gemma: { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing) + gemini: { prompt: 0.5, completion: 1.5 }, + 'gpt-oss': { prompt: 0.05, completion: 0.2 }, + // Specific model variants (check FIRST - more specific patterns at end) 'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 }, - 'o4-mini': { prompt: 1.1, completion: 4.4 }, - 'o3-mini': { prompt: 1.1, completion: 4.4 }, - o3: { prompt: 2, completion: 8 }, - 'o1-mini': { prompt: 1.1, completion: 4.4 }, - 'o1-preview': { prompt: 15, completion: 60 }, - o1: { prompt: 15, completion: 60 }, + 'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 }, + 'gpt-4-1106': { prompt: 10, completion: 30 }, + 'gpt-4.1': { prompt: 2, completion: 8 }, 'gpt-4.1-nano': { prompt: 0.1, completion: 0.4 }, 'gpt-4.1-mini': { prompt: 0.4, completion: 1.6 }, - 'gpt-4.1': { prompt: 2, completion: 8 }, 'gpt-4.5': { prompt: 75, completion: 150 }, - 'gpt-4o-mini': { prompt: 0.15, completion: 0.6 }, - 'gpt-5': { prompt: 1.25, completion: 10 }, - 'gpt-5-mini': { prompt: 0.25, completion: 2 }, - 'gpt-5-nano': { prompt: 0.05, completion: 0.4 }, 'gpt-4o': { prompt: 2.5, completion: 10 }, 'gpt-4o-2024-05-13': { prompt: 5, completion: 15 }, - 'gpt-4-1106': { prompt: 10, completion: 30 }, - 'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 }, - 'claude-3-opus': { prompt: 15, completion: 75 }, + 'gpt-4o-mini': { prompt: 0.15, completion: 0.6 }, + 'gpt-5': { prompt: 1.25, completion: 10 }, + 'gpt-5-nano': { prompt: 0.05, completion: 0.4 }, + 'gpt-5-mini': { prompt: 0.25, completion: 2 }, + 'gpt-5-pro': { prompt: 15, completion: 120 }, + o1: { prompt: 15, completion: 60 }, + 'o1-mini': { prompt: 1.1, completion: 4.4 }, + 'o1-preview': { prompt: 15, completion: 60 }, + o3: { prompt: 2, completion: 8 }, + 'o3-mini': { prompt: 1.1, completion: 4.4 }, + 'o4-mini': { prompt: 1.1, completion: 4.4 }, + 'claude-instant': { prompt: 0.8, completion: 2.4 }, + 'claude-2': { prompt: 8, completion: 24 }, + 'claude-2.1': { prompt: 8, completion: 24 }, + 'claude-3-haiku': { prompt: 0.25, completion: 1.25 }, 'claude-3-sonnet': { prompt: 3, completion: 15 }, + 'claude-3-opus': { prompt: 15, completion: 75 }, + 'claude-3-5-haiku': { prompt: 0.8, completion: 4 }, + 'claude-3.5-haiku': { prompt: 0.8, completion: 4 }, 'claude-3-5-sonnet': { prompt: 3, completion: 15 }, 'claude-3.5-sonnet': { prompt: 3, completion: 15 }, 'claude-3-7-sonnet': { prompt: 3, completion: 15 }, 'claude-3.7-sonnet': { prompt: 3, completion: 15 }, - 'claude-3-5-haiku': { prompt: 0.8, completion: 4 }, - 'claude-3.5-haiku': { prompt: 0.8, completion: 4 }, - 'claude-3-haiku': { prompt: 0.25, completion: 1.25 }, - 'claude-sonnet-4': { prompt: 3, completion: 15 }, + 'claude-haiku-4-5': { prompt: 1, completion: 5 }, 'claude-opus-4': { prompt: 15, completion: 75 }, - 'claude-2.1': { prompt: 8, completion: 24 }, - 'claude-2': { prompt: 8, completion: 24 }, - 'claude-instant': { prompt: 0.8, completion: 2.4 }, - 'claude-': { prompt: 0.8, completion: 2.4 }, - 'command-r-plus': { prompt: 3, completion: 15 }, + 'claude-sonnet-4': { prompt: 3, completion: 15 }, 'command-r': { prompt: 0.5, completion: 1.5 }, + 'command-r-plus': { prompt: 3, completion: 15 }, + 'command-text': { prompt: 1.5, completion: 2.0 }, 'deepseek-reasoner': { prompt: 0.28, completion: 0.42 }, - deepseek: { prompt: 0.28, completion: 0.42 }, - /* cohere doesn't have rates for the older command models, - so this was from https://artificialanalysis.ai/models/command-light/providers */ - command: { prompt: 0.38, completion: 0.38 }, - gemma: { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemma-2': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemma-3': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemma-3-27b': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 }, + 'deepseek-r1': { prompt: 0.4, completion: 2.0 }, + 'deepseek-v3': { prompt: 0.2, completion: 0.8 }, + 'gemma-2': { prompt: 0.01, completion: 0.03 }, // Base pattern (using gemma-2-9b pricing) + 'gemma-3': { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing) + 'gemma-3-27b': { prompt: 0.09, completion: 0.16 }, + 'gemini-1.5': { prompt: 2.5, completion: 10 }, + 'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 }, + 'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 }, + 'gemini-2.0': { prompt: 0.1, completion: 0.4 }, // Base pattern (using 2.0-flash pricing) 'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 }, - 'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemini-2.5-pro': { prompt: 1.25, completion: 10 }, + 'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 }, + 'gemini-2.5': { prompt: 0.3, completion: 2.5 }, // Base pattern (using 2.5-flash pricing) 'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 }, 'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 }, - 'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time - 'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 }, - 'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 }, - 'gemini-1.5': { prompt: 2.5, completion: 10 }, + 'gemini-2.5-pro': { prompt: 1.25, completion: 10 }, 'gemini-pro-vision': { prompt: 0.5, completion: 1.5 }, - gemini: { prompt: 0.5, completion: 1.5 }, - 'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 }, - 'grok-2-vision-latest': { prompt: 2.0, completion: 10.0 }, - 'grok-2-vision': { prompt: 2.0, completion: 10.0 }, + grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2 + 'grok-beta': { prompt: 5.0, completion: 15.0 }, 'grok-vision-beta': { prompt: 5.0, completion: 15.0 }, + 'grok-2': { prompt: 2.0, completion: 10.0 }, 'grok-2-1212': { prompt: 2.0, completion: 10.0 }, 'grok-2-latest': { prompt: 2.0, completion: 10.0 }, - 'grok-2': { prompt: 2.0, completion: 10.0 }, - 'grok-3-mini-fast': { prompt: 0.6, completion: 4 }, - 'grok-3-mini': { prompt: 0.3, completion: 0.5 }, - 'grok-3-fast': { prompt: 5.0, completion: 25.0 }, + 'grok-2-vision': { prompt: 2.0, completion: 10.0 }, + 'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 }, + 'grok-2-vision-latest': { prompt: 2.0, completion: 10.0 }, 'grok-3': { prompt: 3.0, completion: 15.0 }, + 'grok-3-fast': { prompt: 5.0, completion: 25.0 }, + 'grok-3-mini': { prompt: 0.3, completion: 0.5 }, + 'grok-3-mini-fast': { prompt: 0.6, completion: 4 }, 'grok-4': { prompt: 3.0, completion: 15.0 }, - 'grok-beta': { prompt: 5.0, completion: 15.0 }, - 'mistral-large': { prompt: 2.0, completion: 6.0 }, - 'pixtral-large': { prompt: 2.0, completion: 6.0 }, - 'mistral-saba': { prompt: 0.2, completion: 0.6 }, codestral: { prompt: 0.3, completion: 0.9 }, - 'ministral-8b': { prompt: 0.1, completion: 0.1 }, 'ministral-3b': { prompt: 0.04, completion: 0.04 }, - // GPT-OSS models - 'gpt-oss': { prompt: 0.05, completion: 0.2 }, + 'ministral-8b': { prompt: 0.1, completion: 0.1 }, + 'mistral-nemo': { prompt: 0.15, completion: 0.15 }, + 'mistral-saba': { prompt: 0.2, completion: 0.6 }, + 'pixtral-large': { prompt: 2.0, completion: 6.0 }, + 'mistral-large': { prompt: 2.0, completion: 6.0 }, + 'mixtral-8x22b': { prompt: 0.65, completion: 0.65 }, + kimi: { prompt: 0.14, completion: 2.49 }, // Base pattern (using kimi-k2 pricing) + // GPT-OSS models (specific sizes) 'gpt-oss:20b': { prompt: 0.05, completion: 0.2 }, 'gpt-oss-20b': { prompt: 0.05, completion: 0.2 }, 'gpt-oss:120b': { prompt: 0.15, completion: 0.6 }, 'gpt-oss-120b': { prompt: 0.15, completion: 0.6 }, - // GLM models (Zhipu AI) + // GLM models (Zhipu AI) - general to specific glm4: { prompt: 0.1, completion: 0.1 }, 'glm-4': { prompt: 0.1, completion: 0.1 }, 'glm-4-32b': { prompt: 0.1, completion: 0.1 }, 'glm-4.5': { prompt: 0.35, completion: 1.55 }, - 'glm-4.5v': { prompt: 0.6, completion: 1.8 }, 'glm-4.5-air': { prompt: 0.14, completion: 0.86 }, + 'glm-4.5v': { prompt: 0.6, completion: 1.8 }, 'glm-4.6': { prompt: 0.5, completion: 1.75 }, + // Qwen models + qwen: { prompt: 0.08, completion: 0.33 }, // Qwen base pattern (using qwen2.5-72b pricing) + 'qwen2.5': { prompt: 0.08, completion: 0.33 }, // Qwen 2.5 base pattern + 'qwen-turbo': { prompt: 0.05, completion: 0.2 }, + 'qwen-plus': { prompt: 0.4, completion: 1.2 }, + 'qwen-max': { prompt: 1.6, completion: 6.4 }, + 'qwq-32b': { prompt: 0.15, completion: 0.4 }, + // Qwen3 models + qwen3: { prompt: 0.035, completion: 0.138 }, // Qwen3 base pattern (using qwen3-4b pricing) + 'qwen3-8b': { prompt: 0.035, completion: 0.138 }, + 'qwen3-14b': { prompt: 0.05, completion: 0.22 }, + 'qwen3-30b-a3b': { prompt: 0.06, completion: 0.22 }, + 'qwen3-32b': { prompt: 0.05, completion: 0.2 }, + 'qwen3-235b-a22b': { prompt: 0.08, completion: 0.55 }, + // Qwen3 VL (Vision-Language) models + 'qwen3-vl-8b-thinking': { prompt: 0.18, completion: 2.1 }, + 'qwen3-vl-8b-instruct': { prompt: 0.18, completion: 0.69 }, + 'qwen3-vl-30b-a3b': { prompt: 0.29, completion: 1.0 }, + 'qwen3-vl-235b-a22b': { prompt: 0.3, completion: 1.2 }, + // Qwen3 specialized models + 'qwen3-max': { prompt: 1.2, completion: 6 }, + 'qwen3-coder': { prompt: 0.22, completion: 0.95 }, + 'qwen3-coder-30b-a3b': { prompt: 0.06, completion: 0.25 }, + 'qwen3-coder-plus': { prompt: 1, completion: 5 }, + 'qwen3-coder-flash': { prompt: 0.3, completion: 1.5 }, + 'qwen3-next-80b-a3b': { prompt: 0.1, completion: 0.8 }, }, bedrockValues, ); @@ -195,67 +249,39 @@ const cacheTokenValues = { * @returns {string|undefined} The key corresponding to the model name, or undefined if no match is found. */ const getValueKey = (model, endpoint) => { + if (!model || typeof model !== 'string') { + return undefined; + } + + // Use findMatchingPattern directly against tokenValues for efficient lookup + if (!endpoint || (typeof endpoint === 'string' && !tokenValues[endpoint])) { + const matchedKey = findMatchingPattern(model, tokenValues); + if (matchedKey) { + return matchedKey; + } + } + + // Fallback: use matchModelName for edge cases and legacy handling const modelName = matchModelName(model, endpoint); if (!modelName) { return undefined; } + // Legacy token size mappings and aliases for older models if (modelName.includes('gpt-3.5-turbo-16k')) { return '16k'; - } else if (modelName.includes('gpt-3.5-turbo-0125')) { - return 'gpt-3.5-turbo-0125'; - } else if (modelName.includes('gpt-3.5-turbo-1106')) { - return 'gpt-3.5-turbo-1106'; } else if (modelName.includes('gpt-3.5')) { return '4k'; - } else if (modelName.includes('o4-mini')) { - return 'o4-mini'; - } else if (modelName.includes('o4')) { - return 'o4'; - } else if (modelName.includes('o3-mini')) { - return 'o3-mini'; - } else if (modelName.includes('o3')) { - return 'o3'; - } else if (modelName.includes('o1-preview')) { - return 'o1-preview'; - } else if (modelName.includes('o1-mini')) { - return 'o1-mini'; - } else if (modelName.includes('o1')) { - return 'o1'; - } else if (modelName.includes('gpt-4.5')) { - return 'gpt-4.5'; - } else if (modelName.includes('gpt-4.1-nano')) { - return 'gpt-4.1-nano'; - } else if (modelName.includes('gpt-4.1-mini')) { - return 'gpt-4.1-mini'; - } else if (modelName.includes('gpt-4.1')) { - return 'gpt-4.1'; - } else if (modelName.includes('gpt-4o-2024-05-13')) { - return 'gpt-4o-2024-05-13'; - } else if (modelName.includes('gpt-5-nano')) { - return 'gpt-5-nano'; - } else if (modelName.includes('gpt-5-mini')) { - return 'gpt-5-mini'; - } else if (modelName.includes('gpt-5')) { - return 'gpt-5'; - } else if (modelName.includes('gpt-4o-mini')) { - return 'gpt-4o-mini'; - } else if (modelName.includes('gpt-4o')) { - return 'gpt-4o'; } else if (modelName.includes('gpt-4-vision')) { - return 'gpt-4-1106'; - } else if (modelName.includes('gpt-4-1106')) { - return 'gpt-4-1106'; + return 'gpt-4-1106'; // Alias for gpt-4-vision } else if (modelName.includes('gpt-4-0125')) { - return 'gpt-4-1106'; + return 'gpt-4-1106'; // Alias for gpt-4-0125 } else if (modelName.includes('gpt-4-turbo')) { - return 'gpt-4-1106'; + return 'gpt-4-1106'; // Alias for gpt-4-turbo } else if (modelName.includes('gpt-4-32k')) { return '32k'; } else if (modelName.includes('gpt-4')) { return '8k'; - } else if (tokenValues[modelName]) { - return modelName; } return undefined; diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js index 3cbce34295..670ea9d5ec 100644 --- a/api/models/tx.spec.js +++ b/api/models/tx.spec.js @@ -1,3 +1,4 @@ +const { maxTokensMap } = require('@librechat/api'); const { EModelEndpoint } = require('librechat-data-provider'); const { defaultRate, @@ -113,6 +114,14 @@ describe('getValueKey', () => { expect(getValueKey('gpt-5-nano-2025-01-30-0130')).toBe('gpt-5-nano'); }); + it('should return "gpt-5-pro" for model type of "gpt-5-pro"', () => { + expect(getValueKey('gpt-5-pro-2025-01-30')).toBe('gpt-5-pro'); + expect(getValueKey('openai/gpt-5-pro')).toBe('gpt-5-pro'); + expect(getValueKey('gpt-5-pro-0130')).toBe('gpt-5-pro'); + expect(getValueKey('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro'); + expect(getValueKey('gpt-5-pro-preview')).toBe('gpt-5-pro'); + }); + it('should return "gpt-4o" for model type of "gpt-4o"', () => { expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o'); expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o'); @@ -288,6 +297,20 @@ describe('getMultiplier', () => { ); }); + it('should return the correct multiplier for gpt-5-pro', () => { + const valueKey = getValueKey('gpt-5-pro-2025-01-30'); + expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-pro'].prompt); + expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe( + tokenValues['gpt-5-pro'].completion, + ); + expect(getMultiplier({ model: 'gpt-5-pro-preview', tokenType: 'prompt' })).toBe( + tokenValues['gpt-5-pro'].prompt, + ); + expect(getMultiplier({ model: 'openai/gpt-5-pro', tokenType: 'completion' })).toBe( + tokenValues['gpt-5-pro'].completion, + ); + }); + it('should return the correct multiplier for gpt-4o', () => { const valueKey = getValueKey('gpt-4o-2024-08-06'); expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt); @@ -471,6 +494,249 @@ describe('AWS Bedrock Model Tests', () => { }); }); +describe('Amazon Model Tests', () => { + describe('Amazon Nova Models', () => { + it('should return correct pricing for nova-premier', () => { + expect(getMultiplier({ model: 'nova-premier', tokenType: 'prompt' })).toBe( + tokenValues['nova-premier'].prompt, + ); + expect(getMultiplier({ model: 'nova-premier', tokenType: 'completion' })).toBe( + tokenValues['nova-premier'].completion, + ); + expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['nova-premier'].prompt, + ); + expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'completion' })).toBe( + tokenValues['nova-premier'].completion, + ); + }); + + it('should return correct pricing for nova-pro', () => { + expect(getMultiplier({ model: 'nova-pro', tokenType: 'prompt' })).toBe( + tokenValues['nova-pro'].prompt, + ); + expect(getMultiplier({ model: 'nova-pro', tokenType: 'completion' })).toBe( + tokenValues['nova-pro'].completion, + ); + expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['nova-pro'].prompt, + ); + expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'completion' })).toBe( + tokenValues['nova-pro'].completion, + ); + }); + + it('should return correct pricing for nova-lite', () => { + expect(getMultiplier({ model: 'nova-lite', tokenType: 'prompt' })).toBe( + tokenValues['nova-lite'].prompt, + ); + expect(getMultiplier({ model: 'nova-lite', tokenType: 'completion' })).toBe( + tokenValues['nova-lite'].completion, + ); + expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['nova-lite'].prompt, + ); + expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'completion' })).toBe( + tokenValues['nova-lite'].completion, + ); + }); + + it('should return correct pricing for nova-micro', () => { + expect(getMultiplier({ model: 'nova-micro', tokenType: 'prompt' })).toBe( + tokenValues['nova-micro'].prompt, + ); + expect(getMultiplier({ model: 'nova-micro', tokenType: 'completion' })).toBe( + tokenValues['nova-micro'].completion, + ); + expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['nova-micro'].prompt, + ); + expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'completion' })).toBe( + tokenValues['nova-micro'].completion, + ); + }); + + it('should match both short and full model names to the same pricing', () => { + const models = ['nova-micro', 'nova-lite', 'nova-pro', 'nova-premier']; + const fullModels = [ + 'amazon.nova-micro-v1:0', + 'amazon.nova-lite-v1:0', + 'amazon.nova-pro-v1:0', + 'amazon.nova-premier-v1:0', + ]; + + models.forEach((shortModel, i) => { + const fullModel = fullModels[i]; + const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' }); + const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' }); + const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' }); + const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' }); + + expect(shortPrompt).toBe(fullPrompt); + expect(shortCompletion).toBe(fullCompletion); + expect(shortPrompt).toBe(tokenValues[shortModel].prompt); + expect(shortCompletion).toBe(tokenValues[shortModel].completion); + }); + }); + }); + + describe('Amazon Titan Models', () => { + it('should return correct pricing for titan-text-premier', () => { + expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-premier'].prompt, + ); + expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'completion' })).toBe( + tokenValues['titan-text-premier'].completion, + ); + expect(getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-premier'].prompt, + ); + expect( + getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'completion' }), + ).toBe(tokenValues['titan-text-premier'].completion); + }); + + it('should return correct pricing for titan-text-express', () => { + expect(getMultiplier({ model: 'titan-text-express', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-express'].prompt, + ); + expect(getMultiplier({ model: 'titan-text-express', tokenType: 'completion' })).toBe( + tokenValues['titan-text-express'].completion, + ); + expect(getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-express'].prompt, + ); + expect( + getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'completion' }), + ).toBe(tokenValues['titan-text-express'].completion); + }); + + it('should return correct pricing for titan-text-lite', () => { + expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-lite'].prompt, + ); + expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'completion' })).toBe( + tokenValues['titan-text-lite'].completion, + ); + expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-lite'].prompt, + ); + expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'completion' })).toBe( + tokenValues['titan-text-lite'].completion, + ); + }); + + it('should match both short and full model names to the same pricing', () => { + const models = ['titan-text-lite', 'titan-text-express', 'titan-text-premier']; + const fullModels = [ + 'amazon.titan-text-lite-v1', + 'amazon.titan-text-express-v1', + 'amazon.titan-text-premier-v1:0', + ]; + + models.forEach((shortModel, i) => { + const fullModel = fullModels[i]; + const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' }); + const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' }); + const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' }); + const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' }); + + expect(shortPrompt).toBe(fullPrompt); + expect(shortCompletion).toBe(fullCompletion); + expect(shortPrompt).toBe(tokenValues[shortModel].prompt); + expect(shortCompletion).toBe(tokenValues[shortModel].completion); + }); + }); + }); +}); + +describe('AI21 Model Tests', () => { + describe('AI21 J2 Models', () => { + it('should return correct pricing for j2-mid', () => { + expect(getMultiplier({ model: 'j2-mid', tokenType: 'prompt' })).toBe( + tokenValues['j2-mid'].prompt, + ); + expect(getMultiplier({ model: 'j2-mid', tokenType: 'completion' })).toBe( + tokenValues['j2-mid'].completion, + ); + expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'prompt' })).toBe( + tokenValues['j2-mid'].prompt, + ); + expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'completion' })).toBe( + tokenValues['j2-mid'].completion, + ); + }); + + it('should return correct pricing for j2-ultra', () => { + expect(getMultiplier({ model: 'j2-ultra', tokenType: 'prompt' })).toBe( + tokenValues['j2-ultra'].prompt, + ); + expect(getMultiplier({ model: 'j2-ultra', tokenType: 'completion' })).toBe( + tokenValues['j2-ultra'].completion, + ); + expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'prompt' })).toBe( + tokenValues['j2-ultra'].prompt, + ); + expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'completion' })).toBe( + tokenValues['j2-ultra'].completion, + ); + }); + + it('should match both short and full model names to the same pricing', () => { + const models = ['j2-mid', 'j2-ultra']; + const fullModels = ['ai21.j2-mid-v1', 'ai21.j2-ultra-v1']; + + models.forEach((shortModel, i) => { + const fullModel = fullModels[i]; + const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' }); + const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' }); + const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' }); + const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' }); + + expect(shortPrompt).toBe(fullPrompt); + expect(shortCompletion).toBe(fullCompletion); + expect(shortPrompt).toBe(tokenValues[shortModel].prompt); + expect(shortCompletion).toBe(tokenValues[shortModel].completion); + }); + }); + }); + + describe('AI21 Jamba Models', () => { + it('should return correct pricing for jamba-instruct', () => { + expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' })).toBe( + tokenValues['jamba-instruct'].prompt, + ); + expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' })).toBe( + tokenValues['jamba-instruct'].completion, + ); + expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['jamba-instruct'].prompt, + ); + expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'completion' })).toBe( + tokenValues['jamba-instruct'].completion, + ); + }); + + it('should match both short and full model names to the same pricing', () => { + const shortPrompt = getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' }); + const fullPrompt = getMultiplier({ + model: 'ai21.jamba-instruct-v1:0', + tokenType: 'prompt', + }); + const shortCompletion = getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' }); + const fullCompletion = getMultiplier({ + model: 'ai21.jamba-instruct-v1:0', + tokenType: 'completion', + }); + + expect(shortPrompt).toBe(fullPrompt); + expect(shortCompletion).toBe(fullCompletion); + expect(shortPrompt).toBe(tokenValues['jamba-instruct'].prompt); + expect(shortCompletion).toBe(tokenValues['jamba-instruct'].completion); + }); + }); +}); + describe('Deepseek Model Tests', () => { const deepseekModels = ['deepseek-chat', 'deepseek-coder', 'deepseek-reasoner', 'deepseek.r1']; @@ -502,6 +768,187 @@ describe('Deepseek Model Tests', () => { }); }); +describe('Qwen3 Model Tests', () => { + describe('Qwen3 Base Models', () => { + it('should return correct pricing for qwen3 base pattern', () => { + expect(getMultiplier({ model: 'qwen3', tokenType: 'prompt' })).toBe( + tokenValues['qwen3'].prompt, + ); + expect(getMultiplier({ model: 'qwen3', tokenType: 'completion' })).toBe( + tokenValues['qwen3'].completion, + ); + }); + + it('should return correct pricing for qwen3-4b (falls back to qwen3)', () => { + expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'completion' })).toBe( + tokenValues['qwen3'].completion, + ); + }); + + it('should return correct pricing for qwen3-8b', () => { + expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-8b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-8b'].completion, + ); + }); + + it('should return correct pricing for qwen3-14b', () => { + expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-14b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-14b'].completion, + ); + }); + + it('should return correct pricing for qwen3-235b-a22b', () => { + expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-235b-a22b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-235b-a22b'].completion, + ); + }); + + it('should handle model name variations with provider prefixes', () => { + const models = [ + { input: 'qwen3', expected: 'qwen3' }, + { input: 'qwen3-4b', expected: 'qwen3' }, + { input: 'qwen3-8b', expected: 'qwen3-8b' }, + { input: 'qwen3-32b', expected: 'qwen3-32b' }, + ]; + models.forEach(({ input, expected }) => { + const withPrefix = `alibaba/${input}`; + expect(getMultiplier({ model: withPrefix, tokenType: 'prompt' })).toBe( + tokenValues[expected].prompt, + ); + expect(getMultiplier({ model: withPrefix, tokenType: 'completion' })).toBe( + tokenValues[expected].completion, + ); + }); + }); + }); + + describe('Qwen3 VL (Vision-Language) Models', () => { + it('should return correct pricing for qwen3-vl-8b-thinking', () => { + expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-vl-8b-thinking'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'completion' })).toBe( + tokenValues['qwen3-vl-8b-thinking'].completion, + ); + }); + + it('should return correct pricing for qwen3-vl-8b-instruct', () => { + expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-vl-8b-instruct'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'completion' })).toBe( + tokenValues['qwen3-vl-8b-instruct'].completion, + ); + }); + + it('should return correct pricing for qwen3-vl-30b-a3b', () => { + expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-vl-30b-a3b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-vl-30b-a3b'].completion, + ); + }); + + it('should return correct pricing for qwen3-vl-235b-a22b', () => { + expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-vl-235b-a22b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-vl-235b-a22b'].completion, + ); + }); + }); + + describe('Qwen3 Specialized Models', () => { + it('should return correct pricing for qwen3-max', () => { + expect(getMultiplier({ model: 'qwen3-max', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-max'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-max', tokenType: 'completion' })).toBe( + tokenValues['qwen3-max'].completion, + ); + }); + + it('should return correct pricing for qwen3-coder', () => { + expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-coder'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'completion' })).toBe( + tokenValues['qwen3-coder'].completion, + ); + }); + + it('should return correct pricing for qwen3-coder-plus', () => { + expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-coder-plus'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'completion' })).toBe( + tokenValues['qwen3-coder-plus'].completion, + ); + }); + + it('should return correct pricing for qwen3-coder-flash', () => { + expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-coder-flash'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'completion' })).toBe( + tokenValues['qwen3-coder-flash'].completion, + ); + }); + + it('should return correct pricing for qwen3-next-80b-a3b', () => { + expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-next-80b-a3b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-next-80b-a3b'].completion, + ); + }); + }); + + describe('Qwen3 Model Variations', () => { + it('should handle all qwen3 models with provider prefixes', () => { + const models = ['qwen3', 'qwen3-8b', 'qwen3-max', 'qwen3-coder', 'qwen3-vl-8b-instruct']; + const prefixes = ['alibaba', 'qwen', 'openrouter']; + + models.forEach((model) => { + prefixes.forEach((prefix) => { + const fullModel = `${prefix}/${model}`; + expect(getMultiplier({ model: fullModel, tokenType: 'prompt' })).toBe( + tokenValues[model].prompt, + ); + expect(getMultiplier({ model: fullModel, tokenType: 'completion' })).toBe( + tokenValues[model].completion, + ); + }); + }); + }); + + it('should handle qwen3-4b falling back to qwen3 base pattern', () => { + const testCases = ['qwen3-4b', 'alibaba/qwen3-4b', 'qwen/qwen3-4b-preview']; + testCases.forEach((model) => { + expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(tokenValues['qwen3'].prompt); + expect(getMultiplier({ model, tokenType: 'completion' })).toBe( + tokenValues['qwen3'].completion, + ); + }); + }); + }); +}); + describe('getCacheMultiplier', () => { it('should return the correct cache multiplier for a given valueKey and cacheType', () => { expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'write' })).toBe( @@ -914,6 +1361,37 @@ describe('Claude Model Tests', () => { ); }); + it('should return correct prompt and completion rates for Claude Haiku 4.5', () => { + expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'prompt' })).toBe( + tokenValues['claude-haiku-4-5'].prompt, + ); + expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'completion' })).toBe( + tokenValues['claude-haiku-4-5'].completion, + ); + }); + + it('should handle Claude Haiku 4.5 model name variations', () => { + const modelVariations = [ + 'claude-haiku-4-5', + 'claude-haiku-4-5-20250420', + 'claude-haiku-4-5-latest', + 'anthropic/claude-haiku-4-5', + 'claude-haiku-4-5/anthropic', + 'claude-haiku-4-5-preview', + ]; + + modelVariations.forEach((model) => { + const valueKey = getValueKey(model); + expect(valueKey).toBe('claude-haiku-4-5'); + expect(getMultiplier({ model, tokenType: 'prompt' })).toBe( + tokenValues['claude-haiku-4-5'].prompt, + ); + expect(getMultiplier({ model, tokenType: 'completion' })).toBe( + tokenValues['claude-haiku-4-5'].completion, + ); + }); + }); + it('should handle Claude 4 model name variations with different prefixes and suffixes', () => { const modelVariations = [ 'claude-sonnet-4', @@ -991,3 +1469,119 @@ describe('Claude Model Tests', () => { }); }); }); + +describe('tokens.ts and tx.js sync validation', () => { + it('should resolve all models in maxTokensMap to pricing via getValueKey', () => { + const tokensKeys = Object.keys(maxTokensMap[EModelEndpoint.openAI]); + const txKeys = Object.keys(tokenValues); + + const unresolved = []; + + tokensKeys.forEach((key) => { + // Skip legacy token size mappings (e.g., '4k', '8k', '16k', '32k') + if (/^\d+k$/.test(key)) return; + + // Skip generic pattern keys (end with '-' or ':') + if (key.endsWith('-') || key.endsWith(':')) return; + + // Try to resolve via getValueKey + const resolvedKey = getValueKey(key); + + // If it resolves and the resolved key has pricing, success + if (resolvedKey && txKeys.includes(resolvedKey)) return; + + // If it resolves to a legacy key (4k, 8k, etc), also OK + if (resolvedKey && /^\d+k$/.test(resolvedKey)) return; + + // If we get here, this model can't get pricing - flag it + unresolved.push({ + key, + resolvedKey: resolvedKey || 'undefined', + context: maxTokensMap[EModelEndpoint.openAI][key], + }); + }); + + if (unresolved.length > 0) { + console.log('\nModels that cannot resolve to pricing via getValueKey:'); + unresolved.forEach(({ key, resolvedKey, context }) => { + console.log(` - '${key}' → '${resolvedKey}' (context: ${context})`); + }); + } + + expect(unresolved).toEqual([]); + }); + + it('should not have redundant dated variants with same pricing and context as base model', () => { + const txKeys = Object.keys(tokenValues); + const redundant = []; + + txKeys.forEach((key) => { + // Check if this is a dated variant (ends with -YYYY-MM-DD) + if (key.match(/.*-\d{4}-\d{2}-\d{2}$/)) { + const baseKey = key.replace(/-\d{4}-\d{2}-\d{2}$/, ''); + + if (txKeys.includes(baseKey)) { + const variantPricing = tokenValues[key]; + const basePricing = tokenValues[baseKey]; + const variantContext = maxTokensMap[EModelEndpoint.openAI][key]; + const baseContext = maxTokensMap[EModelEndpoint.openAI][baseKey]; + + const samePricing = + variantPricing.prompt === basePricing.prompt && + variantPricing.completion === basePricing.completion; + const sameContext = variantContext === baseContext; + + if (samePricing && sameContext) { + redundant.push({ + key, + baseKey, + pricing: `${variantPricing.prompt}/${variantPricing.completion}`, + context: variantContext, + }); + } + } + } + }); + + if (redundant.length > 0) { + console.log('\nRedundant dated variants found (same pricing and context as base):'); + redundant.forEach(({ key, baseKey, pricing, context }) => { + console.log(` - '${key}' → '${baseKey}' (pricing: ${pricing}, context: ${context})`); + console.log(` Can be removed - pattern matching will handle it`); + }); + } + + expect(redundant).toEqual([]); + }); + + it('should have context windows in tokens.ts for all models with pricing in tx.js (openAI catch-all)', () => { + const txKeys = Object.keys(tokenValues); + const missingContext = []; + + txKeys.forEach((key) => { + // Skip legacy token size mappings (4k, 8k, 16k, 32k) + if (/^\d+k$/.test(key)) return; + + // Check if this model has a context window defined + const context = maxTokensMap[EModelEndpoint.openAI][key]; + + if (!context) { + const pricing = tokenValues[key]; + missingContext.push({ + key, + pricing: `${pricing.prompt}/${pricing.completion}`, + }); + } + }); + + if (missingContext.length > 0) { + console.log('\nModels with pricing but missing context in tokens.ts:'); + missingContext.forEach(({ key, pricing }) => { + console.log(` - '${key}' (pricing: ${pricing})`); + console.log(` Add to tokens.ts openAIModels/bedrockModels/etc.`); + }); + } + + expect(missingContext).toEqual([]); + }); +}); diff --git a/api/server/services/Endpoints/agents/agent.js b/api/server/services/Endpoints/agents/agent.js index 1966834ed4..ec9d56d026 100644 --- a/api/server/services/Endpoints/agents/agent.js +++ b/api/server/services/Endpoints/agents/agent.js @@ -143,7 +143,7 @@ const initializeAgent = async ({ const agentMaxContextTokens = optionalChainWithEmptyCheck( maxContextTokens, getModelMaxTokens(tokensModel, providerEndpointMap[provider], options.endpointTokenConfig), - 4096, + 18000, ); if ( diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 162827767f..12daf64e47 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -186,6 +186,19 @@ describe('getModelMaxTokens', () => { ); }); + test('should return correct tokens for gpt-5-pro matches', () => { + expect(getModelMaxTokens('gpt-5-pro')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro']); + expect(getModelMaxTokens('gpt-5-pro-preview')).toBe( + maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'], + ); + expect(getModelMaxTokens('openai/gpt-5-pro')).toBe( + maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'], + ); + expect(getModelMaxTokens('gpt-5-pro-2025-01-30')).toBe( + maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'], + ); + }); + test('should return correct tokens for Anthropic models', () => { const models = [ 'claude-2.1', @@ -469,7 +482,7 @@ describe('getModelMaxTokens', () => { test('should return correct max output tokens for GPT-5 models', () => { const { getModelMaxOutputTokens } = require('@librechat/api'); - ['gpt-5', 'gpt-5-mini', 'gpt-5-nano'].forEach((model) => { + ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro'].forEach((model) => { expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]); expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe( maxOutputTokensMap[EModelEndpoint.openAI][model], @@ -582,6 +595,13 @@ describe('matchModelName', () => { expect(matchModelName('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano'); }); + it('should return the closest matching key for gpt-5-pro matches', () => { + expect(matchModelName('openai/gpt-5-pro')).toBe('gpt-5-pro'); + expect(matchModelName('gpt-5-pro-preview')).toBe('gpt-5-pro'); + expect(matchModelName('gpt-5-pro-2025-01-30')).toBe('gpt-5-pro'); + expect(matchModelName('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro'); + }); + // Tests for Google models it('should return the exact model name if it exists in maxTokensMap - Google models', () => { expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k'); @@ -832,6 +852,49 @@ describe('Claude Model Tests', () => { ); }); + it('should return correct context length for Claude Haiku 4.5', () => { + expect(getModelMaxTokens('claude-haiku-4-5', EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'], + ); + expect(getModelMaxTokens('claude-haiku-4-5')).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'], + ); + }); + + it('should handle Claude Haiku 4.5 model name variations', () => { + const modelVariations = [ + 'claude-haiku-4-5', + 'claude-haiku-4-5-20250420', + 'claude-haiku-4-5-latest', + 'anthropic/claude-haiku-4-5', + 'claude-haiku-4-5/anthropic', + 'claude-haiku-4-5-preview', + ]; + + modelVariations.forEach((model) => { + const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]); + expect(modelKey).toBe('claude-haiku-4-5'); + expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'], + ); + }); + }); + + it('should match model names correctly for Claude Haiku 4.5', () => { + const modelVariations = [ + 'claude-haiku-4-5', + 'claude-haiku-4-5-20250420', + 'claude-haiku-4-5-latest', + 'anthropic/claude-haiku-4-5', + 'claude-haiku-4-5/anthropic', + 'claude-haiku-4-5-preview', + ]; + + modelVariations.forEach((model) => { + expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-haiku-4-5'); + }); + }); + it('should handle Claude 4 model name variations with different prefixes and suffixes', () => { const modelVariations = [ 'claude-sonnet-4', @@ -924,6 +987,121 @@ describe('Kimi Model Tests', () => { }); }); +describe('Qwen3 Model Tests', () => { + describe('getModelMaxTokens', () => { + test('should return correct tokens for Qwen3 base pattern', () => { + expect(getModelMaxTokens('qwen3')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']); + }); + + test('should return correct tokens for qwen3-4b (falls back to qwen3)', () => { + expect(getModelMaxTokens('qwen3-4b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']); + }); + + test('should return correct tokens for Qwen3 base models', () => { + expect(getModelMaxTokens('qwen3-8b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-8b']); + expect(getModelMaxTokens('qwen3-14b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-14b']); + expect(getModelMaxTokens('qwen3-32b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-32b']); + expect(getModelMaxTokens('qwen3-235b-a22b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-235b-a22b'], + ); + }); + + test('should return correct tokens for Qwen3 VL (Vision-Language) models', () => { + expect(getModelMaxTokens('qwen3-vl-8b-thinking')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-thinking'], + ); + expect(getModelMaxTokens('qwen3-vl-8b-instruct')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-instruct'], + ); + expect(getModelMaxTokens('qwen3-vl-30b-a3b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-30b-a3b'], + ); + expect(getModelMaxTokens('qwen3-vl-235b-a22b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-235b-a22b'], + ); + }); + + test('should return correct tokens for Qwen3 specialized models', () => { + expect(getModelMaxTokens('qwen3-max')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-max']); + expect(getModelMaxTokens('qwen3-coder')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-coder'], + ); + expect(getModelMaxTokens('qwen3-coder-30b-a3b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-30b-a3b'], + ); + expect(getModelMaxTokens('qwen3-coder-plus')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-plus'], + ); + expect(getModelMaxTokens('qwen3-coder-flash')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-flash'], + ); + expect(getModelMaxTokens('qwen3-next-80b-a3b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-next-80b-a3b'], + ); + }); + + test('should handle Qwen3 models with provider prefixes', () => { + expect(getModelMaxTokens('alibaba/qwen3')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']); + expect(getModelMaxTokens('alibaba/qwen3-4b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3'], + ); + expect(getModelMaxTokens('qwen/qwen3-8b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-8b'], + ); + expect(getModelMaxTokens('openrouter/qwen3-max')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-max'], + ); + expect(getModelMaxTokens('alibaba/qwen3-vl-8b-instruct')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-instruct'], + ); + expect(getModelMaxTokens('qwen/qwen3-coder')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-coder'], + ); + }); + + test('should handle Qwen3 models with suffixes', () => { + expect(getModelMaxTokens('qwen3-preview')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']); + expect(getModelMaxTokens('qwen3-4b-preview')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3'], + ); + expect(getModelMaxTokens('qwen3-8b-latest')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-8b'], + ); + expect(getModelMaxTokens('qwen3-max-2024')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-max'], + ); + }); + }); + + describe('matchModelName', () => { + test('should match exact Qwen3 model names', () => { + expect(matchModelName('qwen3')).toBe('qwen3'); + expect(matchModelName('qwen3-4b')).toBe('qwen3'); + expect(matchModelName('qwen3-8b')).toBe('qwen3-8b'); + expect(matchModelName('qwen3-vl-8b-thinking')).toBe('qwen3-vl-8b-thinking'); + expect(matchModelName('qwen3-max')).toBe('qwen3-max'); + expect(matchModelName('qwen3-coder')).toBe('qwen3-coder'); + }); + + test('should match Qwen3 model variations with provider prefixes', () => { + expect(matchModelName('alibaba/qwen3')).toBe('qwen3'); + expect(matchModelName('alibaba/qwen3-4b')).toBe('qwen3'); + expect(matchModelName('qwen/qwen3-8b')).toBe('qwen3-8b'); + expect(matchModelName('openrouter/qwen3-max')).toBe('qwen3-max'); + expect(matchModelName('alibaba/qwen3-vl-8b-instruct')).toBe('qwen3-vl-8b-instruct'); + expect(matchModelName('qwen/qwen3-coder')).toBe('qwen3-coder'); + }); + + test('should match Qwen3 model variations with suffixes', () => { + expect(matchModelName('qwen3-preview')).toBe('qwen3'); + expect(matchModelName('qwen3-4b-preview')).toBe('qwen3'); + expect(matchModelName('qwen3-8b-latest')).toBe('qwen3-8b'); + expect(matchModelName('qwen3-max-2024')).toBe('qwen3-max'); + expect(matchModelName('qwen3-coder-v1')).toBe('qwen3-coder'); + }); + }); +}); + describe('GLM Model Tests (Zhipu AI)', () => { describe('getModelMaxTokens', () => { test('should return correct tokens for GLM models', () => { diff --git a/packages/api/src/utils/tokens.ts b/packages/api/src/utils/tokens.ts index d527836642..32921ca851 100644 --- a/packages/api/src/utils/tokens.ts +++ b/packages/api/src/utils/tokens.ts @@ -40,10 +40,10 @@ const openAIModels = { 'gpt-5': 400000, 'gpt-5-mini': 400000, 'gpt-5-nano': 400000, + 'gpt-5-pro': 400000, 'gpt-4o': 127500, // -500 from max 'gpt-4o-mini': 127500, // -500 from max 'gpt-4o-2024-05-13': 127500, // -500 from max - 'gpt-4o-2024-08-06': 127500, // -500 from max 'gpt-4-turbo': 127500, // -500 from max 'gpt-4-vision': 127500, // -500 from max 'gpt-3.5-turbo': 16375, // -10 from max @@ -60,9 +60,11 @@ const mistralModels = { 'mistral-7b': 31990, // -10 from max 'mistral-small': 31990, // -10 from max 'mixtral-8x7b': 31990, // -10 from max + 'mixtral-8x22b': 65536, 'mistral-large': 131000, 'mistral-large-2402': 127500, 'mistral-large-2407': 127500, + 'mistral-nemo': 131000, 'pixtral-large': 131000, 'mistral-saba': 32000, codestral: 256000, @@ -75,6 +77,7 @@ const cohereModels = { 'command-light-nightly': 8182, // -10 from max command: 4086, // -10 from max 'command-nightly': 8182, // -10 from max + 'command-text': 4086, // -10 from max 'command-r': 127500, // -500 from max 'command-r-plus': 127500, // -500 from max }; @@ -127,14 +130,17 @@ const anthropicModels = { 'claude-3.7-sonnet': 200000, 'claude-3-5-sonnet-latest': 200000, 'claude-3.5-sonnet-latest': 200000, + 'claude-haiku-4-5': 200000, 'claude-sonnet-4': 1000000, 'claude-opus-4': 200000, 'claude-4': 200000, }; const deepseekModels = { - 'deepseek-reasoner': 128000, deepseek: 128000, + 'deepseek-reasoner': 128000, + 'deepseek-r1': 128000, + 'deepseek-v3': 128000, 'deepseek.r1': 128000, }; @@ -200,32 +206,57 @@ const metaModels = { 'llama2:70b': 4000, }; -const ollamaModels = { +const qwenModels = { + qwen: 32000, 'qwen2.5': 32000, + 'qwen-turbo': 1000000, + 'qwen-plus': 131000, + 'qwen-max': 32000, + 'qwq-32b': 32000, + // Qwen3 models + qwen3: 40960, // Qwen3 base pattern (using qwen3-4b context) + 'qwen3-8b': 128000, + 'qwen3-14b': 40960, + 'qwen3-30b-a3b': 40960, + 'qwen3-32b': 40960, + 'qwen3-235b-a22b': 40960, + // Qwen3 VL (Vision-Language) models + 'qwen3-vl-8b-thinking': 256000, + 'qwen3-vl-8b-instruct': 262144, + 'qwen3-vl-30b-a3b': 262144, + 'qwen3-vl-235b-a22b': 131072, + // Qwen3 specialized models + 'qwen3-max': 256000, + 'qwen3-coder': 262144, + 'qwen3-coder-30b-a3b': 262144, + 'qwen3-coder-plus': 128000, + 'qwen3-coder-flash': 128000, + 'qwen3-next-80b-a3b': 262144, }; const ai21Models = { - 'ai21.j2-mid-v1': 8182, // -10 from max - 'ai21.j2-ultra-v1': 8182, // -10 from max - 'ai21.jamba-instruct-v1:0': 255500, // -500 from max + 'j2-mid': 8182, // -10 from max + 'j2-ultra': 8182, // -10 from max + 'jamba-instruct': 255500, // -500 from max }; const amazonModels = { - 'amazon.titan-text-lite-v1': 4000, - 'amazon.titan-text-express-v1': 8000, - 'amazon.titan-text-premier-v1:0': 31500, // -500 from max + // Amazon Titan models + 'titan-text-lite': 4000, + 'titan-text-express': 8000, + 'titan-text-premier': 31500, // -500 from max + // Amazon Nova models // https://aws.amazon.com/ai/generative-ai/nova/ - 'amazon.nova-micro-v1:0': 127000, // -1000 from max, - 'amazon.nova-lite-v1:0': 295000, // -5000 from max, - 'amazon.nova-pro-v1:0': 295000, // -5000 from max, - 'amazon.nova-premier-v1:0': 995000, // -5000 from max, + 'nova-micro': 127000, // -1000 from max + 'nova-lite': 295000, // -5000 from max + 'nova-pro': 295000, // -5000 from max + 'nova-premier': 995000, // -5000 from max }; const bedrockModels = { ...anthropicModels, ...mistralModels, ...cohereModels, - ...ollamaModels, ...deepseekModels, ...metaModels, ...ai21Models, @@ -254,6 +285,7 @@ const aggregateModels = { ...googleModels, ...bedrockModels, ...xAIModels, + ...qwenModels, // misc. kimi: 131000, // GPT-OSS @@ -289,6 +321,7 @@ export const modelMaxOutputs = { 'gpt-5': 128000, 'gpt-5-mini': 128000, 'gpt-5-nano': 128000, + 'gpt-5-pro': 128000, 'gpt-oss-20b': 131000, 'gpt-oss-120b': 131000, system_default: 32000, @@ -299,6 +332,7 @@ const anthropicMaxOutputs = { 'claude-3-haiku': 4096, 'claude-3-sonnet': 4096, 'claude-3-opus': 4096, + 'claude-haiku-4-5': 64000, 'claude-opus-4': 32000, 'claude-sonnet-4': 64000, 'claude-3.5-sonnet': 8192, From cbf52eabe3dff7f0234bf955c23872ce7885a78b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:53:21 +0200 Subject: [PATCH 042/272] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#10175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/zh-Hans/translation.json | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/client/src/locales/zh-Hans/translation.json b/client/src/locales/zh-Hans/translation.json index 736caa7dba..8ffa0c5f0c 100644 --- a/client/src/locales/zh-Hans/translation.json +++ b/client/src/locales/zh-Hans/translation.json @@ -365,6 +365,7 @@ "com_error_files_process": "处理文件时发生错误", "com_error_files_upload": "上传文件时发生错误", "com_error_files_upload_canceled": "文件上传请求已取消。注意:文件上传可能仍在进行中,需要手动删除。", + "com_error_files_upload_too_large": "文件过大,请上传小于 {{0}} MB 的文件", "com_error_files_validation": "验证文件时出错。", "com_error_google_tool_conflict": "内置的 Google 工具与外部工具不兼容。请禁用内置工具或外部工具。", "com_error_heic_conversion": "将 HEIC 图片转换为 JPEG 失败。请尝试手动转换图像或使用其他格式。", @@ -560,6 +561,7 @@ "com_nav_setting_balance": "余额", "com_nav_setting_chat": "对话", "com_nav_setting_data": "数据管理", + "com_nav_setting_delay": "延迟(秒)", "com_nav_setting_general": "通用", "com_nav_setting_mcp": "MCP 设置", "com_nav_setting_personalization": "个性化", @@ -759,6 +761,7 @@ "com_ui_client_secret": "Client Secret", "com_ui_close": "关闭", "com_ui_close_menu": "关闭菜单", + "com_ui_close_settings": "关闭设置", "com_ui_close_window": "关闭窗口", "com_ui_code": "代码", "com_ui_collapse_chat": "收起对话", @@ -857,6 +860,7 @@ "com_ui_edit_editing_image": "编辑图片", "com_ui_edit_mcp_server": "编辑 MCP 服务器", "com_ui_edit_memory": "编辑记忆", + "com_ui_editor_instructions": "拖动图片调整位置 • 使用缩放滑块或按钮调整大小", "com_ui_empty_category": "-", "com_ui_endpoint": "端点", "com_ui_endpoint_menu": "LLM 端点菜单", @@ -891,6 +895,7 @@ "com_ui_feedback_tag_unjustified_refusal": "无故拒绝回答", "com_ui_field_max_length": "{{field}} 最多 {{length}} 个字符", "com_ui_field_required": "此字段为必填项", + "com_ui_file_input_avatar_label": "上传文件用作头像", "com_ui_file_size": "文件大小", "com_ui_file_token_limit": "文件词元数限制", "com_ui_file_token_limit_desc": "为文件处理设定最大词元数限制,以控制成本和资源使用", @@ -953,11 +958,13 @@ "com_ui_import_conversation_file_type_error": "不支持的导入类型", "com_ui_import_conversation_info": "从 JSON 文件导入对话", "com_ui_import_conversation_success": "对话导入成功", + "com_ui_import_conversation_upload_error": "上传文件时出错,请重试。", "com_ui_include_shadcnui": "包含 shadcn/ui 组件指令", "com_ui_initializing": "初始化中...", "com_ui_input": "输入", "com_ui_instructions": "指令", "com_ui_key": "键", + "com_ui_key_required": "API Key 为必填项", "com_ui_late_night": "夜深了", "com_ui_latest_footer": "Every AI for Everyone.", "com_ui_latest_production_version": "最新在用版本", @@ -972,6 +979,7 @@ "com_ui_manage": "管理", "com_ui_marketplace": "市场", "com_ui_marketplace_allow_use": "允许使用市场", + "com_ui_max_file_size": "PNG、JPG 或 JPEG(最大 {{0}})", "com_ui_max_tags": "最多允许 {{0}} 个,用最新值。", "com_ui_mcp_authenticated_success": "MCP 服务器 “{{0}}” 认证成功", "com_ui_mcp_configure_server": "配置 {{0}}", @@ -1066,6 +1074,7 @@ "com_ui_privacy_policy": "隐私政策", "com_ui_privacy_policy_url": "隐私政策链接", "com_ui_prompt": "提示词", + "com_ui_prompt_groups": "提示词组列表", "com_ui_prompt_name": "提示词名称", "com_ui_prompt_name_required": "提示词名称为必填项", "com_ui_prompt_preview_not_shared": "作者未允许对此提示词进行协作。", @@ -1095,6 +1104,8 @@ "com_ui_rename_failed": "重命名对话失败", "com_ui_rename_prompt": "重命名 Prompt", "com_ui_requires_auth": "需要认证", + "com_ui_reset": "重置", + "com_ui_reset_adjustments": "重置调整", "com_ui_reset_var": "重置 {{0}}", "com_ui_reset_zoom": "重置缩放", "com_ui_resource": "资源", @@ -1103,6 +1114,8 @@ "com_ui_revoke_info": "撤销所有用户提供的凭据", "com_ui_revoke_key_confirm": "您确定要撤销此密钥吗?", "com_ui_revoke_key_endpoint": "撤销 {{0}} 的密钥", + "com_ui_revoke_key_error": "撤销 API Key 失败,请重试。", + "com_ui_revoke_key_success": "API Key 撤销成功", "com_ui_revoke_keys": "撤销密钥", "com_ui_revoke_keys_confirm": "您确定要撤销所有密钥吗?", "com_ui_role": "角色", @@ -1116,11 +1129,15 @@ "com_ui_role_viewer": "查看者", "com_ui_role_viewer_desc": "可以查看和使用智能体,但无法修改智能体", "com_ui_roleplay": "角色扮演", + "com_ui_rotate": "旋转", + "com_ui_rotate_90": "旋转 90 度", "com_ui_run_code": "运行代码", "com_ui_run_code_error": "代码运行出错", "com_ui_save": "保存", "com_ui_save_badge_changes": "保存徽章更改?", "com_ui_save_changes": "保存修改", + "com_ui_save_key_error": "保存 API Key 失败,请重试。", + "com_ui_save_key_success": "API Key 保存成功", "com_ui_save_submit": "保存并提交", "com_ui_saved": "保存成功!", "com_ui_saving": "保存中...", @@ -1217,6 +1234,7 @@ "com_ui_update_mcp_success": "已成功创建或更新 MCP", "com_ui_upload": "上传", "com_ui_upload_agent_avatar": "成功更新智能体头像", + "com_ui_upload_avatar_label": "上传头像图片", "com_ui_upload_code_files": "上传代码解释器文件", "com_ui_upload_delay": "上传 “{{0}}” 时比预期花了更长时间。文件正在进行检索索引,请稍候。", "com_ui_upload_error": "上传文件错误", @@ -1228,6 +1246,7 @@ "com_ui_upload_invalid": "上传的文件无效。必须是图片,且不得超过大小限制", "com_ui_upload_invalid_var": "上传的文件无效。必须是图片,且不得超过 {{0}} MB。", "com_ui_upload_ocr_text": "作为文本上传", + "com_ui_upload_provider": "上传至提供商", "com_ui_upload_success": "上传文件成功", "com_ui_upload_type": "选择上传类型", "com_ui_usage": "用量", @@ -1267,6 +1286,8 @@ "com_ui_web_search_scraper": "抓取器", "com_ui_web_search_scraper_firecrawl": "Firecrawl API", "com_ui_web_search_scraper_firecrawl_key": "获取您的 Firecrawl API Key", + "com_ui_web_search_scraper_serper": "Serper Scrape API", + "com_ui_web_search_scraper_serper_key": "获取您的 Serper API Key", "com_ui_web_search_searxng_api_key": "输入 SearXNG API Key(可选)", "com_ui_web_search_searxng_instance_url": "SearXNG 实例 URL", "com_ui_web_searching": "正在搜索网络", @@ -1276,5 +1297,8 @@ "com_ui_x_selected": "{{0}} 已选择", "com_ui_yes": "是的", "com_ui_zoom": "缩放", + "com_ui_zoom_in": "放大", + "com_ui_zoom_level": "缩放级别", + "com_ui_zoom_out": "缩小", "com_user_message": "您" } From e3d33fed8da2808737cf182dde098fb3bb86b1e5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 22 Oct 2025 16:51:58 +0200 Subject: [PATCH 043/272] =?UTF-8?q?=F0=9F=93=A6=20chore:=20update=20`@libr?= =?UTF-8?q?echat/agents`=20to=20v2.4.86=20(#10216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/package.json | 2 +- package-lock.json | 10 +++++----- packages/api/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/package.json b/api/package.json index f0b654b1af..44cc252216 100644 --- a/api/package.json +++ b/api/package.json @@ -48,7 +48,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.85", + "@librechat/agents": "^2.4.86", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index 1a789de54d..2575fdfb6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.85", + "@librechat/agents": "^2.4.86", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -21531,9 +21531,9 @@ } }, "node_modules/@librechat/agents": { - "version": "2.4.85", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.85.tgz", - "integrity": "sha512-t6h5f6ApnoEC+x8kqBlke1RR6BPzT+9BvlkA8VxvQVJtYIt5Ey4BOTRDGjdilDoXUcLui11PbjCd17EbjPkTcA==", + "version": "2.4.86", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.86.tgz", + "integrity": "sha512-Z3v+vMfFEyrDWrlPvgY9dUlhzYvtLXYYULEzkxUM1QpITuI3DsXr3xb1kXHAYOx3NmBGxiN9R/gjZN0tGBEo1g==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -51337,7 +51337,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", - "@librechat/agents": "^2.4.85", + "@librechat/agents": "^2.4.86", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", diff --git a/packages/api/package.json b/packages/api/package.json index 05054498e2..a642cf6e23 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -80,7 +80,7 @@ "@azure/storage-blob": "^12.27.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", - "@librechat/agents": "^2.4.85", + "@librechat/agents": "^2.4.86", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", From d8d5d59d922f50ce1eb7277625886d7eee880050 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 22 Oct 2025 22:02:29 +0200 Subject: [PATCH 044/272] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Messag?= =?UTF-8?q?e=20Cache=20Clearing=20Logic=20into=20Reusable=20Helper=20(#102?= =?UTF-8?q?26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Agents/AgentDetail.tsx | 7 ++--- client/src/components/Agents/Marketplace.tsx | 9 +++---- .../components/Chat/Menus/HeaderNewChat.tsx | 9 +++---- client/src/components/Nav/MobileNav.tsx | 6 ++--- client/src/components/Nav/NewChat.tsx | 6 ++--- .../Conversations/useNavigateToConvo.tsx | 10 +++++-- client/src/utils/messages.ts | 26 ++++++++++++++++++- 7 files changed, 45 insertions(+), 28 deletions(-) diff --git a/client/src/components/Agents/AgentDetail.tsx b/client/src/components/Agents/AgentDetail.tsx index 3cbfe330ca..ef77734e30 100644 --- a/client/src/components/Agents/AgentDetail.tsx +++ b/client/src/components/Agents/AgentDetail.tsx @@ -11,9 +11,9 @@ import { AgentListResponse, } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; +import { renderAgentAvatar, clearMessagesCache } from '~/utils'; import { useLocalize, useDefaultConvo } from '~/hooks'; import { useChatContext } from '~/Providers'; -import { renderAgentAvatar } from '~/utils'; interface SupportContact { name?: string; @@ -56,10 +56,7 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id); - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); + clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); /** Template with agent configuration */ diff --git a/client/src/components/Agents/Marketplace.tsx b/client/src/components/Agents/Marketplace.tsx index 97cf1b20cc..ef882142e2 100644 --- a/client/src/components/Agents/Marketplace.tsx +++ b/client/src/components/Agents/Marketplace.tsx @@ -4,7 +4,7 @@ import { useOutletContext } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { useSearchParams, useParams, useNavigate } from 'react-router-dom'; import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client'; -import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider'; +import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; import type { ContextType } from '~/common'; import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks'; @@ -13,11 +13,11 @@ import MarketplaceAdminSettings from './MarketplaceAdminSettings'; import { SidePanelProvider, useChatContext } from '~/Providers'; import { SidePanelGroup } from '~/components/SidePanel'; import { OpenSidebar } from '~/components/Chat/Menus'; +import { cn, clearMessagesCache } from '~/utils'; import CategoryTabs from './CategoryTabs'; import AgentDetail from './AgentDetail'; import SearchBar from './SearchBar'; import AgentGrid from './AgentGrid'; -import { cn } from '~/utils'; import store from '~/store'; interface AgentMarketplaceProps { @@ -224,10 +224,7 @@ const AgentMarketplace: React.FC = ({ className = '' }) = window.open('/c/new', '_blank'); return; } - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); + clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); newConversation(); }; diff --git a/client/src/components/Chat/Menus/HeaderNewChat.tsx b/client/src/components/Chat/Menus/HeaderNewChat.tsx index b2dc6416ab..5245ccbf13 100644 --- a/client/src/components/Chat/Menus/HeaderNewChat.tsx +++ b/client/src/components/Chat/Menus/HeaderNewChat.tsx @@ -1,8 +1,8 @@ +import { QueryKeys } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; -import { QueryKeys, Constants } from 'librechat-data-provider'; import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client'; -import type { TMessage } from 'librechat-data-provider'; import { useChatContext } from '~/Providers'; +import { clearMessagesCache } from '~/utils'; import { useLocalize } from '~/hooks'; export default function HeaderNewChat() { @@ -15,10 +15,7 @@ export default function HeaderNewChat() { window.open('/c/new', '_blank'); return; } - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); + clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); newConversation(); }; diff --git a/client/src/components/Nav/MobileNav.tsx b/client/src/components/Nav/MobileNav.tsx index e945ec5be1..6f11b327ce 100644 --- a/client/src/components/Nav/MobileNav.tsx +++ b/client/src/components/Nav/MobileNav.tsx @@ -5,6 +5,7 @@ import { QueryKeys, Constants } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; import type { Dispatch, SetStateAction } from 'react'; import { useLocalize, useNewConvo } from '~/hooks'; +import { clearMessagesCache } from '~/utils'; import store from '~/store'; export default function MobileNav({ @@ -57,10 +58,7 @@ export default function MobileNav({ aria-label={localize('com_ui_new_chat')} className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover" onClick={() => { - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); + clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); newConversation(); }} diff --git a/client/src/components/Nav/NewChat.tsx b/client/src/components/Nav/NewChat.tsx index b3cdd2cac5..026f115103 100644 --- a/client/src/components/Nav/NewChat.tsx +++ b/client/src/components/Nav/NewChat.tsx @@ -5,6 +5,7 @@ import { QueryKeys, Constants } from 'librechat-data-provider'; import { TooltipAnchor, NewChatIcon, MobileSidebar, Sidebar, Button } from '@librechat/client'; import type { TMessage } from 'librechat-data-provider'; import { useLocalize, useNewConvo } from '~/hooks'; +import { clearMessagesCache } from '~/utils'; import store from '~/store'; export default function NewChat({ @@ -33,10 +34,7 @@ export default function NewChat({ window.open('/c/new', '_blank'); return; } - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); + clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); newConvo(); navigate('/c/new', { state: { focusChat: true } }); diff --git a/client/src/hooks/Conversations/useNavigateToConvo.tsx b/client/src/hooks/Conversations/useNavigateToConvo.tsx index 55f43fa820..2bbb4620b3 100644 --- a/client/src/hooks/Conversations/useNavigateToConvo.tsx +++ b/client/src/hooks/Conversations/useNavigateToConvo.tsx @@ -3,7 +3,13 @@ import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { QueryKeys, Constants, dataService } from 'librechat-data-provider'; import type { TConversation, TEndpointsConfig, TModelsConfig } from 'librechat-data-provider'; -import { buildDefaultConvo, getDefaultEndpoint, getEndpointField, logger } from '~/utils'; +import { + getDefaultEndpoint, + clearMessagesCache, + buildDefaultConvo, + getEndpointField, + logger, +} from '~/utils'; import store from '~/store'; const useNavigateToConvo = (index = 0) => { @@ -80,7 +86,7 @@ const useNavigateToConvo = (index = 0) => { }); } clearAllConversations(true); - queryClient.setQueryData([QueryKeys.messages, currentConvoId], []); + clearMessagesCache(queryClient, currentConvoId); if (convo.conversationId !== Constants.NEW_CONVO && convo.conversationId) { queryClient.invalidateQueries([QueryKeys.conversation, convo.conversationId]); fetchFreshData(convo); diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index fe8ec36499..d436c45077 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -1,5 +1,6 @@ -import { ContentTypes } from 'librechat-data-provider'; +import { ContentTypes, QueryKeys, Constants } from 'librechat-data-provider'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; +import type { QueryClient } from '@tanstack/react-query'; export const TEXT_KEY_DIVIDER = '|||'; @@ -146,3 +147,26 @@ export const scrollToEnd = (callback?: () => void) => { } } }; + +/** + * Clears messages for both the specified conversation ID and the NEW_CONVO query key. + * This ensures that messages are properly cleared in all contexts, preventing stale data + * from persisting in the NEW_CONVO cache. + * + * @param queryClient - The React Query client instance + * @param conversationId - The conversation ID to clear messages for + */ +export const clearMessagesCache = ( + queryClient: QueryClient, + conversationId: string | undefined | null, +): void => { + const convoId = conversationId ?? Constants.NEW_CONVO; + + // Clear messages for the current conversation + queryClient.setQueryData([QueryKeys.messages, convoId], []); + + // Also clear NEW_CONVO messages if we're not already on NEW_CONVO + if (convoId !== Constants.NEW_CONVO) { + queryClient.setQueryData([QueryKeys.messages, Constants.NEW_CONVO], []); + } +}; From 87d7ee4b0ef0078c80ae7a9b54ad066344072ffe Mon Sep 17 00:00:00 2001 From: Sebastien Bruel <93573440+sbruel@users.noreply.github.com> Date: Thu, 23 Oct 2025 05:04:49 +0900 Subject: [PATCH 045/272] =?UTF-8?q?=F0=9F=8C=90=20feat:=20Configurable=20D?= =?UTF-8?q?omain=20and=20Port=20for=20Vite=20Dev=20Server=20(#10180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/vite.config.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/client/vite.config.ts b/client/vite.config.ts index a356e246a1..f49e6bc9cb 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,4 +1,5 @@ import react from '@vitejs/plugin-react'; +// @ts-ignore import path from 'path'; import type { Plugin } from 'vite'; import { defineConfig } from 'vite'; @@ -7,19 +8,23 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills'; import { VitePWA } from 'vite-plugin-pwa'; // https://vitejs.dev/config/ +const backendPort = process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT) || 3080; +const backendURL = process.env.HOST ? `http://${process.env.HOST}:${backendPort}` : `http://localhost:${backendPort}`; + export default defineConfig(({ command }) => ({ base: '', server: { - host: 'localhost', - port: 3090, + allowedHosts: process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',') || [], + host: process.env.HOST || 'localhost', + port: process.env.PORT && Number(process.env.PORT) || 3090, strictPort: false, proxy: { '/api': { - target: 'http://localhost:3080', + target: backendURL, changeOrigin: true, }, '/oauth': { - target: 'http://localhost:3080', + target: backendURL, changeOrigin: true, }, }, @@ -259,6 +264,7 @@ export default defineConfig(({ command }) => ({ interface SourcemapExclude { excludeNodeModules?: boolean; } + export function sourcemapExclude(opts?: SourcemapExclude): Plugin { return { name: 'sourcemap-exclude', From 9495520f6f23391211ad3f00f1a992c378027bae Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 22 Oct 2025 22:22:57 +0200 Subject: [PATCH 046/272] =?UTF-8?q?=F0=9F=93=A6=20chore:=20update=20`vite`?= =?UTF-8?q?=20to=20v6.4.1=20and=20`@playwright/test`=20to=20v1.56.1=20(#10?= =?UTF-8?q?227)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📦 chore: update vite to v6.4.1 * 📦 chore: update @playwright/test to v1.56.1 --- client/package.json | 2 +- package-lock.json | 135 +++++++++++++++++++++++++++++++++++++++----- package.json | 2 +- 3 files changed, 124 insertions(+), 15 deletions(-) diff --git a/client/package.json b/client/package.json index b46f77cbd8..dcd5f637a1 100644 --- a/client/package.json +++ b/client/package.json @@ -149,7 +149,7 @@ "tailwindcss": "^3.4.1", "ts-jest": "^29.2.5", "typescript": "^5.3.3", - "vite": "^6.3.6", + "vite": "^6.4.1", "vite-plugin-compression2": "^2.2.1", "vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-pwa": "^0.21.2" diff --git a/package-lock.json b/package-lock.json index 2575fdfb6a..cf3bb750eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.20.0", "@microsoft/eslint-formatter-sarif": "^3.1.0", - "@playwright/test": "^1.50.1", + "@playwright/test": "^1.56.1", "@types/react-virtualized": "^9.22.0", "caniuse-lite": "^1.0.30001741", "cross-env": "^7.0.3", @@ -2768,7 +2768,7 @@ "tailwindcss": "^3.4.1", "ts-jest": "^29.2.5", "typescript": "^5.3.3", - "vite": "^6.3.6", + "vite": "^6.4.1", "vite-plugin-compression2": "^2.2.1", "vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-pwa": "^0.21.2" @@ -4305,6 +4305,24 @@ "node": ">=6" } }, + "client/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "client/node_modules/framer-motion": { "version": "11.18.2", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", @@ -4358,6 +4376,19 @@ "dev": true, "license": "MIT" }, + "client/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "client/node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -4453,6 +4484,81 @@ "browserslist": ">= 4.21.0" } }, + "client/node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "client/node_modules/vite-plugin-pwa": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz", @@ -22902,12 +23008,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz", - "integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.50.1" + "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -43042,12 +43148,12 @@ } }, "node_modules/playwright": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz", - "integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.50.1" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -43060,9 +43166,9 @@ } }, "node_modules/playwright-core": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz", - "integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -49973,6 +50079,7 @@ "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -50076,6 +50183,7 @@ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.0.0" }, @@ -50094,6 +50202,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 4a69267be2..cfd5d53691 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.20.0", "@microsoft/eslint-formatter-sarif": "^3.1.0", - "@playwright/test": "^1.50.1", + "@playwright/test": "^1.56.1", "@types/react-virtualized": "^9.22.0", "caniuse-lite": "^1.0.30001741", "cross-env": "^7.0.3", From 05c91951974828a6f64b9b2cccd57ba2bcd97394 Mon Sep 17 00:00:00 2001 From: Sebastien Bruel <93573440+sbruel@users.noreply.github.com> Date: Mon, 27 Oct 2025 10:30:05 +0900 Subject: [PATCH 047/272] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20Agent=20?= =?UTF-8?q?Tools=20Modal=20on=20First-Time=20Agent=20Creation=20(#10234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/Providers/AgentPanelContext.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index 3925492534..4effd7d679 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -35,9 +35,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) enabled: !isEphemeralAgent(agent_id), }); - const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents, { - enabled: !isEphemeralAgent(agent_id), - }); + const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents); const { data: mcpData } = useMCPToolsQuery({ enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null, From cbbbde36814b0f99a691543c6a5cc487104e2d84 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:32:38 -0400 Subject: [PATCH 048/272] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#10229)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/lv/translation.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index ac3fe25876..e4fd26b544 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -628,7 +628,7 @@ "com_ui_2fa_invalid": "Nederīgs divfaktoru autentifikācijas kods", "com_ui_2fa_setup": "Iestatīt 2FA", "com_ui_2fa_verified": "Divfaktoru autentifikācija veiksmīgi verificēta", - "com_ui_accept": "Es piekrītu", + "com_ui_accept": "Piekrītu", "com_ui_action_button": "Darbības poga", "com_ui_active": "Aktīvais", "com_ui_add": "Pievienot", @@ -814,7 +814,7 @@ "com_ui_date_september": "Septembris", "com_ui_date_today": "Šodien", "com_ui_date_yesterday": "Vakar", - "com_ui_decline": "Es nepiekrītu", + "com_ui_decline": "Nepiekrītu", "com_ui_default_post_request": "Noklusējums (POST pieprasījums)", "com_ui_delete": "Dzēst", "com_ui_delete_action": "Dzēst darbību", @@ -1173,7 +1173,7 @@ "com_ui_share_delete_error": "Dzēšot koplietoto saiti, radās kļūda.", "com_ui_share_error": "Kopīgojot sarunas saiti, radās kļūda.", "com_ui_share_everyone": "Koplietot ar visiem", - "com_ui_share_everyone_description_var": "Šis {{resource}} būs pieejams ikvienam. Lūdzu, pārliecinieties, ka {{resource}} patiesībā ir paredzēts koplietošanai ar visiem. Esiet uzmanīgi ar saviem datiem.", + "com_ui_share_everyone_description_var": "Šis {{resource}} būs pieejams ikvienam. Lūdzu, pārliecinieties, ka {{resource}} patiesībā ir paredzēts koplietošanai visiem. Esiet uzmanīgi ar saviem datiem.", "com_ui_share_link_to_chat": "Kopīgot saiti sarunai", "com_ui_share_update_message": "Jūsu vārds, pielāgotie norādījumi un visas ziņas, ko pievienojat pēc kopīgošanas, paliek privātas.", "com_ui_share_var": "Kopīgot {{0}}", From 90e610cedad4ec7be5e1fcb7e88abdf915745803 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 26 Oct 2025 21:37:55 -0400 Subject: [PATCH 049/272] =?UTF-8?q?=F0=9F=8E=AA=20refactor:=20Allow=20Last?= =?UTF-8?q?=20Model=20Spec=20Selection=20without=20Prioritizing=20(#10258)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Default Model Spec Retrieval Logic, allowing last selected spec on new chat if last selection was a spec * chore: Replace hardcoded 'new' conversation ID with Constants.NEW_CONVO for consistency * chore: remove redundant condition for model spec preset selection in useNewConvo hook --- client/src/hooks/useNewConvo.ts | 8 +++++--- client/src/routes/ChatRoute.tsx | 6 ++++-- client/src/utils/endpoints.ts | 20 +++++++++++++------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index 22ea5f327c..63b442b83a 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -252,18 +252,20 @@ const useNewConvo = (index = 0) => { }; let preset = _preset; - const defaultModelSpec = getDefaultModelSpec(startupConfig); + const result = getDefaultModelSpec(startupConfig); + const defaultModelSpec = result?.default ?? result?.last; if ( !preset && startupConfig && (startupConfig.modelSpecs?.prioritize === true || - (startupConfig.interface?.modelSelect ?? true) !== true) && + (startupConfig.interface?.modelSelect ?? true) !== true || + (result?.last != null && Object.keys(_template).length === 0)) && defaultModelSpec ) { preset = getModelSpecPreset(defaultModelSpec); } - if (conversation.conversationId === 'new' && !modelsData) { + if (conversation.conversationId === Constants.NEW_CONVO && !modelsData) { const filesToDelete = Array.from(files.values()) .filter( (file) => diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index d81cbc075c..240b5583b9 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -66,7 +66,8 @@ export default function ChatRoute() { } if (conversationId === Constants.NEW_CONVO && endpointsQuery.data && modelsQuery.data) { - const spec = getDefaultModelSpec(startupConfig); + const result = getDefaultModelSpec(startupConfig); + const spec = result?.default ?? result?.last; logger.log('conversation', 'ChatRoute, new convo effect', conversation); newConversation({ modelsData: modelsQuery.data, @@ -90,7 +91,8 @@ export default function ChatRoute() { assistantListMap[EModelEndpoint.assistants] && assistantListMap[EModelEndpoint.azureAssistants] ) { - const spec = getDefaultModelSpec(startupConfig); + const result = getDefaultModelSpec(startupConfig); + const spec = result?.default ?? result?.last; logger.log('conversation', 'ChatRoute new convo, assistants effect', conversation); newConversation({ modelsData: modelsQuery.data, diff --git a/client/src/utils/endpoints.ts b/client/src/utils/endpoints.ts index 60585132d4..c98680843a 100644 --- a/client/src/utils/endpoints.ts +++ b/client/src/utils/endpoints.ts @@ -176,11 +176,17 @@ export function getConvoSwitchLogic(params: ConversationInitParams): InitiatedTe }; } -/** Gets the default spec by order. - * - * First, the admin defined default, then last selected spec, followed by first spec +/** + * Gets default model spec from config and user preferences. + * Priority: admin default → last selected → first spec (when prioritize=true or modelSelect disabled). + * Otherwise: admin default or last conversation spec. */ -export function getDefaultModelSpec(startupConfig?: t.TStartupConfig) { +export function getDefaultModelSpec(startupConfig?: t.TStartupConfig): + | { + default?: t.TModelSpec; + last?: t.TModelSpec; + } + | undefined { const { modelSpecs, interface: interfaceConfig } = startupConfig ?? {}; const { list, prioritize } = modelSpecs ?? {}; if (!list) { @@ -190,9 +196,9 @@ export function getDefaultModelSpec(startupConfig?: t.TStartupConfig) { if (prioritize === true || !interfaceConfig?.modelSelect) { const lastSelectedSpecName = localStorage.getItem(LocalStorageKeys.LAST_SPEC); const lastSelectedSpec = list?.find((spec) => spec.name === lastSelectedSpecName); - return defaultSpec || lastSelectedSpec || list?.[0]; + return { default: defaultSpec || lastSelectedSpec || list?.[0] }; } else if (defaultSpec) { - return defaultSpec; + return { default: defaultSpec }; } const lastConversationSetup = JSON.parse( localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0') ?? '{}', @@ -200,7 +206,7 @@ export function getDefaultModelSpec(startupConfig?: t.TStartupConfig) { if (!lastConversationSetup.spec) { return; } - return list?.find((spec) => spec.name === lastConversationSetup.spec); + return { last: list?.find((spec) => spec.name === lastConversationSetup.spec) }; } export function getModelSpecPreset(modelSpec?: t.TModelSpec) { From 13b784a3e68ab26f6c3cd16a7e9801fba576f34f Mon Sep 17 00:00:00 2001 From: Federico Ruggi Date: Mon, 27 Oct 2025 02:48:23 +0100 Subject: [PATCH 050/272] =?UTF-8?q?=F0=9F=A7=BC=20fix:=20Sanitize=20MCP=20?= =?UTF-8?q?Server=20Selection=20Against=20Config=20(#10243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * filter out unavailable servers * bump render time * Fix import path for useGetStartupConfig * refactor: Change configuredServers to use Set for improved filtering of available MCPs --------- Co-authored-by: Danny Avila --- .../VirtualScrollingPerformance.test.tsx | 2 +- .../hooks/MCP/__tests__/useMCPSelect.test.tsx | 104 +++++++++++++++--- client/src/hooks/MCP/useMCPSelect.ts | 16 ++- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx index 1efb239308..1e1b7d1e4b 100644 --- a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx +++ b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx @@ -194,7 +194,7 @@ describe('Virtual Scrolling Performance', () => { // Performance check: rendering should be fast const renderTime = endTime - startTime; - expect(renderTime).toBeLessThan(720); + expect(renderTime).toBeLessThan(740); console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`); console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`); diff --git a/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx b/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx index b562b77d86..7145e95e74 100644 --- a/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx +++ b/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx @@ -6,6 +6,7 @@ import { Constants, LocalStorageKeys } from 'librechat-data-provider'; import { ephemeralAgentByConvoId } from '~/store'; import { setTimestamp } from '~/utils/timestamps'; import { useMCPSelect } from '../useMCPSelect'; +import * as dataProvider from '~/data-provider'; // Mock dependencies jest.mock('~/utils/timestamps', () => ({ @@ -14,10 +15,21 @@ jest.mock('~/utils/timestamps', () => ({ jest.mock('lodash/isEqual', () => jest.fn((a, b) => JSON.stringify(a) === JSON.stringify(b))); -const createWrapper = () => { +jest.mock('~/data-provider', () => ({ + ...jest.requireActual('~/data-provider'), + useGetStartupConfig: jest.fn(), +})); + +const createWrapper = (mcpServers: string[] = []) => { // Create a new Jotai store for each test to ensure clean state const store = createStore(); + // Mock the startup config + (dataProvider.useGetStartupConfig as jest.Mock).mockReturnValue({ + data: { mcpServers: Object.fromEntries(mcpServers.map((v) => [v, {}])) }, + isLoading: false, + }); + const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} @@ -65,7 +77,7 @@ describe('useMCPSelect', () => { describe('State Updates', () => { it('should update mcpValues when setMCPValues is called', async () => { const { result } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + wrapper: createWrapper(['value1', 'value2']), }); const newValues = ['value1', 'value2']; @@ -229,7 +241,7 @@ describe('useMCPSelect', () => { const { result, rerender } = renderHook( ({ conversationId }) => useMCPSelect({ conversationId }), { - wrapper: createWrapper(), + wrapper: createWrapper(['convo1-value', 'convo2-value']), initialProps: { conversationId: 'convo1' }, }, ); @@ -271,7 +283,7 @@ describe('useMCPSelect', () => { describe('Ephemeral Agent Synchronization', () => { it('should sync mcpValues when ephemeralAgent is updated externally', async () => { // Create a shared wrapper for both hooks to share the same Recoil/Jotai context - const wrapper = createWrapper(); + const wrapper = createWrapper(['external-value1', 'external-value2']); // Create a component that uses both hooks to ensure they share state const TestComponent = () => { @@ -298,9 +310,75 @@ describe('useMCPSelect', () => { }); }); + it('should filter out MCPs not in configured servers', async () => { + const wrapper = createWrapper(['server1', 'server2']); + + const TestComponent = () => { + const mcpHook = useMCPSelect({}); + const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); + return { mcpHook, setEphemeralAgent }; + }; + + const { result } = renderHook(() => TestComponent(), { wrapper }); + + act(() => { + result.current.setEphemeralAgent({ + mcp: ['server1', 'removed-server', 'server2'], + }); + }); + + await waitFor(() => { + expect(result.current.mcpHook.mcpValues).toEqual(['server1', 'server2']); + }); + }); + + it('should clear all MCPs when none are in configured servers', async () => { + const wrapper = createWrapper(['server1', 'server2']); + + const TestComponent = () => { + const mcpHook = useMCPSelect({}); + const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); + return { mcpHook, setEphemeralAgent }; + }; + + const { result } = renderHook(() => TestComponent(), { wrapper }); + + act(() => { + result.current.setEphemeralAgent({ + mcp: ['removed1', 'removed2', 'removed3'], + }); + }); + + await waitFor(() => { + expect(result.current.mcpHook.mcpValues).toEqual([]); + }); + }); + + it('should keep all MCPs when all are in configured servers', async () => { + const wrapper = createWrapper(['server1', 'server2', 'server3']); + + const TestComponent = () => { + const mcpHook = useMCPSelect({}); + const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); + return { mcpHook, setEphemeralAgent }; + }; + + const { result } = renderHook(() => TestComponent(), { wrapper }); + + act(() => { + result.current.setEphemeralAgent({ + mcp: ['server1', 'server2'], + }); + }); + + await waitFor(() => { + expect(result.current.mcpHook.mcpValues).toEqual(['server1', 'server2']); + }); + }); + it('should update ephemeralAgent when mcpValues changes through hook', async () => { // Create a shared wrapper for both hooks - const wrapper = createWrapper(); + const wrapper = createWrapper(['hook-value1', 'hook-value2']); // Create a component that uses both the hook and accesses Recoil state const TestComponent = () => { @@ -326,7 +404,7 @@ describe('useMCPSelect', () => { it('should handle empty ephemeralAgent.mcp array correctly', async () => { // Create a shared wrapper - const wrapper = createWrapper(); + const wrapper = createWrapper(['initial-value']); // Create a component that uses both hooks const TestComponent = () => { @@ -360,7 +438,7 @@ describe('useMCPSelect', () => { it('should properly sync non-empty arrays from ephemeralAgent', async () => { // Additional test to ensure non-empty arrays DO sync - const wrapper = createWrapper(); + const wrapper = createWrapper(['value1', 'value2', 'value3', 'value4', 'value5']); const TestComponent = () => { const mcpHook = useMCPSelect({}); @@ -401,7 +479,7 @@ describe('useMCPSelect', () => { describe('Edge Cases', () => { it('should handle undefined conversationId', () => { const { result } = renderHook(() => useMCPSelect({ conversationId: undefined }), { - wrapper: createWrapper(), + wrapper: createWrapper(['test']), }); expect(result.current.mcpValues).toEqual([]); @@ -422,11 +500,10 @@ describe('useMCPSelect', () => { }); it('should handle very large arrays without performance issues', async () => { - const { result } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), - }); - const largeArray = Array.from({ length: 1000 }, (_, i) => `value-${i}`); + const { result } = renderHook(() => useMCPSelect({}), { + wrapper: createWrapper(largeArray), + }); const startTime = performance.now(); @@ -457,8 +534,9 @@ describe('useMCPSelect', () => { describe('Memory Leak Prevention', () => { it('should not leak memory on repeated updates', async () => { + const values = Array.from({ length: 100 }, (_, i) => `value-${i}`); const { result } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + wrapper: createWrapper(values), }); // Perform many updates to test for memory leaks diff --git a/client/src/hooks/MCP/useMCPSelect.ts b/client/src/hooks/MCP/useMCPSelect.ts index dc3ac869c9..3f37bb4d70 100644 --- a/client/src/hooks/MCP/useMCPSelect.ts +++ b/client/src/hooks/MCP/useMCPSelect.ts @@ -1,13 +1,18 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useAtom } from 'jotai'; import isEqual from 'lodash/isEqual'; import { useRecoilState } from 'recoil'; import { Constants, LocalStorageKeys } from 'librechat-data-provider'; import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store'; +import { useGetStartupConfig } from '~/data-provider'; import { setTimestamp } from '~/utils/timestamps'; export function useMCPSelect({ conversationId }: { conversationId?: string | null }) { const key = conversationId ?? Constants.NEW_CONVO; + const { data: startupConfig } = useGetStartupConfig(); + const configuredServers = useMemo(() => { + return new Set(Object.keys(startupConfig?.mcpServers ?? {})); + }, [startupConfig?.mcpServers]); const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom); const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key)); @@ -15,10 +20,13 @@ export function useMCPSelect({ conversationId }: { conversationId?: string | nul // Sync Jotai state with ephemeral agent state useEffect(() => { - if (ephemeralAgent?.mcp && ephemeralAgent.mcp.length > 0) { - setMCPValuesRaw(ephemeralAgent.mcp); + const mcps = ephemeralAgent?.mcp ?? []; + if (mcps.length > 0) { + // Strip out servers that are not available in the startup config + const activeMcps = mcps.filter((mcp) => configuredServers.has(mcp)); + setMCPValuesRaw(activeMcps); } - }, [ephemeralAgent?.mcp, setMCPValuesRaw]); + }, [ephemeralAgent?.mcp, setMCPValuesRaw, configuredServers]); useEffect(() => { setEphemeralAgent((prev) => { From d46dde4e01f90f8efe17b0e803ce09cb9e37162d Mon Sep 17 00:00:00 2001 From: Max Sanna Date: Mon, 27 Oct 2025 02:58:29 +0100 Subject: [PATCH 051/272] =?UTF-8?q?=F0=9F=91=AB=20fix:=20Update=20Entra=20?= =?UTF-8?q?ID=20group=20retrieval=20to=20use=20getMemberGroups=20and=20add?= =?UTF-8?q?=20pagination=20support=20(#10199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/services/GraphApiService.js | 47 +++++--- api/server/services/GraphApiService.spec.js | 118 +++++++++++++++++--- 2 files changed, 134 insertions(+), 31 deletions(-) diff --git a/api/server/services/GraphApiService.js b/api/server/services/GraphApiService.js index 82fa245d58..08ca253964 100644 --- a/api/server/services/GraphApiService.js +++ b/api/server/services/GraphApiService.js @@ -159,7 +159,7 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li /** * Get current user's Entra ID group memberships from Microsoft Graph - * Uses /me/memberOf endpoint to get groups the user is a member of + * Uses /me/getMemberGroups endpoint to get transitive groups the user is a member of * @param {string} accessToken - OpenID Connect access token * @param {string} sub - Subject identifier * @returns {Promise>} Array of group ID strings (GUIDs) @@ -167,10 +167,12 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li const getUserEntraGroups = async (accessToken, sub) => { try { const graphClient = await createGraphClient(accessToken, sub); + const response = await graphClient + .api('/me/getMemberGroups') + .post({ securityEnabledOnly: false }); - const groupsResponse = await graphClient.api('/me/memberOf').select('id').get(); - - return (groupsResponse.value || []).map((group) => group.id); + const groupIds = Array.isArray(response?.value) ? response.value : []; + return [...new Set(groupIds.map((groupId) => String(groupId)))]; } catch (error) { logger.error('[getUserEntraGroups] Error fetching user groups:', error); return []; @@ -187,13 +189,22 @@ const getUserEntraGroups = async (accessToken, sub) => { const getUserOwnedEntraGroups = async (accessToken, sub) => { try { const graphClient = await createGraphClient(accessToken, sub); + const allGroupIds = []; + let nextLink = '/me/ownedObjects/microsoft.graph.group'; - const groupsResponse = await graphClient - .api('/me/ownedObjects/microsoft.graph.group') - .select('id') - .get(); + while (nextLink) { + const response = await graphClient.api(nextLink).select('id').top(999).get(); + const groups = response?.value || []; + allGroupIds.push(...groups.map((group) => group.id)); - return (groupsResponse.value || []).map((group) => group.id); + nextLink = response['@odata.nextLink'] + ? response['@odata.nextLink'] + .replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '') + .trim() || null + : null; + } + + return allGroupIds; } catch (error) { logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error); return []; @@ -211,21 +222,27 @@ const getUserOwnedEntraGroups = async (accessToken, sub) => { const getGroupMembers = async (accessToken, sub, groupId) => { try { const graphClient = await createGraphClient(accessToken, sub); - const allMembers = []; - let nextLink = `/groups/${groupId}/members`; + const allMembers = new Set(); + let nextLink = `/groups/${groupId}/transitiveMembers`; while (nextLink) { const membersResponse = await graphClient.api(nextLink).select('id').top(999).get(); - const members = membersResponse.value || []; - allMembers.push(...members.map((member) => member.id)); + const members = membersResponse?.value || []; + members.forEach((member) => { + if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') { + allMembers.add(member.id); + } + }); nextLink = membersResponse['@odata.nextLink'] - ? membersResponse['@odata.nextLink'].split('/v1.0')[1] + ? membersResponse['@odata.nextLink'] + .replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '') + .trim() || null : null; } - return allMembers; + return Array.from(allMembers); } catch (error) { logger.error('[getGroupMembers] Error fetching group members:', error); return []; diff --git a/api/server/services/GraphApiService.spec.js b/api/server/services/GraphApiService.spec.js index 5d8dd62cf5..fa11190cc3 100644 --- a/api/server/services/GraphApiService.spec.js +++ b/api/server/services/GraphApiService.spec.js @@ -73,6 +73,7 @@ describe('GraphApiService', () => { header: jest.fn().mockReturnThis(), top: jest.fn().mockReturnThis(), get: jest.fn(), + post: jest.fn(), }; Client.init.mockReturnValue(mockGraphClient); @@ -514,31 +515,33 @@ describe('GraphApiService', () => { }); describe('getUserEntraGroups', () => { - it('should fetch user groups from memberOf endpoint', async () => { + it('should fetch user groups using getMemberGroups endpoint', async () => { const mockGroupsResponse = { - value: [ - { - id: 'group-1', - }, - { - id: 'group-2', - }, - ], + value: ['group-1', 'group-2'], }; - mockGraphClient.get.mockResolvedValue(mockGroupsResponse); + mockGraphClient.post.mockResolvedValue(mockGroupsResponse); const result = await GraphApiService.getUserEntraGroups('token', 'user'); - expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf'); - expect(mockGraphClient.select).toHaveBeenCalledWith('id'); + expect(mockGraphClient.api).toHaveBeenCalledWith('/me/getMemberGroups'); + expect(mockGraphClient.post).toHaveBeenCalledWith({ securityEnabledOnly: false }); + + expect(result).toEqual(['group-1', 'group-2']); + }); + + it('should deduplicate returned group ids', async () => { + mockGraphClient.post.mockResolvedValue({ + value: ['group-1', 'group-2', 'group-1'], + }); + + const result = await GraphApiService.getUserEntraGroups('token', 'user'); - expect(result).toHaveLength(2); expect(result).toEqual(['group-1', 'group-2']); }); it('should return empty array on error', async () => { - mockGraphClient.get.mockRejectedValue(new Error('API error')); + mockGraphClient.post.mockRejectedValue(new Error('API error')); const result = await GraphApiService.getUserEntraGroups('token', 'user'); @@ -550,7 +553,7 @@ describe('GraphApiService', () => { value: [], }; - mockGraphClient.get.mockResolvedValue(mockGroupsResponse); + mockGraphClient.post.mockResolvedValue(mockGroupsResponse); const result = await GraphApiService.getUserEntraGroups('token', 'user'); @@ -558,7 +561,7 @@ describe('GraphApiService', () => { }); it('should handle missing value property', async () => { - mockGraphClient.get.mockResolvedValue({}); + mockGraphClient.post.mockResolvedValue({}); const result = await GraphApiService.getUserEntraGroups('token', 'user'); @@ -566,6 +569,89 @@ describe('GraphApiService', () => { }); }); + describe('getUserOwnedEntraGroups', () => { + it('should fetch owned groups with pagination support', async () => { + const firstPage = { + value: [ + { + id: 'owned-group-1', + }, + ], + '@odata.nextLink': + 'https://graph.microsoft.com/v1.0/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz', + }; + + const secondPage = { + value: [ + { + id: 'owned-group-2', + }, + ], + }; + + mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage); + + const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user'); + + expect(mockGraphClient.api).toHaveBeenNthCalledWith( + 1, + '/me/ownedObjects/microsoft.graph.group', + ); + expect(mockGraphClient.api).toHaveBeenNthCalledWith( + 2, + '/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz', + ); + expect(mockGraphClient.top).toHaveBeenCalledWith(999); + expect(mockGraphClient.get).toHaveBeenCalledTimes(2); + + expect(result).toEqual(['owned-group-1', 'owned-group-2']); + }); + + it('should return empty array on error', async () => { + mockGraphClient.get.mockRejectedValue(new Error('API error')); + + const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user'); + + expect(result).toEqual([]); + }); + }); + + describe('getGroupMembers', () => { + it('should fetch transitive members and include only users', async () => { + const firstPage = { + value: [ + { id: 'user-1', '@odata.type': '#microsoft.graph.user' }, + { id: 'child-group', '@odata.type': '#microsoft.graph.group' }, + ], + '@odata.nextLink': + 'https://graph.microsoft.com/v1.0/groups/group-id/transitiveMembers?$skiptoken=abc', + }; + const secondPage = { + value: [{ id: 'user-2', '@odata.type': '#microsoft.graph.user' }], + }; + + mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage); + + const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id'); + + expect(mockGraphClient.api).toHaveBeenNthCalledWith(1, '/groups/group-id/transitiveMembers'); + expect(mockGraphClient.api).toHaveBeenNthCalledWith( + 2, + '/groups/group-id/transitiveMembers?$skiptoken=abc', + ); + expect(mockGraphClient.top).toHaveBeenCalledWith(999); + expect(result).toEqual(['user-1', 'user-2']); + }); + + it('should return empty array on error', async () => { + mockGraphClient.get.mockRejectedValue(new Error('API error')); + + const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id'); + + expect(result).toEqual([]); + }); + }); + describe('testGraphApiAccess', () => { beforeEach(() => { jest.clearAllMocks(); From 64df54528dcd138f7f790f720de31c2fbba41509 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 19:45:37 -0400 Subject: [PATCH 052/272] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#10259)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/zh-Hant/translation.json | 52 +++++++++++++++++---- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/client/src/locales/zh-Hant/translation.json b/client/src/locales/zh-Hant/translation.json index 2de11c381e..0023757bbc 100644 --- a/client/src/locales/zh-Hant/translation.json +++ b/client/src/locales/zh-Hant/translation.json @@ -1,6 +1,6 @@ { - "chat_direction_left_to_right": "這裡需要放些東西。目前是空的。", - "chat_direction_right_to_left": "這裡需要放些東西。目前是空的。", + "chat_direction_left_to_right": "由左至右", + "chat_direction_right_to_left": "由右至左", "com_a11y_ai_composing": "AI 仍在撰寫中", "com_a11y_end": "AI 已完成回覆", "com_a11y_start": "AI 已開始回覆。", @@ -21,8 +21,12 @@ "com_agents_category_it": "IT", "com_agents_category_it_description": "IT 支援、技術排障與系統管理 agent", "com_agents_category_rd": "研發", + "com_agents_category_rd_description": "擅長開發流程,發明和技術研究的agents", + "com_agents_category_sales": "銷售", + "com_agents_category_sales_description": "擅長銷售流程和客戶關係的agents", "com_agents_category_tab_label": "{{category}} 類別,{{position}} / {{total}}", "com_agents_category_tabs_label": "Agent 類別", + "com_agents_chat_with": "與 {{name}} 對話", "com_agents_clear_search": "清除搜尋", "com_agents_code_interpreter": "啟用後,您的代理可以安全地使用 LibreChat 程式碼解譯器 API 來執行產生的程式碼,包括檔案處理功能。需要有效的 API 金鑰。", "com_agents_code_interpreter_title": "程式碼解譯器 API", @@ -35,22 +39,48 @@ "com_agents_enable_file_search": "啟用檔案搜尋", "com_agents_error_bad_request_message": "無法處理該請求", "com_agents_error_bad_request_suggestion": "請檢查您的輸入並再試一次。", + "com_agents_error_category_title": "類別錯誤", + "com_agents_error_generic": "我們在載入內容的時候遇到問題", + "com_agents_error_invalid_request": "錯誤的請求", + "com_agents_error_loading": "載入agents錯誤", + "com_agents_error_network_message": "無法連線至伺服器", + "com_agents_error_network_suggestion": "檢查您的網路連線後再試一次", + "com_agents_error_network_title": "連線問題", + "com_agents_error_not_found_message": "無法找到請求的內容", + "com_agents_error_not_found_suggestion": "瀏覽其他的選項或是回到市場", + "com_agents_error_not_found_title": "沒有找到", + "com_agents_error_retry": "再試一次", + "com_agents_error_search_title": "搜尋錯誤", + "com_agents_error_searching": "搜尋agents錯誤", + "com_agents_error_server_message": "此伺服器暫時不可用", + "com_agents_error_server_suggestion": "請稍後再次嘗試", + "com_agents_error_server_title": "伺服器錯誤", + "com_agents_error_suggestion_generic": "請嘗試重新整理網頁或稍後再試", + "com_agents_error_timeout_message": "此請求逾時未完成", + "com_agents_error_timeout_suggestion": "請檢查您的網路連線後再試一次", + "com_agents_error_timeout_title": "連線逾時", + "com_agents_error_title": "發生錯誤了", "com_agents_file_context_disabled": "在為檔案上下文上傳檔案之前,必須先建立 Agent。", "com_agents_file_search_disabled": "必須先建立代理才能上傳檔案進行檔案搜尋。", "com_agents_file_search_info": "啟用後,代理將會被告知以下列出的確切檔案名稱,使其能夠從這些檔案中擷取相關內容。", "com_agents_instructions_placeholder": "代理程式使用的系統指令", + "com_agents_loading": "載入中...", + "com_agents_marketplace": "Agents市場", "com_agents_mcp_description_placeholder": "簡要解釋它的作用", "com_agents_mcp_icon_size": "最小尺寸 128 x 128 px", "com_agents_mcp_info": "將 MCP 伺服器新增至您的 Agent,讓其能執行任務並與外部服務互動", "com_agents_mcp_name_placeholder": "自定義工具", "com_agents_mcp_trust_subtext": "自訂連接器未經 LibreChat 驗證", "com_agents_mcps_disabled": "在新增 MCP 之前,您需要先建立 Agent。", + "com_agents_missing_name": "在建立agent之前,請先輸入名稱", "com_agents_missing_provider_model": "請在建立代理前選擇供應商和模型。", "com_agents_name_placeholder": "選填:代理人的名稱", "com_agents_no_access": "您沒有權限編輯此助理", "com_agents_no_agent_id_error": "找不到 Agent ID。請先建立 Agent。", "com_agents_not_available": "代理不可用", + "com_agents_recommended": "我們推薦的agents", "com_agents_search_info": "啟用後,允許您的 Agent 搜尋網路以取得最新資訊。需要有效的 API 金鑰。", + "com_agents_search_instructions": "輸入名稱或描述來搜尋agents", "com_agents_search_name": "依名稱搜尋代理", "com_agents_update_error": "更新您的代理時發生錯誤。", "com_assistants_action_attempt": "助理想要與 {{0}} 交談", @@ -251,7 +281,7 @@ "com_endpoint_openai_max": "要生成的最大 token 數。輸入 token 和生成 token 的總長度受到模型前後文長度的限制。", "com_endpoint_openai_max_tokens": "可選的 `max_tokens` 欄位,代表在對話完成中可以生成的最大 token 數。\n\n輸入 token 和生成 token 的總長度受限於模型的上下文長度。如果此數字超過最大上下文 token 數,您可能會遇到錯誤。", "com_endpoint_openai_pres": "數值範圍介於 -2.0 和 2.0 之間。正值會根據該 token 是否在目前的文字中出現來進行懲罰,增加模型談及新主題的可能性。", - "com_endpoint_openai_prompt_prefix_placeholder": "在系統訊息中設定自訂提示。", + "com_endpoint_openai_prompt_prefix_placeholder": "在系統訊息中設定自訂提示。預設:無", "com_endpoint_openai_reasoning_effort": "僅適用於推理模型:限制推理的投入。降低推理投入可以使回應更快,且在回應中使用較少的推理 token。最小值會產生非常少的推理 token,以達到最快的首次 token 產生時間,特別適合程式碼與指令遵循。", "com_endpoint_openai_reasoning_summary": "僅限 Responses API:模型執行推理的摘要。這有助於除錯並理解模型的推理過程。可設定為 無、自動、簡潔或詳細。", "com_endpoint_openai_resend": "重新傳送之前所有附加的圖片。注意:這可能會大幅增加 token 成本,如果附加了太多圖片,您可能會遇到錯誤。", @@ -265,7 +295,7 @@ "com_endpoint_output": "輸出", "com_endpoint_plug_image_detail": "影像詳細資訊", "com_endpoint_plug_resend_files": "重新傳送檔案", - "com_endpoint_plug_set_custom_instructions_for_gpt_placeholder": "在系統訊息中新增自訂提示。", + "com_endpoint_plug_set_custom_instructions_for_gpt_placeholder": "在系統訊息中新增自訂提示。\n預設:無", "com_endpoint_plug_skip_completion": "跳過完成步驟", "com_endpoint_plug_use_functions": "使用外掛作為 OpenAI 函式", "com_endpoint_presence_penalty": "出現懲罰", @@ -338,16 +368,22 @@ "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": "日", "com_nav_balance_days": "日", "com_nav_balance_every": "每", "com_nav_balance_hour": "小時", "com_nav_balance_hours": "小時", "com_nav_balance_interval": "間隔", + "com_nav_balance_last_refill": "上次儲值", "com_nav_balance_minute": "分鐘", "com_nav_balance_minutes": "分鐘", "com_nav_balance_month": "月", "com_nav_balance_months": "月", + "com_nav_balance_next_refill": "下次儲值", + "com_nav_balance_refill_amount": "儲值金額", "com_nav_balance_second": "秒", "com_nav_balance_seconds": "秒", "com_nav_balance_week": "週", @@ -469,9 +505,9 @@ "com_nav_search_placeholder": "搜尋訊息", "com_nav_send_message": "傳送訊息", "com_nav_setting_account": "帳號", - "com_nav_setting_chat": "聊天", + "com_nav_setting_chat": "對話", "com_nav_setting_data": "資料控制", - "com_nav_setting_general": "一般", + "com_nav_setting_general": "通用", "com_nav_setting_mcp": "MCP 設定", "com_nav_setting_personalization": "個性化", "com_nav_setting_speech": "語音", @@ -532,7 +568,7 @@ "com_ui_admin_access_warning": "停用管理員對此功能的存取權限可能會導致意外的介面問題,需要重新整理頁面。若儲存此設定,唯一的還原方式是透過 librechat.yaml 設定檔中的介面設定,這會影響所有角色。", "com_ui_admin_settings": "管理員設定", "com_ui_advanced": "進階", - "com_ui_agent": "助理", + "com_ui_agent": "Agent", "com_ui_agent_delete_error": "刪除助理時發生錯誤", "com_ui_agent_deleted": "已成功刪除助理", "com_ui_agent_duplicate_error": "複製助理時發生錯誤", @@ -600,7 +636,7 @@ "com_ui_command_usage_placeholder": "透過指令或名稱選擇提示", "com_ui_concise": "簡潔", "com_ui_confirm_action": "確認操作", - "com_ui_context": "情境", + "com_ui_context": "前後文", "com_ui_continue": "繼續", "com_ui_controls": "控制項", "com_ui_copied": "已複製!", From 33d6b337bc4d99e5a968a2785ce7ca9be7d489e1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 27 Oct 2025 19:46:30 -0400 Subject: [PATCH 053/272] =?UTF-8?q?=F0=9F=93=9B=20feat:=20Chat=20Badges=20?= =?UTF-8?q?via=20Model=20Specs=20(#10272)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove `useChatContext` from `useSelectMention`, explicitly pass `conversation` object * feat: ephemeral agents via model specs * refactor: Sync Jotai state with ephemeral agent state, also when Ephemeral Agent has no MCP servers selected * refactor: move `useUpdateEphemeralAgent` to store and clean up imports * refactor: reorder imports and invalidate queries for mcpConnectionStatus in event handler * refactor: replace useApplyModelSpecEffects with useApplyModelSpecAgents and update event handlers to use new agent template logic * ci: update useMCPSelect test to verify mcpValues sync with empty ephemeralAgent.mcp --- api/models/Agent.js | 25 +++-- api/server/services/Endpoints/agents/build.js | 4 +- client/src/components/Chat/Input/ChatForm.tsx | 2 + client/src/components/Chat/Input/Mention.tsx | 4 + .../Endpoints/ModelSelectorChatContext.tsx | 13 +-- .../Menus/Endpoints/ModelSelectorContext.tsx | 3 +- client/src/hooks/Agents/index.ts | 1 + .../hooks/Agents/useApplyModelSpecAgents.ts | 95 +++++++++++++++++++ .../Conversations/useNavigateToConvo.tsx | 29 +++++- client/src/hooks/Input/useSelectMention.ts | 6 +- .../hooks/MCP/__tests__/useMCPSelect.test.tsx | 7 +- client/src/hooks/MCP/useMCPSelect.ts | 2 + client/src/hooks/SSE/useEventHandlers.ts | 41 +++++--- client/src/hooks/useNewConvo.ts | 9 ++ client/src/store/agents.ts | 12 +++ client/src/utils/endpoints.ts | 34 +++++++ packages/data-provider/src/models.ts | 8 ++ 17 files changed, 254 insertions(+), 41 deletions(-) create mode 100644 client/src/hooks/Agents/useApplyModelSpecAgents.ts diff --git a/api/models/Agent.js b/api/models/Agent.js index 5468293523..f5f740ba7b 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -62,25 +62,37 @@ const getAgents = async (searchParameter) => await Agent.find(searchParameter).l * * @param {Object} params * @param {ServerRequest} params.req + * @param {string} params.spec * @param {string} params.agent_id * @param {string} params.endpoint * @param {import('@librechat/agents').ClientOptions} [params.model_parameters] * @returns {Promise} The agent document as a plain object, or null if not found. */ -const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => { +const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_parameters: _m }) => { const { model, ...model_parameters } = _m; + const modelSpecs = req.config?.modelSpecs?.list; + /** @type {TModelSpec | null} */ + let modelSpec = null; + if (spec != null && spec !== '') { + modelSpec = modelSpecs?.find((s) => s.name === spec) || null; + } /** @type {TEphemeralAgent | null} */ const ephemeralAgent = req.body.ephemeralAgent; const mcpServers = new Set(ephemeralAgent?.mcp); + if (modelSpec?.mcpServers) { + for (const mcpServer of modelSpec.mcpServers) { + mcpServers.add(mcpServer); + } + } /** @type {string[]} */ const tools = []; - if (ephemeralAgent?.execute_code === true) { + if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) { tools.push(Tools.execute_code); } - if (ephemeralAgent?.file_search === true) { + if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) { tools.push(Tools.file_search); } - if (ephemeralAgent?.web_search === true) { + if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) { tools.push(Tools.web_search); } @@ -122,17 +134,18 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _ * * @param {Object} params * @param {ServerRequest} params.req + * @param {string} params.spec * @param {string} params.agent_id * @param {string} params.endpoint * @param {import('@librechat/agents').ClientOptions} [params.model_parameters] * @returns {Promise} The agent document as a plain object, or null if not found. */ -const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => { +const loadAgent = async ({ req, spec, agent_id, endpoint, model_parameters }) => { if (!agent_id) { return null; } if (agent_id === EPHEMERAL_AGENT_ID) { - return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters }); + return await loadEphemeralAgent({ req, spec, agent_id, endpoint, model_parameters }); } const agent = await getAgent({ id: agent_id, diff --git a/api/server/services/Endpoints/agents/build.js b/api/server/services/Endpoints/agents/build.js index 3bf90e8d82..34fcaf4be4 100644 --- a/api/server/services/Endpoints/agents/build.js +++ b/api/server/services/Endpoints/agents/build.js @@ -3,9 +3,10 @@ const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat- const { loadAgent } = require('~/models/Agent'); const buildOptions = (req, endpoint, parsedBody, endpointType) => { - const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody; + const { spec, iconURL, agent_id, ...model_parameters } = parsedBody; const agentPromise = loadAgent({ req, + spec, agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID, endpoint, model_parameters, @@ -20,7 +21,6 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => { endpoint, agent_id, endpointType, - instructions, model_parameters, agent: agentPromise, }); diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 0736c7dc61..b807369082 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -220,6 +220,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
{showPlusPopover && !isAssistantsEndpoint(endpoint) && ( { )} {showMentionPopover && ( ; newConversation: ConvoGenerator; textAreaRef: React.MutableRefObject; @@ -42,6 +45,7 @@ export default function Mention({ const { onSelectMention } = useSelectMention({ presets, modelSpecs, + conversation, assistantsMap, endpointsConfig, newConversation, diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx index bd639523d8..eac3bb200c 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useMemo } from 'react'; -import type { EModelEndpoint } from 'librechat-data-provider'; +import type { EModelEndpoint, TConversation } from 'librechat-data-provider'; import { useChatContext } from '~/Providers/ChatContext'; interface ModelSelectorChatContextValue { @@ -8,6 +8,7 @@ interface ModelSelectorChatContextValue { spec?: string | null; agent_id?: string | null; assistant_id?: string | null; + conversation: TConversation | null; newConversation: ReturnType['newConversation']; } @@ -26,16 +27,10 @@ export function ModelSelectorChatProvider({ children }: { children: React.ReactN spec: conversation?.spec, agent_id: conversation?.agent_id, assistant_id: conversation?.assistant_id, + conversation, newConversation, }), - [ - conversation?.endpoint, - conversation?.model, - conversation?.spec, - conversation?.agent_id, - conversation?.assistant_id, - newConversation, - ], + [conversation, newConversation], ); return ( diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx index a4527d56e7..e79d9a2d21 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx @@ -57,7 +57,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector const agentsMap = useAgentsMapContext(); const assistantsMap = useAssistantsMapContext(); const { data: endpointsConfig } = useGetEndpointsQuery(); - const { endpoint, model, spec, agent_id, assistant_id, newConversation } = + const { endpoint, model, spec, agent_id, assistant_id, conversation, newConversation } = useModelSelectorChatContext(); const modelSpecs = useMemo(() => { const specs = startupConfig?.modelSpecs?.list ?? []; @@ -96,6 +96,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector const { onSelectEndpoint, onSelectSpec } = useSelectMention({ // presets, modelSpecs, + conversation, assistantsMap, endpointsConfig, newConversation, diff --git a/client/src/hooks/Agents/index.ts b/client/src/hooks/Agents/index.ts index b0df8398e4..3597b0e646 100644 --- a/client/src/hooks/Agents/index.ts +++ b/client/src/hooks/Agents/index.ts @@ -6,3 +6,4 @@ export { default as useAgentCapabilities } from './useAgentCapabilities'; export { default as useGetAgentsConfig } from './useGetAgentsConfig'; export { default as useAgentDefaultPermissionLevel } from './useAgentDefaultPermissionLevel'; export { default as useAgentToolPermissions } from './useAgentToolPermissions'; +export * from './useApplyModelSpecAgents'; diff --git a/client/src/hooks/Agents/useApplyModelSpecAgents.ts b/client/src/hooks/Agents/useApplyModelSpecAgents.ts new file mode 100644 index 0000000000..e7f15741cb --- /dev/null +++ b/client/src/hooks/Agents/useApplyModelSpecAgents.ts @@ -0,0 +1,95 @@ +import { useCallback } from 'react'; +import type { TStartupConfig, TSubmission } from 'librechat-data-provider'; +import { useUpdateEphemeralAgent, useApplyNewAgentTemplate } from '~/store/agents'; +import { getModelSpec, applyModelSpecEphemeralAgent } from '~/utils'; + +/** + * Hook that applies a model spec from a preset to an ephemeral agent. + * This is used when initializing a new conversation with a preset that has a spec. + */ +export function useApplyModelSpecEffects() { + const updateEphemeralAgent = useUpdateEphemeralAgent(); + const applyPresetModelSpec = useCallback( + ({ + convoId, + specName, + startupConfig, + }: { + convoId: string | null; + specName?: string | null; + startupConfig?: TStartupConfig; + }) => { + if (specName == null || !specName) { + return; + } + + const modelSpec = getModelSpec({ + specName, + startupConfig, + }); + + applyModelSpecEphemeralAgent({ + convoId, + modelSpec, + updateEphemeralAgent, + }); + }, + [updateEphemeralAgent], + ); + + return applyPresetModelSpec; +} + +export function useApplyAgentTemplate() { + const applyAgentTemplate = useApplyNewAgentTemplate(); + /** + * Helper function to apply agent template with model spec merged into ephemeral agent + */ + const applyAgentTemplateWithSpec = useCallback( + ({ + targetId, + sourceId, + ephemeralAgent, + specName, + startupConfig, + }: { + targetId: string; + sourceId?: TSubmission['conversation']['conversationId'] | null; + ephemeralAgent: TSubmission['ephemeralAgent']; + specName?: string | null; + startupConfig?: TStartupConfig; + }) => { + if (!specName) { + applyAgentTemplate(targetId, sourceId, ephemeralAgent); + return; + } + + const modelSpec = getModelSpec({ + specName, + startupConfig, + }); + + if (!modelSpec) { + applyAgentTemplate(targetId, sourceId, ephemeralAgent); + return; + } + + // Merge model spec fields into ephemeral agent + const mergedAgent = { + ...ephemeralAgent, + mcp: [...(ephemeralAgent?.mcp ?? []), ...(modelSpec.mcpServers ?? [])], + web_search: ephemeralAgent?.web_search ?? modelSpec.webSearch ?? false, + file_search: ephemeralAgent?.file_search ?? modelSpec.fileSearch ?? false, + execute_code: ephemeralAgent?.execute_code ?? modelSpec.executeCode ?? false, + }; + + // Deduplicate MCP servers + mergedAgent.mcp = [...new Set(mergedAgent.mcp)]; + + applyAgentTemplate(targetId, sourceId, mergedAgent); + }, + [applyAgentTemplate], + ); + + return applyAgentTemplateWithSpec; +} diff --git a/client/src/hooks/Conversations/useNavigateToConvo.tsx b/client/src/hooks/Conversations/useNavigateToConvo.tsx index 2bbb4620b3..bfe4a0b96e 100644 --- a/client/src/hooks/Conversations/useNavigateToConvo.tsx +++ b/client/src/hooks/Conversations/useNavigateToConvo.tsx @@ -1,8 +1,14 @@ +import { useCallback } from 'react'; import { useSetRecoilState } from 'recoil'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { QueryKeys, Constants, dataService } from 'librechat-data-provider'; -import type { TConversation, TEndpointsConfig, TModelsConfig } from 'librechat-data-provider'; +import type { + TEndpointsConfig, + TStartupConfig, + TModelsConfig, + TConversation, +} from 'librechat-data-provider'; import { getDefaultEndpoint, clearMessagesCache, @@ -10,15 +16,34 @@ import { getEndpointField, logger, } from '~/utils'; +import { useApplyModelSpecEffects } from '~/hooks/Agents'; import store from '~/store'; const useNavigateToConvo = (index = 0) => { const navigate = useNavigate(); const queryClient = useQueryClient(); const clearAllConversations = store.useClearConvoState(); + const applyModelSpecEffects = useApplyModelSpecEffects(); const setSubmission = useSetRecoilState(store.submissionByIndex(index)); const clearAllLatestMessages = store.useClearLatestMessages(`useNavigateToConvo ${index}`); - const { hasSetConversation, setConversation } = store.useCreateConversationAtom(index); + const { hasSetConversation, setConversation: setConvo } = store.useCreateConversationAtom(index); + + const setConversation = useCallback( + (conversation: TConversation) => { + setConvo(conversation); + if (!conversation.spec) { + return; + } + + const startupConfig = queryClient.getQueryData([QueryKeys.startupConfig]); + applyModelSpecEffects({ + startupConfig, + specName: conversation?.spec, + convoId: conversation.conversationId, + }); + }, + [setConvo, queryClient, applyModelSpecEffects], + ); const fetchFreshData = async (conversation?: Partial) => { const conversationId = conversation?.conversationId; diff --git a/client/src/hooks/Input/useSelectMention.ts b/client/src/hooks/Input/useSelectMention.ts index a5be633da0..51a2f75b11 100644 --- a/client/src/hooks/Input/useSelectMention.ts +++ b/client/src/hooks/Input/useSelectMention.ts @@ -10,18 +10,19 @@ import type { } from 'librechat-data-provider'; import type { MentionOption, ConvoGenerator } from '~/common'; import { getConvoSwitchLogic, getModelSpecIconURL, removeUnavailableTools, logger } from '~/utils'; -import { useChatContext } from '~/Providers'; import { useDefaultConvo } from '~/hooks'; import store from '~/store'; export default function useSelectMention({ presets, modelSpecs, + conversation, assistantsMap, + returnHandlers, endpointsConfig, newConversation, - returnHandlers, }: { + conversation: TConversation | null; presets?: TPreset[]; modelSpecs: TModelSpec[]; assistantsMap?: TAssistantsMap; @@ -29,7 +30,6 @@ export default function useSelectMention({ endpointsConfig: TEndpointsConfig; returnHandlers?: boolean; }) { - const { conversation } = useChatContext(); const getDefaultConversation = useDefaultConvo(); const modularChat = useRecoilValue(store.modularChat); const availableTools = useRecoilValue(store.availableTools); diff --git a/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx b/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx index 7145e95e74..ab10ec6d76 100644 --- a/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx +++ b/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx @@ -431,9 +431,10 @@ describe('useMCPSelect', () => { }); }); - // Values should remain unchanged since empty mcp array doesn't trigger update - // (due to the condition: ephemeralAgent?.mcp && ephemeralAgent.mcp.length > 0) - expect(result.current.mcpHook.mcpValues).toEqual(['initial-value']); + // Values should sync to empty array when ephemeralAgent.mcp is set to [] + await waitFor(() => { + expect(result.current.mcpHook.mcpValues).toEqual([]); + }); }); it('should properly sync non-empty arrays from ephemeralAgent', async () => { diff --git a/client/src/hooks/MCP/useMCPSelect.ts b/client/src/hooks/MCP/useMCPSelect.ts index 3f37bb4d70..3ce7999346 100644 --- a/client/src/hooks/MCP/useMCPSelect.ts +++ b/client/src/hooks/MCP/useMCPSelect.ts @@ -25,6 +25,8 @@ export function useMCPSelect({ conversationId }: { conversationId?: string | nul // Strip out servers that are not available in the startup config const activeMcps = mcps.filter((mcp) => configuredServers.has(mcp)); setMCPValuesRaw(activeMcps); + } else { + setMCPValuesRaw([]); } }, [ephemeralAgent?.mcp, setMCPValuesRaw, configuredServers]); diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 83c1ff1ad9..6348581b68 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -1,8 +1,8 @@ -import { v4 } from 'uuid'; import { useCallback, useRef } from 'react'; +import { v4 } from 'uuid'; import { useSetRecoilState } from 'recoil'; -import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { QueryKeys, Constants, @@ -13,7 +13,12 @@ import { tConvoUpdateSchema, isAssistantsEndpoint, } from 'librechat-data-provider'; -import type { TMessage, TConversation, EventSubmission } from 'librechat-data-provider'; +import type { + TMessage, + TConversation, + EventSubmission, + TStartupConfig, +} from 'librechat-data-provider'; import type { TResData, TFinalResData, ConvoGenerator } from '~/common'; import type { InfiniteData } from '@tanstack/react-query'; import type { TGenTitleMutation } from '~/data-provider'; @@ -31,11 +36,12 @@ import { } from '~/utils'; import useAttachmentHandler from '~/hooks/SSE/useAttachmentHandler'; import useContentHandler from '~/hooks/SSE/useContentHandler'; -import store, { useApplyNewAgentTemplate } from '~/store'; import useStepHandler from '~/hooks/SSE/useStepHandler'; +import { useApplyAgentTemplate } from '~/hooks/Agents'; import { useAuthContext } from '~/hooks/AuthContext'; import { MESSAGE_UPDATE_INTERVAL } from '~/common'; import { useLiveAnnouncer } from '~/Providers'; +import store from '~/store'; type TSyncData = { sync: boolean; @@ -172,7 +178,7 @@ export default function useEventHandlers({ }: EventHandlerParams) { const queryClient = useQueryClient(); const { announcePolite } = useLiveAnnouncer(); - const applyAgentTemplate = useApplyNewAgentTemplate(); + const applyAgentTemplate = useApplyAgentTemplate(); const setAbortScroll = useSetRecoilState(store.abortScroll); const navigate = useNavigate(); const location = useLocation(); @@ -356,6 +362,7 @@ export default function useEventHandlers({ const createdHandler = useCallback( (data: TResData, submission: EventSubmission) => { + queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]); const { messages, userMessage, isRegenerate = false, isTemporary = false } = submission; const initialResponse = { ...submission.initialResponse, @@ -411,11 +418,13 @@ export default function useEventHandlers({ } if (conversationId) { - applyAgentTemplate( - conversationId, - submission.conversation.conversationId, - submission.ephemeralAgent, - ); + applyAgentTemplate({ + targetId: conversationId, + sourceId: submission.conversation?.conversationId, + ephemeralAgent: submission.ephemeralAgent, + specName: submission.conversation?.spec, + startupConfig: queryClient.getQueryData([QueryKeys.startupConfig]), + }); } if (resetLatestMessage) { @@ -566,11 +575,13 @@ export default function useEventHandlers({ }); if (conversation.conversationId && submission.ephemeralAgent) { - applyAgentTemplate( - conversation.conversationId, - submissionConvo.conversationId, - submission.ephemeralAgent, - ); + applyAgentTemplate({ + targetId: conversation.conversationId, + sourceId: submissionConvo.conversationId, + ephemeralAgent: submission.ephemeralAgent, + specName: submission.conversation?.spec, + startupConfig: queryClient.getQueryData([QueryKeys.startupConfig]), + }); } if (location.pathname === `/c/${Constants.NEW_CONVO}`) { diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index 63b442b83a..9f0e17b297 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -29,6 +29,7 @@ import { import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; import useAssistantListMap from './Assistants/useAssistantListMap'; import { useResetChatBadges } from './useChatBadges'; +import { useApplyModelSpecEffects } from './Agents'; import { usePauseGlobalAudio } from './Audio'; import { logger } from '~/utils'; import store from '~/store'; @@ -37,6 +38,7 @@ const useNewConvo = (index = 0) => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { data: startupConfig } = useGetStartupConfig(); + const applyModelSpecEffects = useApplyModelSpecEffects(); const clearAllConversations = store.useClearConvoState(); const defaultPreset = useRecoilValue(store.defaultPreset); const { setConversation } = store.useCreateConversationAtom(index); @@ -265,6 +267,12 @@ const useNewConvo = (index = 0) => { preset = getModelSpecPreset(defaultModelSpec); } + applyModelSpecEffects({ + startupConfig, + specName: preset?.spec, + convoId: conversation.conversationId, + }); + if (conversation.conversationId === Constants.NEW_CONVO && !modelsData) { const filesToDelete = Array.from(files.values()) .filter( @@ -311,6 +319,7 @@ const useNewConvo = (index = 0) => { saveBadgesState, pauseGlobalAudio, switchToConversation, + applyModelSpecEffects, ], ); diff --git a/client/src/store/agents.ts b/client/src/store/agents.ts index a62fae6046..13136ef34e 100644 --- a/client/src/store/agents.ts +++ b/client/src/store/agents.ts @@ -16,6 +16,18 @@ export const ephemeralAgentByConvoId = atomFamily + (convoId: string, agent: TEphemeralAgent | null) => { + set(ephemeralAgentByConvoId(convoId), agent); + }, + [], + ); + + return updateEphemeralAgent; +} + /** * Creates a callback function to apply the ephemeral agent state * from the "new" conversation template to a specified conversation ID. diff --git a/client/src/utils/endpoints.ts b/client/src/utils/endpoints.ts index c98680843a..1de9e2845c 100644 --- a/client/src/utils/endpoints.ts +++ b/client/src/utils/endpoints.ts @@ -1,4 +1,5 @@ import { + Constants, EModelEndpoint, defaultEndpoints, modularEndpoints, @@ -176,6 +177,39 @@ export function getConvoSwitchLogic(params: ConversationInitParams): InitiatedTe }; } +export function getModelSpec({ + specName, + startupConfig, +}: { + specName?: string | null; + startupConfig?: t.TStartupConfig; +}): t.TModelSpec | undefined { + if (!startupConfig || !specName) { + return; + } + return startupConfig.modelSpecs?.list?.find((spec) => spec.name === specName); +} + +export function applyModelSpecEphemeralAgent({ + convoId, + modelSpec, + updateEphemeralAgent, +}: { + convoId?: string | null; + modelSpec?: t.TModelSpec; + updateEphemeralAgent: ((convoId: string, agent: t.TEphemeralAgent | null) => void) | undefined; +}) { + if (!modelSpec || !updateEphemeralAgent) { + return; + } + updateEphemeralAgent((convoId ?? Constants.NEW_CONVO) || Constants.NEW_CONVO, { + mcp: modelSpec.mcpServers ?? [], + web_search: modelSpec.webSearch ?? false, + file_search: modelSpec.fileSearch ?? false, + execute_code: modelSpec.executeCode ?? false, + }); +} + /** * Gets default model spec from config and user preferences. * Priority: admin default → last selected → first spec (when prioritize=true or modelSelect disabled). diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index 78ba1237fc..1edca6ea37 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -26,6 +26,10 @@ export type TModelSpec = { showIconInHeader?: boolean; iconURL?: string | EModelEndpoint; // Allow using project-included icons authType?: AuthType; + webSearch?: boolean; + fileSearch?: boolean; + executeCode?: boolean; + mcpServers?: string[]; }; export const tModelSpecSchema = z.object({ @@ -40,6 +44,10 @@ export const tModelSpecSchema = z.object({ showIconInHeader: z.boolean().optional(), iconURL: z.union([z.string(), eModelEndpointSchema]).optional(), authType: authTypeSchema.optional(), + webSearch: z.boolean().optional(), + fileSearch: z.boolean().optional(), + executeCode: z.boolean().optional(), + mcpServers: z.array(z.string()).optional(), }); export const specsConfigSchema = z.object({ From 0446d0e1900f4d75c6b0e7e8b94deffc4478d3b2 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 28 Oct 2025 00:46:43 +0100 Subject: [PATCH 054/272] =?UTF-8?q?=E2=99=BF=20fix:=20Address=20Accessibil?= =?UTF-8?q?ity=20Issues=20(#10260)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: add i18n localization comment for AlwaysMakeProd component * feat: enhance accessibility by adding aria-label and aria-labelledby to Switch component * feat: add aria-labels for accessibility in Agent and Assistant avatar buttons * fix: add switch aria-labels for accessibility in various components * feat: add aria-labels and localization keys for accessibility in DataTable, DataTableColumnHeader, and OGDialogTemplate components * chore: refactor out nested ternary * feat: add aria-label to DataTable filter button for My Files modal * feat: add aria-labels for Buttons and localization strings * feat: add aria-labels to Checkboxes in Agent Builder * feat: enhance accessibility by adding aria-label and aria-labelledby to Checkbox component * feat: add aria-label to FileSearchCheckbox in Agent Builder * feat: add aria-label to Prompts text input area * feat: enhance accessibility by adding aria-label and aria-labelledby to TextAreaAutosize component * feat: remove improper role: "list" prop from List in Conversations.tsx to enhance accessibility and stop aria rules conflicting within react-virtualized component * feat: enhance accessibility by allowing tab navigation and adding ring highlights for conversation title editing accept/reject buttons * feat: add aria-label to Copy Link button in the conversation share modal * feat: add title to QR code svg in conversation share modal to describe the image content * feat: enhance accessibility by making Agent Avatar upload keyboard navigable and round out highlight border on focus * feat: enhance accessibility by adding aria attributes around alerting users with screen readers to invalid email address inputs in the Agent Builder * feat: add aria-labels to buttons in Advanced panel of Agent Builder * feat: enhance accessibility by making FileUpload and Clear All buttons in PresetItems keyboard navigable * feat: enchance accessiblity by indexing view and delete button aria-labels in shared links management modal to their specific chat titles * feat: add border highlighting on focus for AnimatedSearchInput * feat: add category description to aria-labels for prompts in ListCard * feat: add proper scoping to rows and columns in table headers * feat: add localized aria-labelling to EditTextPart's TextAreaAutosize component and base dynamic paramters panel components and their supporting translation keys * feat: add localized aria-labels and aria-labelledBy to Checkbox components without them * feat: add localized aria-labeledBy for endpoint settings Sliders * feat: add localized aria-labels for TextareaAutosize components * chore: remove unused i18n string * feat: add localized aria-label for BookmarkForm Checkbox * fix: add stopPropagation onKeyDown for Preview and Edit menu items in prompts that was causing the prompts to inadvertently be sent when triggered with keyboard navigation when Auto-send Prompts was toggled on * fix: switch TableCell to TableHead for title cells according to harvard issue #789 * fix: add more descriptive localization key for file filter button in DataTable * chore: remove self-explanatory code comment from RenameForm * fix: remove stray bg-yellow highlight that was left in during debugging * fix: add aria-label to model configurator panel back button * fix: undo incorrect hoist of tool name split for aria-label and span in MCPInput --------- Co-authored-by: Danny Avila --- .../Agents/MarketplaceAdminSettings.tsx | 1 + .../src/components/Bookmarks/BookmarkForm.tsx | 8 ++- client/src/components/Chat/Input/ChatForm.tsx | 3 ++ .../Chat/Input/Files/FileUpload.tsx | 34 ++++++++---- .../Chat/Input/Files/Table/DataTable.tsx | 6 ++- .../Chat/Menus/Presets/PresetItems.tsx | 10 ++-- .../Chat/Messages/Content/EditMessage.tsx | 1 + .../Messages/Content/Parts/EditTextPart.tsx | 1 + .../Conversations/Conversations.tsx | 1 - .../ConvoOptions/ShareButton.tsx | 9 +++- .../components/Conversations/RenameForm.tsx | 9 ++-- .../Endpoints/Settings/Advanced.tsx | 18 +++++-- .../Endpoints/Settings/AgentSettings.tsx | 7 ++- .../components/Endpoints/Settings/Google.tsx | 4 ++ .../components/Endpoints/Settings/Plugins.tsx | 4 ++ .../Nav/ExportConversation/ExportModal.tsx | 16 ++++-- .../Nav/SettingsTabs/Chat/SaveBadgesState.tsx | 1 + .../Nav/SettingsTabs/Chat/ShowThinking.tsx | 1 + .../Nav/SettingsTabs/Data/SharedLinks.tsx | 53 ++++++++----------- .../src/components/Prompts/AdminSettings.tsx | 8 ++- .../Prompts/Groups/AlwaysMakeProd.tsx | 2 +- .../Prompts/Groups/AutoSendPrompt.tsx | 2 +- .../Prompts/Groups/ChatGroupItem.tsx | 6 +++ .../Prompts/Groups/CreatePromptForm.tsx | 1 + client/src/components/Prompts/Groups/List.tsx | 1 + .../components/Prompts/Groups/ListCard.tsx | 4 +- .../Prompts/Groups/NoPromptGroup.tsx | 1 + .../Prompts/Groups/VariableForm.tsx | 3 +- .../src/components/Prompts/PromptEditor.tsx | 1 + .../Sharing/GenericGrantAccessDialog.tsx | 7 ++- .../Sharing/PeoplePickerAdminSettings.tsx | 2 + .../SidePanel/Agents/AdminSettings.tsx | 2 + .../Agents/Advanced/AdvancedButton.tsx | 1 + .../Agents/Advanced/AdvancedPanel.tsx | 1 + .../SidePanel/Agents/Advanced/AgentChain.tsx | 3 ++ .../SidePanel/Agents/AgentAvatar.tsx | 6 ++- .../SidePanel/Agents/AgentConfig.tsx | 18 ++++++- .../SidePanel/Agents/AgentPanel.tsx | 7 +++ .../components/SidePanel/Agents/Artifacts.tsx | 1 + .../SidePanel/Agents/Code/Action.tsx | 7 ++- .../SidePanel/Agents/Code/ApiKeyDialog.tsx | 1 + .../SidePanel/Agents/FileSearchCheckbox.tsx | 1 + .../components/SidePanel/Agents/Images.tsx | 7 +-- .../components/SidePanel/Agents/MCPInput.tsx | 13 ++++- .../components/SidePanel/Agents/MCPTool.tsx | 7 +++ .../SidePanel/Agents/ModelPanel.tsx | 1 + .../SidePanel/Agents/Search/Action.tsx | 1 + .../SidePanel/Agents/Search/ApiKeyDialog.tsx | 6 ++- .../Agents/Version/VersionButton.tsx | 1 + .../SidePanel/Bookmarks/BookmarkTable.tsx | 1 + .../SidePanel/Builder/AssistantAvatar.tsx | 6 ++- .../src/components/SidePanel/Builder/Code.tsx | 2 + .../SidePanel/Builder/ImageVision.tsx | 2 + .../SidePanel/Builder/Retrieval.tsx | 2 + .../SidePanel/Files/PanelColumns.tsx | 2 + .../src/components/SidePanel/MCP/MCPPanel.tsx | 9 +++- .../SidePanel/Memories/AdminSettings.tsx | 2 + .../SidePanel/Memories/MemoryCreateDialog.tsx | 1 + .../SidePanel/Memories/MemoryEditDialog.tsx | 1 + .../SidePanel/Memories/MemoryViewer.tsx | 6 ++- .../SidePanel/Parameters/DynamicCheckbox.tsx | 1 + .../SidePanel/Parameters/DynamicSlider.tsx | 3 ++ .../SidePanel/Parameters/DynamicSwitch.tsx | 3 ++ .../SidePanel/Parameters/DynamicTextarea.tsx | 1 + client/src/locales/en/translation.json | 14 ++++- client/src/routes/RouteErrorBoundary.tsx | 15 ++++-- .../src/components/AnimatedSearchInput.tsx | 2 +- packages/client/src/components/Checkbox.tsx | 50 +++++++++++------ packages/client/src/components/DataTable.tsx | 26 ++++++++- .../src/components/DataTableColumnHeader.tsx | 29 +++++++--- .../src/components/OGDialogTemplate.tsx | 4 +- packages/client/src/components/Switch.tsx | 48 +++++++++++------ .../src/components/TextareaAutosize.tsx | 14 ++++- .../client/src/locales/en/translation.json | 5 +- 74 files changed, 427 insertions(+), 131 deletions(-) diff --git a/client/src/components/Agents/MarketplaceAdminSettings.tsx b/client/src/components/Agents/MarketplaceAdminSettings.tsx index fa5fa34fbc..e09f168afe 100644 --- a/client/src/components/Agents/MarketplaceAdminSettings.tsx +++ b/client/src/components/Agents/MarketplaceAdminSettings.tsx @@ -58,6 +58,7 @@ const LabelController: React.FC = ({ checked={field.value} onCheckedChange={field.onChange} value={field.value.toString()} + aria-label={label} /> )} /> diff --git a/client/src/components/Bookmarks/BookmarkForm.tsx b/client/src/components/Bookmarks/BookmarkForm.tsx index 3b2633485b..23e94dbfb1 100644 --- a/client/src/components/Bookmarks/BookmarkForm.tsx +++ b/client/src/components/Bookmarks/BookmarkForm.tsx @@ -129,7 +129,11 @@ const BookmarkForm = ({
-
{conversationId != null && conversationId && ( @@ -161,6 +166,7 @@ const BookmarkForm = ({ onCheckedChange={field.onChange} className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" value={field.value?.toString()} + aria-label={localize('com_ui_bookmarks_add_to_conversation')} /> )} /> diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index b807369082..f1dc1ef076 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -12,6 +12,7 @@ import { import { useTextarea, useAutoSave, + useLocalize, useRequiresKey, useHandleKeyUp, useQueryParams, @@ -38,6 +39,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { const submitButtonRef = useRef(null); const textAreaRef = useRef(null); useFocusChatEffect(textAreaRef); + const localize = useLocalize(); const [isCollapsed, setIsCollapsed] = useState(false); const [, setIsScrollable] = useState(false); @@ -279,6 +281,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { setIsTextAreaFocused(true); }} onBlur={setIsTextAreaFocused.bind(null, false)} + aria-label={localize('com_ui_message_input')} onClick={handleFocusOrClick} style={{ height: 44, overflowY: 'auto' }} className={cn( diff --git a/client/src/components/Chat/Input/Files/FileUpload.tsx b/client/src/components/Chat/Input/Files/FileUpload.tsx index 723fa32e86..718c8c1f5d 100644 --- a/client/src/components/Chat/Input/Files/FileUpload.tsx +++ b/client/src/components/Chat/Input/Files/FileUpload.tsx @@ -62,17 +62,28 @@ const FileUpload: React.FC = ({ statusText = invalidText ?? localize('com_ui_upload_invalid'); } + const handleClick = () => { + const fileInput = document.getElementById(`file-upload-${id}`) as HTMLInputElement; + if (fileInput) { + fileInput.click(); + } + }; + return ( - + ); }; diff --git a/client/src/components/Chat/Input/Files/Table/DataTable.tsx b/client/src/components/Chat/Input/Files/Table/DataTable.tsx index ffb3e2825b..70459b2d66 100644 --- a/client/src/components/Chat/Input/Files/Table/DataTable.tsx +++ b/client/src/components/Chat/Input/Files/Table/DataTable.tsx @@ -122,7 +122,11 @@ export default function DataTable({ columns, data }: DataTablePro /> - diff --git a/client/src/components/Chat/Menus/Presets/PresetItems.tsx b/client/src/components/Chat/Menus/Presets/PresetItems.tsx index 4e7710e0a7..a0c65bc04c 100644 --- a/client/src/components/Chat/Menus/Presets/PresetItems.tsx +++ b/client/src/components/Chat/Menus/Presets/PresetItems.tsx @@ -59,9 +59,10 @@ const PresetItems: FC<{ - +
diff --git a/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx b/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx index 242b13765e..5422d9733d 100644 --- a/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx @@ -170,6 +170,7 @@ const EditTextPart = ({ 'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4', removeFocusRings, )} + aria-label={localize('com_ui_editable_message')} dir={isRTL ? 'rtl' : 'ltr'} />
diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index b16c6458c7..b6a7032e9f 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -201,7 +201,6 @@ const Conversations: FC = ({ overscanRowCount={10} className="outline-none" style={{ outline: 'none' }} - role="list" aria-label="Conversations" onRowsRendered={handleRowsRendered} tabIndex={-1} diff --git a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx index 46310268f0..cbbb612251 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -77,7 +77,13 @@ export default function ShareButton({
{showQR && (
- +
)} @@ -87,6 +93,7 @@ export default function ShareButton({ diff --git a/client/src/components/Endpoints/Settings/Advanced.tsx b/client/src/components/Endpoints/Settings/Advanced.tsx index d0beaa9020..504e6cd94d 100644 --- a/client/src/components/Endpoints/Settings/Advanced.tsx +++ b/client/src/components/Endpoints/Settings/Advanced.tsx @@ -151,6 +151,7 @@ export default function Settings({ min={0} step={0.01} className="flex h-4 w-full" + aria-labelledby="temp-int" /> @@ -160,7 +161,9 @@ export default function Settings({
@@ -199,7 +203,9 @@ export default function Settings({
@@ -238,7 +245,9 @@ export default function Settings({
@@ -306,6 +316,7 @@ export default function Settings({ onCheckedChange={(checked: boolean) => setResendFiles(checked)} disabled={readonly} className="flex" + aria-label={localize('com_endpoint_plug_resend_files')} /> @@ -323,6 +334,7 @@ export default function Settings({ max={2} min={0} step={1} + aria-label={localize('com_endpoint_plug_image_detail')} /> diff --git a/client/src/components/Endpoints/Settings/AgentSettings.tsx b/client/src/components/Endpoints/Settings/AgentSettings.tsx index f41a8bc19e..f4425a4db4 100644 --- a/client/src/components/Endpoints/Settings/AgentSettings.tsx +++ b/client/src/components/Endpoints/Settings/AgentSettings.tsx @@ -53,7 +53,9 @@ export default function Settings({ conversation, setOption, models, readonly }:
@@ -101,6 +104,7 @@ export default function Settings({ conversation, setOption, models, readonly }: onCheckedChange={onCheckedChangeAgent} disabled={readonly} className="ml-4 mt-2" + aria-label={localize('com_endpoint_plug_use_functions')} /> @@ -119,6 +123,7 @@ export default function Settings({ conversation, setOption, models, readonly }: onCheckedChange={onCheckedChangeSkip} disabled={readonly} className="ml-4 mt-2" + aria-label={localize('com_endpoint_plug_skip_completion')} /> diff --git a/client/src/components/Endpoints/Settings/Google.tsx b/client/src/components/Endpoints/Settings/Google.tsx index 6e513c1791..18bf95a1d0 100644 --- a/client/src/components/Endpoints/Settings/Google.tsx +++ b/client/src/components/Endpoints/Settings/Google.tsx @@ -171,6 +171,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.temperature.min} step={google.temperature.step} className="flex h-4 w-full" + aria-labelledby="temp-int" /> @@ -211,6 +212,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.topP.min} step={google.topP.step} className="flex h-4 w-full" + aria-labelledby="top-p-int" /> @@ -252,6 +254,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.topK.min} step={google.topK.step} className="flex h-4 w-full" + aria-labelledby="top-k-int" /> @@ -296,6 +299,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.maxOutputTokens.min} step={google.maxOutputTokens.step} className="flex h-4 w-full" + aria-labelledby="max-tokens-int" /> @@ -296,6 +297,7 @@ export default function Settings({ min={0} step={0.01} className="flex h-4 w-full" + aria-labelledby="top-p-int" /> @@ -337,6 +339,7 @@ export default function Settings({ min={-2} step={0.01} className="flex h-4 w-full" + aria-labelledby="freq-penalty-int" /> @@ -378,6 +381,7 @@ export default function Settings({ min={-2} step={0.01} className="flex h-4 w-full" + aria-labelledby="pres-penalty-int" /> diff --git a/client/src/components/Nav/ExportConversation/ExportModal.tsx b/client/src/components/Nav/ExportConversation/ExportModal.tsx index 642b5bbc81..2083ddec1a 100644 --- a/client/src/components/Nav/ExportConversation/ExportModal.tsx +++ b/client/src/components/Nav/ExportConversation/ExportModal.tsx @@ -124,13 +124,15 @@ export default function ExportModal({ disabled={!exportOptionsSupport} checked={includeOptions} onCheckedChange={setIncludeOptions} + aria-labelledby="includeOptions-label" />
@@ -146,13 +148,15 @@ export default function ExportModal({ disabled={!exportBranchesSupport} checked={exportBranches} onCheckedChange={setExportBranches} + aria-labelledby="exportBranches-label" />
@@ -163,8 +167,14 @@ export default function ExportModal({ {localize('com_nav_export_recursive_or_sequential')}
- +
); diff --git a/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx b/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx index 02a5ee256e..949453cb5c 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx @@ -30,6 +30,7 @@ export default function SaveDraft({ onCheckedChange={handleCheckedChange} className="ml-4" data-testid="showThinking" + aria-label={localize('com_nav_show_thinking')} />
); diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index ae25223a9b..bcc6a4af9c 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -13,7 +13,6 @@ import { useMediaQuery, OGDialogHeader, OGDialogTitle, - TooltipAnchor, DataTable, Spinner, Button, @@ -246,37 +245,27 @@ export default function SharedLinks() { }, cell: ({ row }) => (
- { - window.open(`/c/${row.original.conversationId}`, '_blank'); - }} - title={localize('com_ui_view_source')} - > - - - } - /> - { - setDeleteRow(row.original); - setIsDeleteOpen(true); - }} - title={localize('com_ui_delete')} - > - - - } - /> + +
), }, diff --git a/client/src/components/Prompts/AdminSettings.tsx b/client/src/components/Prompts/AdminSettings.tsx index 6f1580800e..7b25db721c 100644 --- a/client/src/components/Prompts/AdminSettings.tsx +++ b/client/src/components/Prompts/AdminSettings.tsx @@ -53,6 +53,7 @@ const LabelController: React.FC = ({ } }} value={field.value.toString()} + aria-label={label} /> )} /> @@ -216,7 +217,12 @@ const AdminSettings = () => { ))}
-
diff --git a/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx b/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx index 17c82c648d..64d6bd60ec 100644 --- a/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx +++ b/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx @@ -28,7 +28,7 @@ export default function AlwaysMakeProd({ checked={alwaysMakeProd} onCheckedChange={handleCheckedChange} data-testid="alwaysMakeProd" - aria-label="Always make prompt production" + aria-label={localize('com_nav_always_make_prod')} />
{localize('com_nav_always_make_prod')}
diff --git a/client/src/components/Prompts/Groups/AutoSendPrompt.tsx b/client/src/components/Prompts/Groups/AutoSendPrompt.tsx index 430506a748..182580a49c 100644 --- a/client/src/components/Prompts/Groups/AutoSendPrompt.tsx +++ b/client/src/components/Prompts/Groups/AutoSendPrompt.tsx @@ -30,7 +30,7 @@ export default function AutoSendPrompt({ >
{localize('com_nav_auto_send_prompts')}
{ + e.stopPropagation(); + }} className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" >