From 2524d3336264084caa9cc5e909186e10f00adbf5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 10 Nov 2025 19:05:30 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=82=20refactor:=20Cleanup=20File=20Fil?= =?UTF-8?q?tering=20Logic,=20Improve=20Validation=20(#10414)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add filterFilesByEndpointConfig to filter disabled file processing by provider * chore: explicit define of endpointFileConfig for better debugging * refactor: move `normalizeEndpointName` to data-provider as used app-wide * chore: remove overrideEndpoint from useFileHandling * refactor: improve endpoint file config selection * refactor: update filterFilesByEndpointConfig to accept structured parameters and improve endpoint file config handling * refactor: replace defaultFileConfig with getEndpointFileConfig for improved file configuration handling across components * test: add comprehensive unit tests for getEndpointFileConfig to validate endpoint configuration handling * refactor: streamline agent endpoint assignment and improve file filtering logic * feat: add error handling for disabled file uploads in endpoint configuration * refactor: update encodeAndFormat functions to accept structured parameters for provider and endpoint * refactor: streamline requestFiles handling in initializeAgent function * fix: getEndpointFileConfig partial config merging scenarios * refactor: enhance mergeWithDefault function to support document-supported providers with comprehensive MIME types * refactor: user-configured default file config in getEndpointFileConfig * fix: prevent file handling when endpoint is disabled and file is dragged to chat * refactor: move `getEndpointField` to `data-provider` and update usage across components and hooks * fix: prioritize endpointType based on agent.endpoint in file filtering logic * fix: prioritize agent.endpoint in file filtering logic and remove unnecessary endpointType defaulting --- api/app/clients/AnthropicClient.js | 8 +- api/app/clients/BaseClient.js | 11 +- api/app/clients/GoogleClient.js | 4 +- api/app/clients/OpenAIClient.js | 8 +- api/server/controllers/agents/client.js | 5 +- api/server/routes/files/multer.js | 18 +- .../services/Config/getEndpointsConfig.js | 2 +- .../services/Config/loadConfigModels.js | 8 +- api/server/services/Endpoints/agents/agent.js | 20 +- api/server/services/Files/images/encode.js | 20 +- api/server/services/Files/process.js | 18 +- client/src/Providers/DragDropContext.tsx | 2 +- .../Chat/Input/Files/AttachFileChat.tsx | 28 +- .../Chat/Input/Files/AttachFileMenu.tsx | 7 +- .../components/Chat/Input/HeaderOptions.tsx | 73 +- .../Chat/Menus/Endpoints/DialogManager.tsx | 3 +- .../Menus/Endpoints/components/SpecIcon.tsx | 3 +- .../Chat/Menus/Presets/PresetItems.tsx | 7 +- .../components/Chat/Messages/MessageIcon.tsx | 3 +- client/src/components/Endpoints/ConvoIcon.tsx | 3 +- .../src/components/Endpoints/EndpointIcon.tsx | 8 +- .../components/Endpoints/EndpointSettings.tsx | 5 +- .../SidePanel/Agents/AgentConfig.tsx | 3 +- .../SidePanel/Agents/Code/Files.tsx | 14 +- .../SidePanel/Agents/FileContext.tsx | 14 +- .../SidePanel/Agents/FileSearch.tsx | 14 +- .../SidePanel/Agents/ModelPanel.tsx | 3 +- .../SidePanel/Builder/CodeFiles.tsx | 17 +- .../SidePanel/Builder/Knowledge.tsx | 13 +- .../components/SidePanel/Files/PanelTable.tsx | 27 +- .../components/SidePanel/Parameters/Panel.tsx | 3 +- client/src/components/SidePanel/SidePanel.tsx | 3 +- client/src/hooks/Chat/useChatFunctions.ts | 3 +- .../hooks/Conversations/useGenerateConvo.ts | 12 +- .../Conversations/useNavigateToConvo.tsx | 10 +- client/src/hooks/Endpoint/useEndpoints.ts | 3 +- client/src/hooks/Files/useDragHelpers.ts | 34 +- client/src/hooks/Files/useFileHandling.ts | 42 +- .../hooks/Files/useSharePointFileHandling.ts | 4 +- client/src/hooks/Input/useRequiresKey.ts | 2 +- client/src/hooks/useNewConvo.ts | 12 +- client/src/locales/en/translation.json | 1 + client/src/utils/endpoints.spec.ts | 9 +- client/src/utils/endpoints.ts | 19 +- client/src/utils/files.ts | 8 +- packages/api/src/app/config.test.ts | 1 - packages/api/src/app/config.ts | 8 +- packages/api/src/endpoints/custom/config.ts | 4 +- packages/api/src/files/encode/audio.ts | 12 +- .../api/src/files/encode/document.spec.ts | 8 +- packages/api/src/files/encode/document.ts | 13 +- packages/api/src/files/encode/utils.ts | 17 +- packages/api/src/files/encode/video.ts | 12 +- packages/api/src/files/filter.spec.ts | 692 +++++++++++ packages/api/src/files/filter.ts | 44 + packages/api/src/files/index.ts | 1 + packages/api/src/utils/common.ts | 9 - packages/data-provider/src/config.ts | 25 +- .../data-provider/src/file-config.spec.ts | 1097 +++++++++++++++++ packages/data-provider/src/file-config.ts | 147 ++- packages/data-provider/src/utils.ts | 8 + packages/data-schemas/src/app/specs.ts | 10 +- 62 files changed, 2352 insertions(+), 290 deletions(-) create mode 100644 packages/api/src/files/filter.spec.ts create mode 100644 packages/api/src/files/filter.ts create mode 100644 packages/data-provider/src/file-config.spec.ts diff --git a/api/app/clients/AnthropicClient.js b/api/app/clients/AnthropicClient.js index cb884f2d54..16a79278f1 100644 --- a/api/app/clients/AnthropicClient.js +++ b/api/app/clients/AnthropicClient.js @@ -305,11 +305,9 @@ class AnthropicClient extends BaseClient { } async addImageURLs(message, attachments) { - const { files, image_urls } = await encodeAndFormat( - this.options.req, - attachments, - EModelEndpoint.anthropic, - ); + const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, { + endpoint: EModelEndpoint.anthropic, + }); message.image_urls = image_urls.length ? image_urls : undefined; return files; } diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 5c6561396e..185e1c964f 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -1213,6 +1213,7 @@ class BaseClient { attachments, { provider: this.options.agent?.provider, + endpoint: this.options.agent?.endpoint, useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi, }, getStrategyFunctions, @@ -1228,7 +1229,10 @@ class BaseClient { const videoResult = await encodeAndFormatVideos( this.options.req, attachments, - this.options.agent.provider, + { + provider: this.options.agent?.provider, + endpoint: this.options.agent?.endpoint, + }, getStrategyFunctions, ); message.videos = @@ -1240,7 +1244,10 @@ class BaseClient { const audioResult = await encodeAndFormatAudios( this.options.req, attachments, - this.options.agent.provider, + { + provider: this.options.agent?.provider, + endpoint: this.options.agent?.endpoint, + }, getStrategyFunctions, ); message.audios = diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js index 9322778e73..760889df8c 100644 --- a/api/app/clients/GoogleClient.js +++ b/api/app/clients/GoogleClient.js @@ -305,7 +305,9 @@ class GoogleClient extends BaseClient { const { files, image_urls } = await encodeAndFormat( this.options.req, attachments, - EModelEndpoint.google, + { + endpoint: EModelEndpoint.google, + }, mode, ); message.image_urls = image_urls.length ? image_urls : undefined; diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index dca43ea3cb..f4c42351e3 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -354,11 +354,9 @@ class OpenAIClient extends BaseClient { * @returns {Promise} */ async addImageURLs(message, attachments) { - const { files, image_urls } = await encodeAndFormat( - this.options.req, - attachments, - this.options.endpoint, - ); + const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, { + endpoint: this.options.endpoint, + }); message.image_urls = image_urls.length ? image_urls : undefined; return files; } diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 13d779e95a..c19e2c0832 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -210,7 +210,10 @@ class AgentClient extends BaseClient { const { files, image_urls } = await encodeAndFormat( this.options.req, attachments, - this.options.agent.provider, + { + provider: this.options.agent.provider, + endpoint: this.options.endpoint, + }, VisionModes.agents, ); message.image_urls = image_urls.length ? image_urls : undefined; diff --git a/api/server/routes/files/multer.js b/api/server/routes/files/multer.js index 3632f34a21..cb155e2ac2 100644 --- a/api/server/routes/files/multer.js +++ b/api/server/routes/files/multer.js @@ -3,7 +3,11 @@ const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); const { sanitizeFilename } = require('@librechat/api'); -const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider'); +const { + mergeFileConfig, + getEndpointFileConfig, + fileConfig: defaultFileConfig, +} = require('librechat-data-provider'); const { getAppConfig } = require('~/server/services/Config'); const storage = multer.diskStorage({ @@ -53,12 +57,14 @@ const createFileFilter = (customFileConfig) => { } const endpoint = req.body.endpoint; - const supportedTypes = - customFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes ?? - customFileConfig?.endpoints?.default.supportedMimeTypes ?? - defaultFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes; + const endpointType = req.body.endpointType; + const endpointFileConfig = getEndpointFileConfig({ + fileConfig: customFileConfig, + endpoint, + endpointType, + }); - if (!defaultFileConfig.checkType(file.mimetype, supportedTypes)) { + if (!defaultFileConfig.checkType(file.mimetype, endpointFileConfig.supportedMimeTypes)) { return cb(new Error('Unsupported file type: ' + file.mimetype), false); } diff --git a/api/server/services/Config/getEndpointsConfig.js b/api/server/services/Config/getEndpointsConfig.js index 081f63d1da..82c8f01abe 100644 --- a/api/server/services/Config/getEndpointsConfig.js +++ b/api/server/services/Config/getEndpointsConfig.js @@ -109,7 +109,7 @@ async function getEndpointsConfig(req) { * @returns {Promise} */ const checkCapability = async (req, capability) => { - const isAgents = isAgentsEndpoint(req.body?.original_endpoint || req.body?.endpoint); + const isAgents = isAgentsEndpoint(req.body?.endpointType || req.body?.endpoint); const endpointsConfig = await getEndpointsConfig(req); const capabilities = isAgents || endpointsConfig?.[EModelEndpoint.agents]?.capabilities != null diff --git a/api/server/services/Config/loadConfigModels.js b/api/server/services/Config/loadConfigModels.js index 65cbcee4ca..34b6a1ecd2 100644 --- a/api/server/services/Config/loadConfigModels.js +++ b/api/server/services/Config/loadConfigModels.js @@ -1,5 +1,9 @@ -const { isUserProvided, normalizeEndpointName } = require('@librechat/api'); -const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider'); +const { isUserProvided } = require('@librechat/api'); +const { + EModelEndpoint, + extractEnvVariable, + normalizeEndpointName, +} = require('librechat-data-provider'); const { fetchModels } = require('~/server/services/ModelService'); const { getAppConfig } = require('./app'); diff --git a/api/server/services/Endpoints/agents/agent.js b/api/server/services/Endpoints/agents/agent.js index be3f3bf4f9..eebaa2cfc0 100644 --- a/api/server/services/Endpoints/agents/agent.js +++ b/api/server/services/Endpoints/agents/agent.js @@ -3,12 +3,14 @@ const { primeResources, getModelMaxTokens, extractLibreChatParams, + filterFilesByEndpointConfig, optionalChainWithEmptyCheck, } = require('@librechat/api'); const { ErrorTypes, EModelEndpoint, EToolResources, + paramEndpoints, isAgentsEndpoint, replaceSpecialVars, providerEndpointMap, @@ -71,6 +73,9 @@ const initializeAgent = async ({ const { resendFiles, maxContextTokens, modelOptions } = extractLibreChatParams(_modelOptions); + const provider = agent.provider; + agent.endpoint = provider; + if (isInitialAgent && conversationId != null && resendFiles) { const fileIds = (await getConvoFiles(conversationId)) ?? []; /** @type {Set} */ @@ -88,6 +93,19 @@ const initializeAgent = async ({ currentFiles = await processFiles(requestFiles); } + if (currentFiles && currentFiles.length) { + let endpointType; + if (!paramEndpoints.has(agent.endpoint)) { + endpointType = EModelEndpoint.custom; + } + + currentFiles = filterFilesByEndpointConfig(req, { + files: currentFiles, + endpoint: agent.endpoint, + endpointType, + }); + } + const { attachments, tool_resources } = await primeResources({ req, getFiles, @@ -98,7 +116,6 @@ const initializeAgent = async ({ requestFileSet: new Set(requestFiles?.map((file) => file.file_id)), }); - const provider = agent.provider; const { tools: structuredTools, toolContextMap, @@ -113,7 +130,6 @@ const initializeAgent = async ({ tool_resources, })) ?? {}; - agent.endpoint = provider; const { getOptions, overrideProvider } = getProviderConfig({ provider, appConfig }); if (overrideProvider !== agent.provider) { agent.provider = overrideProvider; diff --git a/api/server/services/Files/images/encode.js b/api/server/services/Files/images/encode.js index 7609ed388a..ea9dc9f521 100644 --- a/api/server/services/Files/images/encode.js +++ b/api/server/services/Files/images/encode.js @@ -84,11 +84,15 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]); * Encodes and formats the given files. * @param {ServerRequest} req - The request object. * @param {Array} files - The array of files to encode and format. - * @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image. + * @param {object} params - Object containing provider/endpoint information + * @param {Providers | EModelEndpoint | string} [params.provider] - The provider for the image + * @param {string} [params.endpoint] - Optional: The endpoint for the image * @param {string} [mode] - Optional: The endpoint mode for the image. * @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) { +async function encodeAndFormat(req, files, params, mode) { + const { provider, endpoint } = params; + const effectiveEndpoint = endpoint ?? provider; const promises = []; /** @type {Record, 'prepareImagePayload' | 'getDownloadStream'>>} */ const encodingMethods = {}; @@ -134,7 +138,7 @@ async function encodeAndFormat(req, files, endpoint, mode) { } catch (error) { logger.error('Error processing image from blob storage:', error); } - } else if (source !== FileSources.local && base64Only.has(endpoint)) { + } else if (source !== FileSources.local && base64Only.has(effectiveEndpoint)) { const [_file, imageURL] = await preparePayload(req, file); promises.push([_file, await fetchImageToBase64(imageURL)]); continue; @@ -184,15 +188,19 @@ async function encodeAndFormat(req, files, endpoint, mode) { continue; } - if (endpoint && endpoint === EModelEndpoint.google && mode === VisionModes.generative) { + if ( + effectiveEndpoint && + effectiveEndpoint === EModelEndpoint.google && + mode === VisionModes.generative + ) { delete imagePart.image_url; imagePart.inlineData = { mimeType: file.type, data: imageContent, }; - } else if (endpoint && endpoint === EModelEndpoint.google) { + } else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.google) { imagePart.image_url = imagePart.image_url.url; - } else if (endpoint && endpoint === EModelEndpoint.anthropic) { + } else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.anthropic) { imagePart.type = 'image'; imagePart.source = { type: 'base64', diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 701412523d..f586554ae8 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -15,6 +15,7 @@ const { checkOpenAIStorage, removeNullishValues, isAssistantsEndpoint, + getEndpointFileConfig, } = require('librechat-data-provider'); const { EnvVar } = require('@librechat/agents'); const { logger } = require('@librechat/data-schemas'); @@ -994,7 +995,7 @@ async function saveBase64Image( */ function filterFile({ req, image, isAvatar }) { const { file } = req; - const { endpoint, file_id, width, height } = req.body; + const { endpoint, endpointType, file_id, width, height } = req.body; if (!file_id && !isAvatar) { throw new Error('No file_id provided'); @@ -1016,9 +1017,13 @@ function filterFile({ req, image, isAvatar }) { const appConfig = req.config; const fileConfig = mergeFileConfig(appConfig.fileConfig); - const { fileSizeLimit: sizeLimit, supportedMimeTypes } = - fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default; - const fileSizeLimit = isAvatar === true ? fileConfig.avatarSizeLimit : sizeLimit; + const endpointFileConfig = getEndpointFileConfig({ + endpoint, + fileConfig, + endpointType, + }); + const fileSizeLimit = + isAvatar === true ? fileConfig.avatarSizeLimit : endpointFileConfig.fileSizeLimit; if (file.size > fileSizeLimit) { throw new Error( @@ -1028,7 +1033,10 @@ function filterFile({ req, image, isAvatar }) { ); } - const isSupportedMimeType = fileConfig.checkType(file.mimetype, supportedMimeTypes); + const isSupportedMimeType = fileConfig.checkType( + file.mimetype, + endpointFileConfig.supportedMimeTypes, + ); if (!isSupportedMimeType) { throw new Error('Unsupported file type'); diff --git a/client/src/Providers/DragDropContext.tsx b/client/src/Providers/DragDropContext.tsx index 3a5fe2924c..35827c7e96 100644 --- a/client/src/Providers/DragDropContext.tsx +++ b/client/src/Providers/DragDropContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useMemo } from 'react'; +import { getEndpointField } from 'librechat-data-provider'; import type { EModelEndpoint } from 'librechat-data-provider'; import { useGetEndpointsQuery } from '~/data-provider'; -import { getEndpointField } from '~/utils/endpoints'; import { useChatContext } from './ChatContext'; interface DragDropContextValue { diff --git a/client/src/components/Chat/Input/Files/AttachFileChat.tsx b/client/src/components/Chat/Input/Files/AttachFileChat.tsx index 4757c598a5..90ac3145bf 100644 --- a/client/src/components/Chat/Input/Files/AttachFileChat.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileChat.tsx @@ -5,12 +5,12 @@ import { EModelEndpoint, mergeFileConfig, isAgentsEndpoint, + getEndpointField, isAssistantsEndpoint, - fileConfig as defaultFileConfig, + getEndpointFileConfig, } from 'librechat-data-provider'; -import type { EndpointFileConfig, TConversation } from 'librechat-data-provider'; +import type { TConversation } from 'librechat-data-provider'; import { useGetFileConfig, useGetEndpointsQuery } from '~/data-provider'; -import { getEndpointField } from '~/utils/endpoints'; import AttachFileMenu from './AttachFileMenu'; import AttachFile from './AttachFile'; @@ -26,7 +26,7 @@ function AttachFileChat({ const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]); const isAssistants = useMemo(() => isAssistantsEndpoint(endpoint), [endpoint]); - const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ + const { data: fileConfig = null } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); @@ -39,9 +39,23 @@ function AttachFileChat({ ); }, [endpoint, endpointsConfig]); - const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''] as EndpointFileConfig | undefined; - const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false; - const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false; + const endpointFileConfig = useMemo( + () => + getEndpointFileConfig({ + endpoint, + fileConfig, + endpointType, + }), + [endpoint, fileConfig, endpointType], + ); + const endpointSupportsFiles: boolean = useMemo( + () => supportsFiles[endpointType ?? endpoint ?? ''] ?? false, + [endpointType, endpoint], + ); + const isUploadDisabled = useMemo( + () => (disableInputs || endpointFileConfig?.disabled) ?? false, + [disableInputs, endpointFileConfig?.disabled], + ); if (isAssistants && endpointSupportsFiles && !isUploadDisabled) { return ; diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index 821678cfc8..03b3db4343 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -61,13 +61,8 @@ const AttachFileMenu = ({ ephemeralAgentByConvoId(conversationId), ); const [toolResource, setToolResource] = useState(); - const { handleFileChange } = useFileHandling({ - overrideEndpoint: EModelEndpoint.agents, - overrideEndpointFileConfig: endpointFileConfig, - }); + const { handleFileChange } = useFileHandling(); const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ - overrideEndpoint: EModelEndpoint.agents, - overrideEndpointFileConfig: endpointFileConfig, toolResource, }); diff --git a/client/src/components/Chat/Input/HeaderOptions.tsx b/client/src/components/Chat/Input/HeaderOptions.tsx index 5ba2639412..f37cfc0c14 100644 --- a/client/src/components/Chat/Input/HeaderOptions.tsx +++ b/client/src/components/Chat/Input/HeaderOptions.tsx @@ -1,10 +1,8 @@ -import { useRecoilState } from 'recoil'; +import { useState } from 'react'; import { Settings2 } from 'lucide-react'; -import { useState, useEffect, useMemo } from 'react'; +import { TooltipAnchor } from '@librechat/client'; import { Root, Anchor } from '@radix-ui/react-popover'; -import { PluginStoreDialog, TooltipAnchor } from '@librechat/client'; -import { useUserKeyQuery } from 'librechat-data-provider/react-query'; -import { EModelEndpoint, isParamEndpoint, tConvoUpdateSchema } from 'librechat-data-provider'; +import { isParamEndpoint, getEndpointField, tConvoUpdateSchema } from 'librechat-data-provider'; import type { TPreset, TInterfaceConfig } from 'librechat-data-provider'; import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints'; import { useSetIndexOptions, useLocalize } from '~/hooks'; @@ -12,8 +10,6 @@ import { useGetEndpointsQuery } from '~/data-provider'; import OptionsPopover from './OptionsPopover'; import PopoverButtons from './PopoverButtons'; import { useChatContext } from '~/Providers'; -import { getEndpointField } from '~/utils'; -import store from '~/store'; export default function HeaderOptions({ interfaceConfig, @@ -23,36 +19,11 @@ export default function HeaderOptions({ const { data: endpointsConfig } = useGetEndpointsQuery(); const [saveAsDialogShow, setSaveAsDialogShow] = useState(false); - const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState( - store.showPluginStoreDialog, - ); const localize = useLocalize(); const { showPopover, conversation, setShowPopover } = useChatContext(); const { setOption } = useSetIndexOptions(); - const { endpoint, conversationId } = conversation ?? {}; - const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(endpoint ?? ''); - const userProvidesKey = useMemo( - () => !!(endpointsConfig?.[endpoint ?? '']?.userProvide ?? false), - [endpointsConfig, endpoint], - ); - const keyProvided = useMemo( - () => (userProvidesKey ? !!(keyExpiry.expiresAt ?? '') : true), - [keyExpiry.expiresAt, userProvidesKey], - ); - - const noSettings = useMemo<{ [key: string]: boolean }>( - () => ({ - [EModelEndpoint.chatGPTBrowser]: true, - }), - [conversationId], - ); - - useEffect(() => { - if (endpoint && noSettings[endpoint]) { - setShowPopover(false); - } - }, [endpoint, noSettings]); + const { endpoint } = conversation ?? {}; const saveAsPreset = () => { setSaveAsDialogShow(true); @@ -76,22 +47,20 @@ export default function HeaderOptions({
- {!noSettings[endpoint] && - interfaceConfig?.parameters === true && - paramEndpoint === false && ( - - - - )} + {interfaceConfig?.parameters === true && paramEndpoint === false && ( + + + + )}
{interfaceConfig?.parameters === true && paramEndpoint === false && ( )} - {interfaceConfig?.parameters === true && ( - - )}
diff --git a/client/src/components/Chat/Menus/Endpoints/DialogManager.tsx b/client/src/components/Chat/Menus/Endpoints/DialogManager.tsx index fc6278c20a..75eed646b0 100644 --- a/client/src/components/Chat/Menus/Endpoints/DialogManager.tsx +++ b/client/src/components/Chat/Menus/Endpoints/DialogManager.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { EModelEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, getEndpointField } from 'librechat-data-provider'; import { SetKeyDialog } from '~/components/Input/SetKeyDialog'; -import { getEndpointField } from '~/utils'; interface DialogManagerProps { keyDialogOpen: boolean; diff --git a/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx b/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx index 4ba29325be..1a3d9ca480 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx @@ -1,7 +1,8 @@ import React, { memo } from 'react'; +import { getEndpointField } from 'librechat-data-provider'; import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider'; import type { IconMapProps } from '~/common'; -import { getModelSpecIconURL, getIconKey, getEndpointField } from '~/utils'; +import { getModelSpecIconURL, getIconKey } from '~/utils'; import { URLIcon } from '~/components/Endpoints/URLIcon'; import { icons } from '~/hooks/Endpoint/Icons'; diff --git a/client/src/components/Chat/Menus/Presets/PresetItems.tsx b/client/src/components/Chat/Menus/Presets/PresetItems.tsx index a0c65bc04c..6a472a0142 100644 --- a/client/src/components/Chat/Menus/Presets/PresetItems.tsx +++ b/client/src/components/Chat/Menus/Presets/PresetItems.tsx @@ -1,20 +1,21 @@ import { useRecoilValue } from 'recoil'; import { Close } from '@radix-ui/react-popover'; import { Flipper, Flipped } from 'react-flip-toolkit'; +import { getEndpointField } from 'librechat-data-provider'; import { Dialog, - DialogTrigger, Label, - DialogTemplate, PinIcon, EditIcon, TrashIcon, + DialogTrigger, + DialogTemplate, } from '@librechat/client'; import type { TPreset } from 'librechat-data-provider'; import type { FC } from 'react'; -import { getPresetTitle, getEndpointField, getIconKey } from '~/utils'; import FileUpload from '~/components/Chat/Input/Files/FileUpload'; import { useGetEndpointsQuery } from '~/data-provider'; +import { getPresetTitle, getIconKey } from '~/utils'; import { MenuSeparator, MenuItem } from '../UI'; import { icons } from '~/hooks/Endpoint/Icons'; import { useLocalize } from '~/hooks'; diff --git a/client/src/components/Chat/Messages/MessageIcon.tsx b/client/src/components/Chat/Messages/MessageIcon.tsx index 8ef8ed794a..0857bb58a9 100644 --- a/client/src/components/Chat/Messages/MessageIcon.tsx +++ b/client/src/components/Chat/Messages/MessageIcon.tsx @@ -1,9 +1,10 @@ import React, { useMemo, memo } from 'react'; +import { getEndpointField } from 'librechat-data-provider'; import type { Assistant, Agent } from 'librechat-data-provider'; import type { TMessageIcon } from '~/common'; -import { getEndpointField, getIconEndpoint, logger } from '~/utils'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import { useGetEndpointsQuery } from '~/data-provider'; +import { getIconEndpoint, logger } from '~/utils'; import Icon from '~/components/Endpoints/Icon'; const MessageIcon = memo( diff --git a/client/src/components/Endpoints/ConvoIcon.tsx b/client/src/components/Endpoints/ConvoIcon.tsx index faed70a9b9..0ac36924a6 100644 --- a/client/src/components/Endpoints/ConvoIcon.tsx +++ b/client/src/components/Endpoints/ConvoIcon.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; +import { getEndpointField } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; -import { getEndpointField, getIconKey, getEntity, getIconEndpoint } from '~/utils'; +import { getIconKey, getEntity, getIconEndpoint } from '~/utils'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import { icons } from '~/hooks/Endpoint/Icons'; diff --git a/client/src/components/Endpoints/EndpointIcon.tsx b/client/src/components/Endpoints/EndpointIcon.tsx index f635388f0e..c32ea12369 100644 --- a/client/src/components/Endpoints/EndpointIcon.tsx +++ b/client/src/components/Endpoints/EndpointIcon.tsx @@ -1,13 +1,13 @@ -import { isAssistantsEndpoint } from 'librechat-data-provider'; +import { getEndpointField, isAssistantsEndpoint } from 'librechat-data-provider'; import type { - TConversation, - TEndpointsConfig, TPreset, + TConversation, TAssistantsMap, + TEndpointsConfig, } from 'librechat-data-provider'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import MinimalIcon from '~/components/Endpoints/MinimalIcon'; -import { getEndpointField, getIconEndpoint } from '~/utils'; +import { getIconEndpoint } from '~/utils'; export default function EndpointIcon({ conversation, diff --git a/client/src/components/Endpoints/EndpointSettings.tsx b/client/src/components/Endpoints/EndpointSettings.tsx index ce8be90634..d470f53062 100644 --- a/client/src/components/Endpoints/EndpointSettings.tsx +++ b/client/src/components/Endpoints/EndpointSettings.tsx @@ -1,10 +1,11 @@ import { useRecoilValue } from 'recoil'; -import { SettingsViews, TConversation } from 'librechat-data-provider'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; +import { getEndpointField, SettingsViews } from 'librechat-data-provider'; +import type { TConversation } from 'librechat-data-provider'; import type { TSettingsProps } from '~/common'; import { useGetEndpointsQuery } from '~/data-provider'; -import { cn, getEndpointField } from '~/utils'; import { getSettings } from './Settings'; +import { cn } from '~/utils'; import store from '~/store'; export default function Settings({ diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index f1409e6eea..342b8c0da7 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -1,12 +1,11 @@ import React, { useState, useMemo, useCallback } from 'react'; import { useToastContext } from '@librechat/client'; -import { EModelEndpoint } from 'librechat-data-provider'; import { Controller, useWatch, useFormContext } from 'react-hook-form'; +import { EModelEndpoint, getEndpointField } from 'librechat-data-provider'; import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common'; import { removeFocusOutlines, processAgentOption, - getEndpointField, defaultTextProps, validateEmail, getIconKey, diff --git a/client/src/components/SidePanel/Agents/Code/Files.tsx b/client/src/components/SidePanel/Agents/Code/Files.tsx index fc7f9579e1..3ef7da9ca6 100644 --- a/client/src/components/SidePanel/Agents/Code/Files.tsx +++ b/client/src/components/SidePanel/Agents/Code/Files.tsx @@ -6,9 +6,8 @@ import { EModelEndpoint, mergeFileConfig, AgentCapabilities, - fileConfig as defaultFileConfig, + getEndpointFileConfig, } from 'librechat-data-provider'; -import type { EndpointFileConfig } from 'librechat-data-provider'; import type { ExtendedFile, AgentForm } from '~/common'; import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks'; import FileRow from '~/components/Chat/Input/Files/FileRow'; @@ -30,12 +29,11 @@ export default function Files({ const { watch } = useFormContext(); const fileInputRef = useRef(null); const [files, setFiles] = useState>(new Map()); - const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ + const { data: fileConfig = null } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); const { abortUpload, handleFileChange } = useFileHandling({ fileSetter: setFiles, - overrideEndpoint: EModelEndpoint.agents, additionalMetadata: { agent_id, tool_resource }, }); @@ -51,9 +49,11 @@ export default function Files({ const codeChecked = watch(AgentCapabilities.execute_code); - const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents] as - | EndpointFileConfig - | undefined; + const endpointFileConfig = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.agents, + endpointType: EModelEndpoint.agents, + }); const isUploadDisabled = endpointFileConfig?.disabled ?? false; if (isUploadDisabled) { diff --git a/client/src/components/SidePanel/Agents/FileContext.tsx b/client/src/components/SidePanel/Agents/FileContext.tsx index cfe705dbdf..d437e8457f 100644 --- a/client/src/components/SidePanel/Agents/FileContext.tsx +++ b/client/src/components/SidePanel/Agents/FileContext.tsx @@ -5,7 +5,7 @@ import { EModelEndpoint, EToolResources, mergeFileConfig, - fileConfig as defaultFileConfig, + getEndpointFileConfig, } from 'librechat-data-provider'; import { HoverCard, @@ -41,17 +41,15 @@ export default function FileContext({ const { data: startupConfig } = useGetStartupConfig(); const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled; - const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ + const { data: fileConfig = null } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); const { handleFileChange } = useFileHandling({ - overrideEndpoint: EModelEndpoint.agents, additionalMetadata: { agent_id, tool_resource: EToolResources.context }, fileSetter: setFiles, }); const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ - overrideEndpoint: EModelEndpoint.agents, additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, fileSetter: setFiles, }); @@ -65,8 +63,12 @@ export default function FileContext({ 750, ); - const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents]; - const isUploadDisabled = endpointFileConfig.disabled ?? false; + const endpointFileConfig = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.agents, + endpointType: EModelEndpoint.agents, + }); + const isUploadDisabled = endpointFileConfig?.disabled ?? false; const handleSharePointFilesSelected = async (sharePointFiles: any[]) => { try { await handleSharePointFiles(sharePointFiles); diff --git a/client/src/components/SidePanel/Agents/FileSearch.tsx b/client/src/components/SidePanel/Agents/FileSearch.tsx index 573742f8f1..a82fc8bdfb 100644 --- a/client/src/components/SidePanel/Agents/FileSearch.tsx +++ b/client/src/components/SidePanel/Agents/FileSearch.tsx @@ -8,7 +8,7 @@ import { EToolResources, mergeFileConfig, AgentCapabilities, - fileConfig as defaultFileConfig, + getEndpointFileConfig, } from 'librechat-data-provider'; import type { ExtendedFile, AgentForm } from '~/common'; import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling'; @@ -38,18 +38,16 @@ export default function FileSearch({ // Get startup configuration for SharePoint feature flag const { data: startupConfig } = useGetStartupConfig(); - const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ + const { data: fileConfig = null } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); const { handleFileChange } = useFileHandling({ - overrideEndpoint: EModelEndpoint.agents, additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, fileSetter: setFiles, }); const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ - overrideEndpoint: EModelEndpoint.agents, additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, fileSetter: setFiles, }); @@ -66,8 +64,12 @@ export default function FileSearch({ const fileSearchChecked = watch(AgentCapabilities.file_search); - const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents]; - const isUploadDisabled = endpointFileConfig.disabled ?? false; + const endpointFileConfig = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.agents, + endpointType: EModelEndpoint.agents, + }); + const isUploadDisabled = endpointFileConfig?.disabled ?? false; const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled; const disabledUploadButton = isEphemeralAgent(agent_id) || fileSearchChecked === false; diff --git a/client/src/components/SidePanel/Agents/ModelPanel.tsx b/client/src/components/SidePanel/Agents/ModelPanel.tsx index fcd4acc52a..bfcac5bdea 100644 --- a/client/src/components/SidePanel/Agents/ModelPanel.tsx +++ b/client/src/components/SidePanel/Agents/ModelPanel.tsx @@ -7,6 +7,7 @@ import { componentMapping } from '~/components/SidePanel/Parameters/components'; import { alternateName, getSettingsKeys, + getEndpointField, LocalStorageKeys, SettingDefinition, agentParamSettings, @@ -14,9 +15,9 @@ import { import type * as t from 'librechat-data-provider'; import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common'; import { useGetEndpointsQuery } from '~/data-provider'; -import { getEndpointField, cn } from '~/utils'; import { useLocalize } from '~/hooks'; import { Panel } from '~/common'; +import { cn } from '~/utils'; export default function ModelPanel({ providers, diff --git a/client/src/components/SidePanel/Builder/CodeFiles.tsx b/client/src/components/SidePanel/Builder/CodeFiles.tsx index 451bcf8cab..890c963f5e 100644 --- a/client/src/components/SidePanel/Builder/CodeFiles.tsx +++ b/client/src/components/SidePanel/Builder/CodeFiles.tsx @@ -1,10 +1,6 @@ import { useState, useRef, useEffect } from 'react'; -import { - EToolResources, - mergeFileConfig, - fileConfig as defaultFileConfig, -} from 'librechat-data-provider'; -import type { AssistantsEndpoint, EndpointFileConfig } from 'librechat-data-provider'; +import { EToolResources, mergeFileConfig, getEndpointFileConfig } from 'librechat-data-provider'; +import type { AssistantsEndpoint } from 'librechat-data-provider'; import type { ExtendedFile } from '~/common'; import FileRow from '~/components/Chat/Input/Files/FileRow'; import { useGetFileConfig } from '~/data-provider'; @@ -28,11 +24,10 @@ export default function CodeFiles({ const { setFilesLoading } = useChatContext(); const fileInputRef = useRef(null); const [files, setFiles] = useState>(new Map()); - const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ + const { data: fileConfig = null } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); const { handleFileChange } = useFileHandling({ - overrideEndpoint: endpoint, additionalMetadata: { assistant_id, tool_resource }, fileSetter: setFiles, }); @@ -43,7 +38,11 @@ export default function CodeFiles({ } }, [_files]); - const endpointFileConfig = fileConfig.endpoints[endpoint] as EndpointFileConfig | undefined; + const endpointFileConfig = getEndpointFileConfig({ + fileConfig, + endpoint, + endpointType: endpoint, + }); const isUploadDisabled = endpointFileConfig?.disabled ?? false; if (isUploadDisabled) { diff --git a/client/src/components/SidePanel/Builder/Knowledge.tsx b/client/src/components/SidePanel/Builder/Knowledge.tsx index bf5263120e..0ad4f0525a 100644 --- a/client/src/components/SidePanel/Builder/Knowledge.tsx +++ b/client/src/components/SidePanel/Builder/Knowledge.tsx @@ -2,9 +2,9 @@ import { useState, useRef, useEffect } from 'react'; import { mergeFileConfig, retrievalMimeTypes, - fileConfig as defaultFileConfig, + getEndpointFileConfig, } from 'librechat-data-provider'; -import type { AssistantsEndpoint, EndpointFileConfig } from 'librechat-data-provider'; +import type { AssistantsEndpoint } from 'librechat-data-provider'; import type { ExtendedFile } from '~/common'; import FileRow from '~/components/Chat/Input/Files/FileRow'; import { useGetFileConfig } from '~/data-provider'; @@ -38,11 +38,10 @@ export default function Knowledge({ const { setFilesLoading } = useChatContext(); const fileInputRef = useRef(null); const [files, setFiles] = useState>(new Map()); - const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ + const { data: fileConfig = null } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); const { handleFileChange } = useFileHandling({ - overrideEndpoint: endpoint, additionalMetadata: { assistant_id }, fileSetter: setFiles, }); @@ -53,7 +52,11 @@ export default function Knowledge({ } }, [_files]); - const endpointFileConfig = fileConfig.endpoints[endpoint] as EndpointFileConfig | undefined; + const endpointFileConfig = getEndpointFileConfig({ + fileConfig, + endpoint, + endpointType: endpoint, + }); const isUploadDisabled = endpointFileConfig?.disabled ?? false; if (isUploadDisabled) { diff --git a/client/src/components/SidePanel/Files/PanelTable.tsx b/client/src/components/SidePanel/Files/PanelTable.tsx index f341f2f00a..abd458d034 100644 --- a/client/src/components/SidePanel/Files/PanelTable.tsx +++ b/client/src/components/SidePanel/Files/PanelTable.tsx @@ -30,6 +30,7 @@ import { mergeFileConfig, megabyte, isAssistantsEndpoint, + getEndpointFileConfig, type TFile, } from 'librechat-data-provider'; import { useFileMapContext, useChatContext } from '~/Providers'; @@ -86,7 +87,7 @@ export default function DataTable({ columns, data }: DataTablePro const fileMap = useFileMapContext(); const { showToast } = useToastContext(); const { setFiles, conversation } = useChatContext(); - const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ + const { data: fileConfig = null } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); const { addFile } = useUpdateFiles(setFiles); @@ -103,6 +104,7 @@ export default function DataTable({ columns, data }: DataTablePro const fileData = fileMap[file.file_id]; const endpoint = conversation.endpoint; + const endpointType = conversation.endpointType; if (!fileData.source) { return; @@ -126,20 +128,31 @@ export default function DataTable({ columns, data }: DataTablePro }); } - const { fileSizeLimit, supportedMimeTypes } = - fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default; + const endpointFileConfig = getEndpointFileConfig({ + fileConfig, + endpoint, + endpointType, + }); - if (fileData.bytes > fileSizeLimit) { + if (endpointFileConfig.disabled === true) { + showToast({ + message: localize('com_ui_attach_error_disabled'), + status: 'error', + }); + return; + } + + if (fileData.bytes > (endpointFileConfig.fileSizeLimit ?? Number.MAX_SAFE_INTEGER)) { showToast({ message: `${localize('com_ui_attach_error_size')} ${ - fileSizeLimit / megabyte + (endpointFileConfig.fileSizeLimit ?? 0) / megabyte } MB (${endpoint})`, status: 'error', }); return; } - if (!defaultFileConfig.checkType(file.type, supportedMimeTypes)) { + if (!defaultFileConfig.checkType(file.type, endpointFileConfig.supportedMimeTypes ?? [])) { showToast({ message: `${localize('com_ui_attach_error_type')} ${file.type} (${endpoint})`, status: 'error', @@ -162,7 +175,7 @@ export default function DataTable({ columns, data }: DataTablePro metadata: fileData.metadata, }); }, - [addFile, fileMap, conversation, localize, showToast, fileConfig.endpoints], + [addFile, fileMap, conversation, localize, showToast, fileConfig], ); const filenameFilter = table.getColumn('filename')?.getFilterValue() as string; diff --git a/client/src/components/SidePanel/Parameters/Panel.tsx b/client/src/components/SidePanel/Parameters/Panel.tsx index f181622fb2..7541f4f0e8 100644 --- a/client/src/components/SidePanel/Parameters/Panel.tsx +++ b/client/src/components/SidePanel/Parameters/Panel.tsx @@ -5,6 +5,7 @@ import { excludedKeys, paramSettings, getSettingsKeys, + getEndpointField, SettingDefinition, tConvoUpdateSchema, } from 'librechat-data-provider'; @@ -12,9 +13,9 @@ import type { TPreset } from 'librechat-data-provider'; import { SaveAsPresetDialog } from '~/components/Endpoints'; import { useSetIndexOptions, useLocalize } from '~/hooks'; import { useGetEndpointsQuery } from '~/data-provider'; -import { getEndpointField, logger } from '~/utils'; import { componentMapping } from './components'; import { useChatContext } from '~/Providers'; +import { logger } from '~/utils'; export default function Parameters() { const localize = useLocalize(); diff --git a/client/src/components/SidePanel/SidePanel.tsx b/client/src/components/SidePanel/SidePanel.tsx index 865fce20a9..35c164a91c 100644 --- a/client/src/components/SidePanel/SidePanel.tsx +++ b/client/src/components/SidePanel/SidePanel.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useMemo, memo } from 'react'; +import { getEndpointField } from 'librechat-data-provider'; import { useUserKeyQuery } from 'librechat-data-provider/react-query'; import { ResizableHandleAlt, ResizablePanel, useMediaQuery } from '@librechat/client'; import type { TEndpointsConfig, TInterfaceConfig } from 'librechat-data-provider'; @@ -8,7 +9,7 @@ import { useLocalStorage, useLocalize } from '~/hooks'; import { useGetEndpointsQuery } from '~/data-provider'; import NavToggle from '~/components/Nav/NavToggle'; import { useSidePanelContext } from '~/Providers'; -import { cn, getEndpointField } from '~/utils'; +import { cn } from '~/utils'; import Nav from './Nav'; const defaultMinSize = 20; diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 2dcfe37702..114a6376a6 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -6,6 +6,7 @@ import { QueryKeys, ContentTypes, EModelEndpoint, + getEndpointField, isAgentsEndpoint, parseCompactConvo, replaceSpecialVars, @@ -25,10 +26,10 @@ import type { TAskFunction, ExtendedFile } from '~/common'; import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete'; import useGetSender from '~/hooks/Conversations/useGetSender'; import store, { useGetEphemeralAgent } from '~/store'; -import { getEndpointField, logger } from '~/utils'; import useUserKey from '~/hooks/Input/useUserKey'; import { useNavigate } from 'react-router-dom'; import { useAuthContext } from '~/hooks'; +import { logger } from '~/utils'; const logChatRequest = (request: Record) => { logger.log('=====================================\nAsk function called with:'); diff --git a/client/src/hooks/Conversations/useGenerateConvo.ts b/client/src/hooks/Conversations/useGenerateConvo.ts index 0c0728ddec..d96f60e05d 100644 --- a/client/src/hooks/Conversations/useGenerateConvo.ts +++ b/client/src/hooks/Conversations/useGenerateConvo.ts @@ -1,18 +1,18 @@ import { useRecoilValue } from 'recoil'; import { useCallback, useRef, useEffect } from 'react'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; -import { LocalStorageKeys, isAssistantsEndpoint } from 'librechat-data-provider'; +import { getEndpointField, LocalStorageKeys, isAssistantsEndpoint } from 'librechat-data-provider'; import type { - TPreset, - TModelsConfig, - TConversation, TEndpointsConfig, EModelEndpoint, + TModelsConfig, + TConversation, + TPreset, } from 'librechat-data-provider'; -import type { SetterOrUpdater } from 'recoil'; import type { AssistantListItem } from '~/common'; -import { getEndpointField, buildDefaultConvo, getDefaultEndpoint, logger } from '~/utils'; +import type { SetterOrUpdater } from 'recoil'; import useAssistantListMap from '~/hooks/Assistants/useAssistantListMap'; +import { buildDefaultConvo, getDefaultEndpoint, logger } from '~/utils'; import { useGetEndpointsQuery } from '~/data-provider'; import { mainTextareaId } from '~/common'; import store from '~/store'; diff --git a/client/src/hooks/Conversations/useNavigateToConvo.tsx b/client/src/hooks/Conversations/useNavigateToConvo.tsx index bfe4a0b96e..bf8321feb1 100644 --- a/client/src/hooks/Conversations/useNavigateToConvo.tsx +++ b/client/src/hooks/Conversations/useNavigateToConvo.tsx @@ -2,20 +2,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 { QueryKeys, Constants, dataService, getEndpointField } from 'librechat-data-provider'; import type { TEndpointsConfig, TStartupConfig, TModelsConfig, TConversation, } from 'librechat-data-provider'; -import { - getDefaultEndpoint, - clearMessagesCache, - buildDefaultConvo, - getEndpointField, - logger, -} from '~/utils'; +import { getDefaultEndpoint, clearMessagesCache, buildDefaultConvo, logger } from '~/utils'; import { useApplyModelSpecEffects } from '~/hooks/Agents'; import store from '~/store'; diff --git a/client/src/hooks/Endpoint/useEndpoints.ts b/client/src/hooks/Endpoint/useEndpoints.ts index f43f8b5350..acc9810093 100644 --- a/client/src/hooks/Endpoint/useEndpoints.ts +++ b/client/src/hooks/Endpoint/useEndpoints.ts @@ -5,6 +5,7 @@ import { alternateName, EModelEndpoint, PermissionTypes, + getEndpointField, } from 'librechat-data-provider'; import type { TEndpointsConfig, @@ -14,8 +15,8 @@ import type { Agent, } from 'librechat-data-provider'; import type { Endpoint } from '~/common'; -import { mapEndpoints, getIconKey, getEndpointField } from '~/utils'; import { useGetEndpointsQuery } from '~/data-provider'; +import { mapEndpoints, getIconKey } from '~/utils'; import { useHasAccess } from '~/hooks'; import { icons } from './Icons'; diff --git a/client/src/hooks/Files/useDragHelpers.ts b/client/src/hooks/Files/useDragHelpers.ts index 199db81e54..cd3efdb0b0 100644 --- a/client/src/hooks/Files/useDragHelpers.ts +++ b/client/src/hooks/Files/useDragHelpers.ts @@ -1,5 +1,6 @@ import { useState, useMemo, useCallback, useRef } from 'react'; import { useDrop } from 'react-dnd'; +import { useToastContext } from '@librechat/client'; import { NativeTypes } from 'react-dnd-html5-backend'; import { useQueryClient } from '@tanstack/react-query'; import { useRecoilValue, useSetRecoilState } from 'recoil'; @@ -7,10 +8,12 @@ import { Tools, QueryKeys, Constants, - EModelEndpoint, EToolResources, + EModelEndpoint, + mergeFileConfig, AgentCapabilities, isAssistantsEndpoint, + getEndpointFileConfig, defaultAgentCapabilities, } from 'librechat-data-provider'; import type { DropTargetMonitor } from 'react-dnd'; @@ -18,9 +21,12 @@ import type * as t from 'librechat-data-provider'; import store, { ephemeralAgentByConvoId } from '~/store'; import useFileHandling from './useFileHandling'; import { isEphemeralAgent } from '~/common'; +import useLocalize from '../useLocalize'; export default function useDragHelpers() { const queryClient = useQueryClient(); + const { showToast } = useToastContext(); + const localize = useLocalize(); const [showModal, setShowModal] = useState(false); const [draggedFiles, setDraggedFiles] = useState([]); const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined; @@ -33,9 +39,7 @@ export default function useDragHelpers() { [conversation?.endpoint], ); - const { handleFiles } = useFileHandling({ - overrideEndpoint: isAssistants ? undefined : EModelEndpoint.agents, - }); + const { handleFiles } = useFileHandling(); const handleOptionSelect = useCallback( (toolResource: EToolResources | undefined) => { @@ -62,6 +66,26 @@ export default function useDragHelpers() { const handleDrop = useCallback( (item: { files: File[] }) => { + /** Early block: leverage endpoint file config to prevent drag/drop on disabled endpoints */ + const currentEndpoint = conversationRef.current?.endpoint ?? 'default'; + const currentEndpointType = conversationRef.current?.endpointType ?? undefined; + const cfg = queryClient.getQueryData([QueryKeys.fileConfig]); + if (cfg) { + const mergedCfg = mergeFileConfig(cfg); + const endpointCfg = getEndpointFileConfig({ + fileConfig: mergedCfg, + endpoint: currentEndpoint, + endpointType: currentEndpointType, + }); + if (endpointCfg?.disabled === true) { + showToast({ + message: localize('com_ui_attach_error_disabled'), + status: 'error', + }); + return; + } + } + if (isAssistants) { handleFilesRef.current(item.files); return; @@ -110,7 +134,7 @@ export default function useDragHelpers() { setDraggedFiles(item.files); setShowModal(true); }, - [isAssistants, queryClient], + [isAssistants, queryClient, showToast, localize], ); const [{ canDrop, isOver }, drop] = useDrop( diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 7825888985..4c65b80765 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useMemo, useState } from 'react'; import { v4 } from 'uuid'; import { useSetRecoilState } from 'recoil'; import { useToastContext } from '@librechat/client'; @@ -6,16 +6,14 @@ import { useQueryClient } from '@tanstack/react-query'; import { QueryKeys, Constants, - EModelEndpoint, EToolResources, mergeFileConfig, - isAgentsEndpoint, isAssistantsEndpoint, + getEndpointFileConfig, defaultAssistantsVersion, - fileConfig as defaultFileConfig, } from 'librechat-data-provider'; import debounce from 'lodash/debounce'; -import type { EndpointFileConfig, TEndpointsConfig, TError } from 'librechat-data-provider'; +import type { TEndpointsConfig, TError } from 'librechat-data-provider'; import type { ExtendedFile, FileSetter } from '~/common'; import { useGetFileConfig, useUploadFileMutation } from '~/data-provider'; import useLocalize, { TranslationKeys } from '~/hooks/useLocalize'; @@ -29,9 +27,7 @@ import useUpdateFiles from './useUpdateFiles'; type UseFileHandling = { fileSetter?: FileSetter; - overrideEndpoint?: EModelEndpoint; fileFilter?: (file: File) => boolean; - overrideEndpointFileConfig?: EndpointFileConfig; additionalMetadata?: Record; }; @@ -54,17 +50,13 @@ const useFileHandling = (params?: UseFileHandling) => { const agent_id = params?.additionalMetadata?.agent_id ?? ''; const assistant_id = params?.additionalMetadata?.assistant_id ?? ''; + const endpointType = useMemo(() => conversation?.endpointType, [conversation?.endpointType]); + const endpoint = useMemo(() => conversation?.endpoint ?? 'default', [conversation?.endpoint]); const { data: fileConfig = null } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); - const endpoint = useMemo( - () => - params?.overrideEndpoint ?? conversation?.endpointType ?? conversation?.endpoint ?? 'default', - [params?.overrideEndpoint, conversation?.endpointType, conversation?.endpoint], - ); - const displayToast = useCallback(() => { if (errors.length > 1) { // TODO: this should not be a dynamic localize input!! @@ -169,10 +161,7 @@ const useFileHandling = (params?: UseFileHandling) => { const formData = new FormData(); formData.append('endpoint', endpoint); - formData.append( - 'original_endpoint', - conversation?.endpointType || conversation?.endpoint || '', - ); + formData.append('endpointType', endpointType ?? ''); formData.append('file', extendedFile.file as File, encodeURIComponent(filename)); formData.append('file_id', extendedFile.file_id); @@ -194,7 +183,7 @@ const useFileHandling = (params?: UseFileHandling) => { } } - if (isAgentsEndpoint(endpoint)) { + if (!isAssistantsEndpoint(endpointType ?? endpoint)) { if (!agent_id) { formData.append('message_file', 'true'); } @@ -205,9 +194,7 @@ const useFileHandling = (params?: UseFileHandling) => { if (conversation?.agent_id != null && formData.get('agent_id') == null) { formData.append('agent_id', conversation.agent_id); } - } - if (!isAssistantsEndpoint(endpoint)) { uploadFile.mutate(formData); return; } @@ -264,18 +251,19 @@ const useFileHandling = (params?: UseFileHandling) => { /* Validate files */ let filesAreValid: boolean; try { + const endpointFileConfig = getEndpointFileConfig({ + endpoint, + fileConfig, + endpointType, + }); + filesAreValid = validateFiles({ files, fileList, setError, - endpointFileConfig: - params?.overrideEndpointFileConfig ?? - fileConfig?.endpoints?.[endpoint] ?? - fileConfig?.endpoints?.default ?? - defaultFileConfig.endpoints[endpoint] ?? - defaultFileConfig.endpoints.default, + fileConfig, + endpointFileConfig, toolResource: _toolResource, - fileConfig: fileConfig, }); } catch (error) { console.error('file validation error', error); diff --git a/client/src/hooks/Files/useSharePointFileHandling.ts b/client/src/hooks/Files/useSharePointFileHandling.ts index 1c48f9b3ab..11fc0915b7 100644 --- a/client/src/hooks/Files/useSharePointFileHandling.ts +++ b/client/src/hooks/Files/useSharePointFileHandling.ts @@ -5,11 +5,9 @@ import type { SharePointFile } from '~/data-provider/Files/sharepoint'; interface UseSharePointFileHandlingProps { fileSetter?: any; + toolResource?: string; fileFilter?: (file: File) => boolean; additionalMetadata?: Record; - overrideEndpoint?: any; - overrideEndpointFileConfig?: any; - toolResource?: string; } interface UseSharePointFileHandlingReturn { diff --git a/client/src/hooks/Input/useRequiresKey.ts b/client/src/hooks/Input/useRequiresKey.ts index 9c4b248d11..1ffe149c01 100644 --- a/client/src/hooks/Input/useRequiresKey.ts +++ b/client/src/hooks/Input/useRequiresKey.ts @@ -1,6 +1,6 @@ +import { getEndpointField } from 'librechat-data-provider'; import { useChatContext } from '~/Providers/ChatContext'; import { useGetEndpointsQuery } from '~/data-provider'; -import { getEndpointField } from '~/utils'; import useUserKey from './useUserKey'; export default function useRequiresKey() { diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index 9f0e17b297..33e26011f1 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -1,15 +1,16 @@ import { useCallback } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; +import { useRecoilState, useRecoilValue, useSetRecoilState, useRecoilCallback } from 'recoil'; import { Constants, FileSources, EModelEndpoint, isParamEndpoint, + getEndpointField, LocalStorageKeys, isAssistantsEndpoint, } from 'librechat-data-provider'; -import { useRecoilState, useRecoilValue, useSetRecoilState, useRecoilCallback } from 'recoil'; import type { TPreset, TSubmission, @@ -19,19 +20,18 @@ import type { } from 'librechat-data-provider'; import type { AssistantListItem } from '~/common'; import { - getEndpointField, - buildDefaultConvo, + updateLastSelectedModel, + getDefaultModelSpec, getDefaultEndpoint, getModelSpecPreset, - getDefaultModelSpec, - updateLastSelectedModel, + buildDefaultConvo, + logger, } from '~/utils'; 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'; const useNewConvo = (index = 0) => { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index c235190680..ac14e5f6c0 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -716,6 +716,7 @@ "com_ui_attach_error_openai": "Cannot attach Assistant files to other endpoints", "com_ui_attach_error_size": "File size limit exceeded for endpoint:", "com_ui_attach_error_type": "Unsupported file type for endpoint:", + "com_ui_attach_error_disabled": "File uploads are disabled for this endpoint", "com_ui_attach_remove": "Remove file", "com_ui_attach_warn_endpoint": "Non-Assistant files may be ignored without a compatible tool", "com_ui_attachment": "Attachment", diff --git a/client/src/utils/endpoints.spec.ts b/client/src/utils/endpoints.spec.ts index cab26324af..d3a2aadb4b 100644 --- a/client/src/utils/endpoints.spec.ts +++ b/client/src/utils/endpoints.spec.ts @@ -1,11 +1,6 @@ -import { EModelEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, getEndpointField } from 'librechat-data-provider'; import type { TEndpointsConfig, TConfig } from 'librechat-data-provider'; -import { - getEndpointField, - getAvailableEndpoints, - getEndpointsFilter, - mapEndpoints, -} from './endpoints'; +import { getAvailableEndpoints, getEndpointsFilter, mapEndpoints } from './endpoints'; const mockEndpointsConfig: TEndpointsConfig = { [EModelEndpoint.openAI]: { type: undefined, iconURL: 'openAI_icon.png', order: 0 }, diff --git a/client/src/utils/endpoints.ts b/client/src/utils/endpoints.ts index 9ceee3e178..3702fb00ca 100644 --- a/client/src/utils/endpoints.ts +++ b/client/src/utils/endpoints.ts @@ -4,6 +4,7 @@ import { defaultEndpoints, modularEndpoints, LocalStorageKeys, + getEndpointField, isAgentsEndpoint, isAssistantsEndpoint, } from 'librechat-data-provider'; @@ -58,24 +59,6 @@ export const getAvailableEndpoints = ( return availableEndpoints; }; -/** Get the specified field from the endpoint config */ -export function getEndpointField( - endpointsConfig: t.TEndpointsConfig | undefined | null, - endpoint: EModelEndpoint | string | null | undefined, - property: K, -): t.TConfig[K] | undefined { - if (!endpointsConfig || endpoint === null || endpoint === undefined) { - return undefined; - } - - const config = endpointsConfig[endpoint]; - if (!config) { - return undefined; - } - - return config[property]; -} - export function mapEndpoints(endpointsConfig: t.TEndpointsConfig) { const filter = getEndpointsFilter(endpointsConfig); return getAvailableEndpoints(filter, endpointsConfig).sort( diff --git a/client/src/utils/files.ts b/client/src/utils/files.ts index 0959ba91bb..93c810e7ce 100644 --- a/client/src/utils/files.ts +++ b/client/src/utils/files.ts @@ -235,7 +235,13 @@ export const validateFiles = ({ toolResource?: string; fileConfig: FileConfig | null; }) => { - const { fileLimit, fileSizeLimit, totalSizeLimit, supportedMimeTypes } = endpointFileConfig; + const { fileLimit, fileSizeLimit, totalSizeLimit, supportedMimeTypes, disabled } = + endpointFileConfig; + /** Block all uploads if the endpoint is explicitly disabled */ + if (disabled === true) { + setError('com_ui_attach_error_disabled'); + return false; + } const existingFiles = Array.from(files.values()); const incomingTotalSize = fileList.reduce((total, file) => total + file.size, 0); if (incomingTotalSize === 0) { diff --git a/packages/api/src/app/config.test.ts b/packages/api/src/app/config.test.ts index d55f9977fe..f85bb8a62c 100644 --- a/packages/api/src/app/config.test.ts +++ b/packages/api/src/app/config.test.ts @@ -40,7 +40,6 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('~/utils', () => ({ isEnabled: jest.fn((value) => value === 'true'), - normalizeEndpointName: jest.fn((name) => name), })); describe('getTransactionsConfig', () => { diff --git a/packages/api/src/app/config.ts b/packages/api/src/app/config.ts index 8a2a681e65..38144dee2b 100644 --- a/packages/api/src/app/config.ts +++ b/packages/api/src/app/config.ts @@ -1,8 +1,12 @@ import { logger } from '@librechat/data-schemas'; -import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider'; +import { + EModelEndpoint, + removeNullishValues, + normalizeEndpointName, +} from 'librechat-data-provider'; import type { TCustomConfig, TEndpoint, TTransactionsConfig } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; -import { isEnabled, normalizeEndpointName } from '~/utils'; +import { isEnabled } from '~/utils'; /** * Retrieves the balance configuration object diff --git a/packages/api/src/endpoints/custom/config.ts b/packages/api/src/endpoints/custom/config.ts index 220eb43509..ec5c4aa4ae 100644 --- a/packages/api/src/endpoints/custom/config.ts +++ b/packages/api/src/endpoints/custom/config.ts @@ -1,7 +1,7 @@ -import { EModelEndpoint, extractEnvVariable } from 'librechat-data-provider'; +import { EModelEndpoint, extractEnvVariable, normalizeEndpointName } from 'librechat-data-provider'; import type { TCustomEndpoints, TEndpoint, TConfig } from 'librechat-data-provider'; import type { TCustomEndpointsConfig } from '~/types/endpoints'; -import { isUserProvided, normalizeEndpointName } from '~/utils'; +import { isUserProvided } from '~/utils'; /** * Load config endpoints from the cached configuration object diff --git a/packages/api/src/files/encode/audio.ts b/packages/api/src/files/encode/audio.ts index 85b90ce0f8..ef007338b5 100644 --- a/packages/api/src/files/encode/audio.ts +++ b/packages/api/src/files/encode/audio.ts @@ -9,16 +9,19 @@ import { validateAudio } from '~/files/validation'; * 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 params - Object containing provider and optional endpoint + * @param params.provider - The provider to format for (currently only google is supported) + * @param params.endpoint - Optional endpoint name for file config lookup * @param getStrategyFunctions - Function to get strategy functions * @returns Promise that resolves to audio and file metadata */ export async function encodeAndFormatAudios( req: ServerRequest, files: IMongoFile[], - provider: Providers, + params: { provider: Providers; endpoint?: string }, getStrategyFunctions: (source: string) => StrategyFunctions, ): Promise { + const { provider, endpoint } = params; if (!files?.length) { return { audios: [], files: [] }; } @@ -54,7 +57,10 @@ export async function encodeAndFormatAudios( const audioBuffer = Buffer.from(content, 'base64'); /** Extract configured file size limit from fileConfig for this endpoint */ - const configuredFileSizeLimit = getConfiguredFileSizeLimit(req, provider); + const configuredFileSizeLimit = getConfiguredFileSizeLimit(req, { + provider, + endpoint, + }); const validation = await validateAudio( audioBuffer, diff --git a/packages/api/src/files/encode/document.spec.ts b/packages/api/src/files/encode/document.spec.ts index ac1573f3e3..9091cedd9e 100644 --- a/packages/api/src/files/encode/document.spec.ts +++ b/packages/api/src/files/encode/document.spec.ts @@ -31,14 +31,16 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { beforeEach(() => { jest.clearAllMocks(); /** Default mock implementation for getConfiguredFileSizeLimit */ - mockedGetConfiguredFileSizeLimit.mockImplementation((req, provider) => { + mockedGetConfiguredFileSizeLimit.mockImplementation((req, params) => { if (!req.config?.fileConfig) { return undefined; } + const { provider, endpoint } = params; + const lookupKey = endpoint ?? provider; const fileConfig = req.config.fileConfig; const endpoints = fileConfig.endpoints; - if (endpoints?.[provider]) { - const limit = endpoints[provider].fileSizeLimit; + if (endpoints?.[lookupKey]) { + const limit = endpoints[lookupKey].fileSizeLimit; return limit !== undefined ? mbToBytes(limit) : undefined; } if (endpoints?.default) { diff --git a/packages/api/src/files/encode/document.ts b/packages/api/src/files/encode/document.ts index dbe829b09f..7d695b3ee8 100644 --- a/packages/api/src/files/encode/document.ts +++ b/packages/api/src/files/encode/document.ts @@ -14,16 +14,20 @@ import { validatePdf } from '~/files/validation'; * 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 params - Object containing provider, endpoint, and other options + * @param params.provider - The provider name + * @param params.endpoint - Optional endpoint name for file config lookup + * @param params.useResponsesApi - Whether to use responses API format * @param getStrategyFunctions - Function to get strategy functions * @returns Promise that resolves to documents and file metadata */ export async function encodeAndFormatDocuments( req: ServerRequest, files: IMongoFile[], - { provider, useResponsesApi }: { provider: Providers; useResponsesApi?: boolean }, + params: { provider: Providers; endpoint?: string; useResponsesApi?: boolean }, getStrategyFunctions: (source: string) => StrategyFunctions, ): Promise { + const { provider, endpoint, useResponsesApi } = params; if (!files?.length) { return { documents: [], files: [] }; } @@ -68,7 +72,10 @@ export async function encodeAndFormatDocuments( const pdfBuffer = Buffer.from(content, 'base64'); /** Extract configured file size limit from fileConfig for this endpoint */ - const configuredFileSizeLimit = getConfiguredFileSizeLimit(req, provider); + const configuredFileSizeLimit = getConfiguredFileSizeLimit(req, { + provider, + endpoint, + }); const validation = await validatePdf( pdfBuffer, diff --git a/packages/api/src/files/encode/utils.ts b/packages/api/src/files/encode/utils.ts index 4f77703836..9afa627cc7 100644 --- a/packages/api/src/files/encode/utils.ts +++ b/packages/api/src/files/encode/utils.ts @@ -1,24 +1,33 @@ import getStream from 'get-stream'; import { Providers } from '@librechat/agents'; -import { FileSources, mergeFileConfig } from 'librechat-data-provider'; +import { FileSources, mergeFileConfig, getEndpointFileConfig } from 'librechat-data-provider'; import type { IMongoFile } from '@librechat/data-schemas'; import type { ServerRequest, StrategyFunctions, ProcessedFile } from '~/types'; /** * Extracts the configured file size limit for a specific provider from fileConfig * @param req - The server request object containing config - * @param provider - The provider to get the limit for + * @param params - Object containing provider and optional endpoint + * @param params.provider - The provider to get the limit for + * @param params.endpoint - Optional endpoint name for lookup * @returns The configured file size limit in bytes, or undefined if not configured */ export const getConfiguredFileSizeLimit = ( req: ServerRequest, - provider: Providers, + params: { + provider: Providers; + endpoint?: string; + }, ): number | undefined => { if (!req.config?.fileConfig) { return undefined; } + const { provider, endpoint } = params; const fileConfig = mergeFileConfig(req.config.fileConfig); - const endpointConfig = fileConfig.endpoints[provider] ?? fileConfig.endpoints.default; + const endpointConfig = getEndpointFileConfig({ + fileConfig, + endpoint: endpoint ?? provider, + }); return endpointConfig?.fileSizeLimit; }; diff --git a/packages/api/src/files/encode/video.ts b/packages/api/src/files/encode/video.ts index 34e25c2296..d01d6e4da1 100644 --- a/packages/api/src/files/encode/video.ts +++ b/packages/api/src/files/encode/video.ts @@ -9,16 +9,19 @@ import { validateVideo } from '~/files/validation'; * 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 params - Object containing provider and optional endpoint + * @param params.provider - The provider to format for + * @param params.endpoint - Optional endpoint name for file config lookup * @param getStrategyFunctions - Function to get strategy functions * @returns Promise that resolves to videos and file metadata */ export async function encodeAndFormatVideos( req: ServerRequest, files: IMongoFile[], - provider: Providers, + params: { provider: Providers; endpoint?: string }, getStrategyFunctions: (source: string) => StrategyFunctions, ): Promise { + const { provider, endpoint } = params; if (!files?.length) { return { videos: [], files: [] }; } @@ -54,7 +57,10 @@ export async function encodeAndFormatVideos( const videoBuffer = Buffer.from(content, 'base64'); /** Extract configured file size limit from fileConfig for this endpoint */ - const configuredFileSizeLimit = getConfiguredFileSizeLimit(req, provider); + const configuredFileSizeLimit = getConfiguredFileSizeLimit(req, { + provider, + endpoint, + }); const validation = await validateVideo( videoBuffer, diff --git a/packages/api/src/files/filter.spec.ts b/packages/api/src/files/filter.spec.ts new file mode 100644 index 0000000000..efcd3b4f89 --- /dev/null +++ b/packages/api/src/files/filter.spec.ts @@ -0,0 +1,692 @@ +import { Types } from 'mongoose'; +import { Providers } from '@librechat/agents'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { ServerRequest } from '~/types'; +import { filterFilesByEndpointConfig } from './filter'; + +describe('filterFilesByEndpointConfig', () => { + /** Helper to create a mock file */ + const createMockFile = (filename: string): IMongoFile => + ({ + _id: new Types.ObjectId(), + user: new Types.ObjectId(), + file_id: new Types.ObjectId().toString(), + filename, + type: 'application/pdf', + bytes: 1024, + object: 'file', + usage: 0, + source: 'test', + filepath: `/test/${filename}`, + createdAt: new Date(), + updatedAt: new Date(), + }) as unknown as IMongoFile; + + describe('when files are disabled for endpoint', () => { + it('should return empty array when endpoint has disabled: true', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.OPENAI]: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test1.pdf'), createMockFile('test2.pdf')]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.OPENAI, + }); + + expect(result).toEqual([]); + }); + + it('should return empty array when default endpoint has disabled: true and provider not found', () => { + const req = { + config: { + fileConfig: { + endpoints: { + default: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.OPENAI, + }); + + expect(result).toEqual([]); + }); + + it('should return empty array for disabled Anthropic endpoint', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.ANTHROPIC]: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('doc.pdf')]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.ANTHROPIC, + }); + + expect(result).toEqual([]); + }); + + it('should return empty array for disabled Google endpoint', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.GOOGLE]: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('video.mp4')]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.GOOGLE, + }); + + expect(result).toEqual([]); + }); + }); + + describe('when files are enabled for endpoint', () => { + it('should return all files when endpoint has disabled: false', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.OPENAI]: { + disabled: false, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test1.pdf'), createMockFile('test2.pdf')]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.OPENAI, + }); + + expect(result).toEqual(files); + }); + + it('should return all files when endpoint config exists but disabled is not set', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.OPENAI]: { + fileSizeLimit: 10, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.OPENAI, + }); + + expect(result).toEqual(files); + }); + + it('should return all files when no fileConfig exists', () => { + const req = {} as ServerRequest; + + const files = [createMockFile('test1.pdf'), createMockFile('test2.pdf')]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.OPENAI, + }); + + expect(result).toEqual(files); + }); + + it('should return all files when endpoint not in config and no default', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.ANTHROPIC]: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + /** OpenAI not configured, should use base defaults which allow files */ + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.OPENAI, + }); + + expect(result).toEqual(files); + }); + }); + + describe('custom endpoint configuration', () => { + it('should use direct endpoint lookup when endpointType is custom', () => { + const req = { + config: { + fileConfig: { + endpoints: { + ollama: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: 'ollama', + endpointType: EModelEndpoint.custom, + }); + + expect(result).toEqual([]); + }); + + it('should use normalized endpoint lookup for custom endpoints', () => { + const req = { + config: { + fileConfig: { + endpoints: { + ollama: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + /** Test with non-normalized endpoint name (e.g., "Ollama" vs "ollama") */ + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: 'Ollama', + endpointType: EModelEndpoint.custom, + }); + + expect(result).toEqual([]); + }); + + it('should fallback to "custom" config when specific custom endpoint not found', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [EModelEndpoint.custom]: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: 'unknownCustomEndpoint', + endpointType: EModelEndpoint.custom, + }); + + expect(result).toEqual([]); + }); + + it('should return files when custom endpoint has disabled: false', () => { + const req = { + config: { + fileConfig: { + endpoints: { + ollama: { + disabled: false, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test1.pdf'), createMockFile('test2.pdf')]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: 'ollama', + endpointType: EModelEndpoint.custom, + }); + + expect(result).toEqual(files); + }); + + it('should use agents config as fallback for custom endpoints', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [EModelEndpoint.agents]: { + disabled: false, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + /** + * Lookup order for custom endpoint: explicitConfig -> custom -> agents -> default + * Should find and use agents config + */ + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: 'ollama', + endpointType: EModelEndpoint.custom, + }); + + expect(result).toEqual(files); + }); + + it('should fallback to default when agents is not configured for custom endpoint', () => { + const req = { + config: { + fileConfig: { + endpoints: { + /** Only default configured, no agents or custom */ + default: { + disabled: false, + fileLimit: 10, + fileSizeLimit: 20, + totalSizeLimit: 50, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + /** + * Lookup order: explicitConfig -> custom -> agents -> default + * Since none of first three exist, should fall back to default + */ + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: 'ollama', + endpointType: EModelEndpoint.custom, + }); + + expect(result).toEqual(files); + }); + + it('should use default when agents is not configured for custom endpoint', () => { + const req = { + config: { + fileConfig: { + endpoints: { + /** NO agents config - should skip to default */ + default: { + disabled: false, + fileLimit: 15, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + /** + * Lookup order: explicitConfig -> custom -> agents -> default + * Since agents is not configured, should fall back to default + */ + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: 'ollama', + endpointType: EModelEndpoint.custom, + }); + + expect(result).toEqual(files); + }); + + it('should block files when agents is disabled for unconfigured custom endpoint', () => { + const req = { + config: { + fileConfig: { + endpoints: { + agents: { + disabled: true, + }, + default: { + disabled: false, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + /** + * Lookup order: explicitConfig -> custom -> agents -> default + * Should use agents config which is disabled: true + */ + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: 'ollama', + endpointType: EModelEndpoint.custom, + }); + + expect(result).toEqual([]); + }); + + it('should prioritize specific custom endpoint over generic custom config', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [EModelEndpoint.custom]: { + disabled: false, + }, + ollama: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + /** Should use ollama config (disabled: true), not custom config (disabled: false) */ + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: 'ollama', + endpointType: EModelEndpoint.custom, + }); + + expect(result).toEqual([]); + }); + + it('should handle case-insensitive custom endpoint names', () => { + const req = { + config: { + fileConfig: { + endpoints: { + ollama: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + /** Test various case combinations */ + const result1 = filterFilesByEndpointConfig(req, { + files, + endpoint: 'OLLAMA', + endpointType: EModelEndpoint.custom, + }); + + const result2 = filterFilesByEndpointConfig(req, { + files, + endpoint: 'OlLaMa', + endpointType: EModelEndpoint.custom, + }); + + expect(result1).toEqual([]); + expect(result2).toEqual([]); + }); + + it('should work without endpointType for standard endpoints but require it for custom', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.OPENAI]: { + disabled: true, + }, + ollama: { + disabled: true, + }, + default: { + disabled: false, + fileLimit: 10, + fileSizeLimit: 20, + totalSizeLimit: 50, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + /** Standard endpoint works without endpointType */ + const openaiResult = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.OPENAI, + }); + expect(openaiResult).toEqual([]); + + /** Custom endpoint with endpointType uses specific config */ + const customWithTypeResult = filterFilesByEndpointConfig(req, { + files, + endpoint: 'ollama', + endpointType: EModelEndpoint.custom, + }); + expect(customWithTypeResult).toEqual([]); + + /** Custom endpoint without endpointType tries direct lookup, falls back to default */ + const customWithoutTypeResult = filterFilesByEndpointConfig(req, { + files, + endpoint: 'unknownCustom', + }); + expect(customWithoutTypeResult).toEqual(files); + }); + }); + + describe('edge cases', () => { + it('should return empty array when files input is undefined', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.OPENAI]: { + disabled: false, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const result = filterFilesByEndpointConfig(req, { + files: undefined, + endpoint: Providers.OPENAI, + }); + + expect(result).toEqual([]); + }); + + it('should return empty array when files input is empty array', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.OPENAI]: { + disabled: false, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const result = filterFilesByEndpointConfig(req, { + files: [], + endpoint: Providers.OPENAI, + }); + + expect(result).toEqual([]); + }); + + it('should handle custom provider strings', () => { + const req = { + config: { + fileConfig: { + endpoints: { + customProvider: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('test.pdf')]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: 'customProvider', + endpointType: EModelEndpoint.custom, + }); + + expect(result).toEqual([]); + }); + }); + + describe('bypass scenarios from bug report', () => { + it('should block files when switching from enabled to disabled endpoint', () => { + /** + * Scenario: User attaches files under Anthropic (enabled), + * then switches to OpenAI (disabled) + */ + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.OPENAI]: { + disabled: true, + }, + [Providers.ANTHROPIC]: { + disabled: false, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [createMockFile('document.pdf')]; + + /** Files were attached under Anthropic */ + const anthropicResult = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.ANTHROPIC, + }); + expect(anthropicResult).toEqual(files); + + /** User switches to OpenAI - files should be filtered out */ + const openaiResult = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.OPENAI, + }); + expect(openaiResult).toEqual([]); + }); + + it('should prevent drag-and-drop bypass by filtering at agent initialization', () => { + /** + * Scenario: User drags file into disabled endpoint + * Server processes it but filter should remove it + */ + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.OPENAI]: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const draggedFiles = [createMockFile('dragged.pdf')]; + + const result = filterFilesByEndpointConfig(req, { + files: draggedFiles, + endpoint: Providers.OPENAI, + }); + + expect(result).toEqual([]); + }); + + it('should filter multiple files when endpoint disabled', () => { + const req = { + config: { + fileConfig: { + endpoints: { + [Providers.OPENAI]: { + disabled: true, + }, + }, + }, + }, + } as unknown as ServerRequest; + + const files = [ + createMockFile('file1.pdf'), + createMockFile('file2.pdf'), + createMockFile('file3.pdf'), + ]; + + const result = filterFilesByEndpointConfig(req, { + files, + endpoint: Providers.OPENAI, + }); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/api/src/files/filter.ts b/packages/api/src/files/filter.ts new file mode 100644 index 0000000000..eaf2a6fe90 --- /dev/null +++ b/packages/api/src/files/filter.ts @@ -0,0 +1,44 @@ +import { getEndpointFileConfig, mergeFileConfig } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { ServerRequest } from '~/types'; + +/** + * Filters out files if the endpoint/provider has file uploads disabled + * @param req - The server request object containing config + * @param params - Object containing files, endpoint, and endpointType + * @param params.files - Array of processed file documents from MongoDB + * @param params.endpoint - The endpoint name to check configuration for + * @param params.endpointType - The endpoint type to check configuration for + * @returns Filtered array of files (empty if disabled) + */ +export function filterFilesByEndpointConfig( + req: ServerRequest, + params: { + files: IMongoFile[] | undefined; + endpoint?: string | null; + endpointType?: string | null; + }, +): IMongoFile[] { + const { files, endpoint, endpointType } = params; + + if (!files || files.length === 0) { + return []; + } + + const fileConfig = mergeFileConfig(req.config?.fileConfig); + const endpointFileConfig = getEndpointFileConfig({ + fileConfig, + endpoint, + endpointType, + }); + + /** + * If endpoint has files explicitly disabled, filter out all files + * Only filter if disabled is explicitly set to true + */ + if (endpointFileConfig?.disabled === true) { + return []; + } + + return files; +} diff --git a/packages/api/src/files/index.ts b/packages/api/src/files/index.ts index 9111b8d5e3..8397878355 100644 --- a/packages/api/src/files/index.ts +++ b/packages/api/src/files/index.ts @@ -1,6 +1,7 @@ export * from './audio'; export * from './context'; export * from './encode'; +export * from './filter'; export * from './mistral/crud'; export * from './ocr'; export * from './parse'; diff --git a/packages/api/src/utils/common.ts b/packages/api/src/utils/common.ts index f82e8dd218..a5860b0a69 100644 --- a/packages/api/src/utils/common.ts +++ b/packages/api/src/utils/common.ts @@ -1,4 +1,3 @@ -import { Providers } from '@librechat/agents'; import { AuthType } from 'librechat-data-provider'; /** @@ -49,11 +48,3 @@ export function optionalChainWithEmptyCheck( } return values[values.length - 1]; } - -/** - * Normalize the endpoint name to system-expected value. - * @param name - */ -export function normalizeEndpointName(name = ''): string { - return name.toLowerCase() === Providers.OLLAMA ? Providers.OLLAMA : name; -} diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 17e2365a75..082f7dc4a0 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1,12 +1,12 @@ import { z } from 'zod'; import type { ZodError } from 'zod'; -import type { TModelsConfig } from './types'; +import type { TEndpointsConfig, TModelsConfig, TConfig } from './types'; import { EModelEndpoint, eModelEndpointSchema } from './schemas'; import { specsConfigSchema, TSpecsConfig } from './models'; import { fileConfigSchema } from './file-config'; +import { apiBaseUrl } from './api-endpoints'; import { FileSources } from './types/files'; import { MCPServersSchema } from './mcp'; -import { apiBaseUrl } from './api-endpoints'; export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord', 'saml']; @@ -1737,3 +1737,24 @@ export const specialVariables = { }; export type TSpecialVarLabel = `com_ui_special_var_${keyof typeof specialVariables}`; + +/** + * Retrieves a specific field from the endpoints configuration for a given endpoint key. + * Does not infer or default any endpoint type when absent. + */ +export function getEndpointField< + K extends TConfig[keyof TConfig] extends never ? never : keyof TConfig, +>( + endpointsConfig: TEndpointsConfig | undefined | null, + endpoint: EModelEndpoint | string | null | undefined, + property: K, +): TConfig[K] | undefined { + if (!endpointsConfig || endpoint === null || endpoint === undefined) { + return undefined; + } + const config = endpointsConfig[endpoint]; + if (!config) { + return undefined; + } + return config[property]; +} diff --git a/packages/data-provider/src/file-config.spec.ts b/packages/data-provider/src/file-config.spec.ts new file mode 100644 index 0000000000..4b9c866061 --- /dev/null +++ b/packages/data-provider/src/file-config.spec.ts @@ -0,0 +1,1097 @@ +import type { FileConfig } from './types/files'; +import { + fileConfig as baseFileConfig, + getEndpointFileConfig, + mergeFileConfig, +} from './file-config'; +import { EModelEndpoint } from './schemas'; + +describe('getEndpointFileConfig', () => { + describe('custom endpoint lookup', () => { + it('should find custom endpoint by direct lookup', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + ollama: { + disabled: true, + fileLimit: 5, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'ollama', + endpointType: EModelEndpoint.custom, + }); + + expect(result.disabled).toBe(true); + expect(result.fileLimit).toBe(5); + }); + + it('should find custom endpoint by normalized lookup', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + ollama: { + disabled: true, + fileLimit: 7, + }, + }, + }; + + /** Test with non-normalized name */ + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'Ollama', + endpointType: EModelEndpoint.custom, + }); + + expect(result.disabled).toBe(true); + expect(result.fileLimit).toBe(7); + }); + + it('should fallback to generic custom config when specific endpoint not found', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.custom]: { + disabled: false, + fileLimit: 3, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'unknownCustomEndpoint', + endpointType: EModelEndpoint.custom, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(3); + }); + + it('should fallback to agents config when custom and specific endpoint not found', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.agents]: { + disabled: false, + fileLimit: 8, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'unknownCustomEndpoint', + endpointType: EModelEndpoint.custom, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(8); + }); + + it('should use base agents config when only default is dynamically configured', () => { + const dynamicConfig = { + endpoints: { + default: { + disabled: false, + fileLimit: 12, + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + endpoint: 'unknownCustomEndpoint', + endpointType: EModelEndpoint.custom, + }); + + /** + * Should use base agents config (fileLimit: 10) since it exists in baseFileConfig + * and custom endpoints fall back to agents + */ + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(10); /** From baseFileConfig.endpoints.agents */ + }); + + it('should prioritize specific custom endpoint over generic custom config', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.custom]: { + disabled: false, + fileLimit: 20, + }, + ollama: { + disabled: true, + fileLimit: 3, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'ollama', + endpointType: EModelEndpoint.custom, + }); + + /** Should use ollama config, not generic custom */ + expect(result.disabled).toBe(true); + expect(result.fileLimit).toBe(3); + }); + + it('should skip standard endpoint keys in normalized lookup', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + default: { + disabled: false, + fileLimit: 99, + }, + }, + }; + + /** "default" should not match via normalized lookup for custom endpoints */ + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'default', + endpointType: EModelEndpoint.custom, + }); + + /** Should not use direct lookup, should fall back to default */ + expect(result.fileLimit).toBe(99); + }); + + it('should handle complete fallback chain: specific -> custom -> agents -> default', () => { + const customConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + myOllama: { + disabled: true, + fileLimit: 1, + }, + [EModelEndpoint.custom]: { + disabled: false, + fileLimit: 2, + }, + [EModelEndpoint.agents]: { + disabled: false, + fileLimit: 3, + }, + default: { + disabled: false, + fileLimit: 4, + }, + }, + }; + + /** 1. Should find specific config */ + const specific = getEndpointFileConfig({ + fileConfig: customConfig, + endpoint: 'myOllama', + endpointType: EModelEndpoint.custom, + }); + expect(specific.fileLimit).toBe(1); + + /** 2. Should fallback to custom when specific not found */ + const customOnlyConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.custom]: { + disabled: false, + fileLimit: 2, + }, + [EModelEndpoint.agents]: { + disabled: false, + fileLimit: 3, + }, + default: { + disabled: false, + fileLimit: 4, + }, + }, + }; + const customFallback = getEndpointFileConfig({ + fileConfig: customOnlyConfig, + endpoint: 'unknownCustom', + endpointType: EModelEndpoint.custom, + }); + expect(customFallback.fileLimit).toBe(2); + + /** 3. Should fallback to agents */ + const agentsFallback = getEndpointFileConfig({ + fileConfig: { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.agents]: { + disabled: false, + fileLimit: 3, + }, + default: { + disabled: false, + fileLimit: 4, + }, + }, + }, + endpoint: 'unknownCustom', + endpointType: EModelEndpoint.custom, + }); + expect(agentsFallback.fileLimit).toBe(3); + + /** + * 4. Should use agents even if disabled (caller decides based on disabled flag) + * getEndpointFileConfig returns the config, doesn't filter based on disabled + */ + const agentsDisabledConfig = mergeFileConfig({ + endpoints: { + [EModelEndpoint.agents]: { + disabled: true, + }, + default: { + disabled: false, + fileLimit: 4, + }, + }, + }); + const agentsDisabled = getEndpointFileConfig({ + fileConfig: agentsDisabledConfig, + endpoint: 'unknownCustom', + endpointType: EModelEndpoint.custom, + }); + /** Should return agents config (disabled: true), not skip to default */ + expect(agentsDisabled.disabled).toBe(true); + expect(agentsDisabled.fileLimit).toBe(0); /** disabled: true sets fileLimit to 0 */ + }); + }); + + describe('standard endpoint lookup', () => { + it('should find endpoint by endpointType', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.openAI]: { + disabled: true, + fileLimit: 15, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'someOtherName', + endpointType: EModelEndpoint.openAI, + }); + + expect(result.disabled).toBe(true); + expect(result.fileLimit).toBe(15); + }); + + it('should find endpoint by direct endpoint name', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.anthropic]: { + disabled: false, + fileLimit: 25, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.anthropic, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(25); + }); + + it('should find endpoint by normalized name', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + ollama: { + disabled: true, + fileLimit: 6, + }, + }, + }; + + /** Test normalization (Ollama -> ollama) */ + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'Ollama', + }); + + expect(result.disabled).toBe(true); + expect(result.fileLimit).toBe(6); + }); + + it('should use agents fallback for explicitly agents endpoint', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.agents]: { + disabled: false, + fileLimit: 11, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.agents, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(11); + }); + + it('should use agents fallback for unconfigured non-standard endpoint', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.agents]: { + disabled: false, + fileLimit: 10, + }, + default: { + disabled: false, + fileLimit: 100, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'unconfiguredEndpoint', + }); + + /** + * With new logic, unconfigured endpoints are treated as custom + * and fall back through: specific -> custom -> agents -> default + * So this should use agents (fileLimit: 10), not default + */ + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(10); + }); + + it('should prioritize endpointType over endpoint name', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.openAI]: { + disabled: true, + fileLimit: 5, + }, + [EModelEndpoint.anthropic]: { + disabled: false, + fileLimit: 10, + }, + }, + }; + + /** endpointType should take priority */ + const result = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.openAI, + endpointType: EModelEndpoint.anthropic, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(10); + }); + }); + + describe('edge cases', () => { + it('should return default when fileConfig is null', () => { + const result = getEndpointFileConfig({ + fileConfig: null, + endpoint: EModelEndpoint.openAI, + }); + + expect(result).toBeDefined(); + expect(result.disabled).toBe(false); + }); + + it('should return default when fileConfig is undefined', () => { + const result = getEndpointFileConfig({ + fileConfig: undefined, + endpoint: EModelEndpoint.openAI, + }); + + expect(result).toBeDefined(); + expect(result.disabled).toBe(false); + }); + + it('should handle empty endpoint gracefully', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + default: { + disabled: false, + fileLimit: 50, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: '', + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(50); + }); + + it('should handle null endpoint gracefully', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + default: { + disabled: false, + fileLimit: 50, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: null, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(50); + }); + + it('should handle undefined endpoint gracefully', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + default: { + disabled: false, + fileLimit: 50, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: undefined, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(50); + }); + + it('should not mutate the input fileConfig', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.openAI]: { + disabled: false, + fileLimit: 10, + }, + }, + }; + + const originalDisabled = fileConfig.endpoints[EModelEndpoint.openAI]!.disabled; + + getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.openAI, + }); + + /** Config should not be mutated */ + expect(fileConfig.endpoints[EModelEndpoint.openAI]!.disabled).toBe(originalDisabled); + }); + }); + + describe('assistants endpoint handling', () => { + it('should find assistants endpoint config', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.assistants]: { + disabled: false, + fileLimit: 20, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.assistants, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(20); + }); + + it('should find azureAssistants endpoint config', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.azureAssistants]: { + disabled: true, + fileLimit: 15, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.azureAssistants, + }); + + expect(result.disabled).toBe(true); + expect(result.fileLimit).toBe(15); + }); + + it('should not fallback to agents for assistants endpoints', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.agents]: { + disabled: true, + fileLimit: 5, + }, + default: { + disabled: false, + fileLimit: 10, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'unknownAssistants', + endpointType: EModelEndpoint.assistants, + }); + + /** Should use default, not agents */ + expect(result.fileLimit).toBe(10); + }); + }); + + describe('agents endpoint handling', () => { + it('should find agents endpoint config', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.agents]: { + disabled: false, + fileLimit: 9, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.agents, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(9); + }); + }); + + describe('mergeFileConfig integration', () => { + it('should work with mergeFileConfig output for disabled endpoint', () => { + const dynamicConfig = { + endpoints: { + [EModelEndpoint.openAI]: { + disabled: true, + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + endpoint: EModelEndpoint.openAI, + }); + + expect(result.disabled).toBe(true); + /** When disabled: true, merge sets these to 0 */ + expect(result.fileLimit).toBe(0); + expect(result.fileSizeLimit).toBe(0); + expect(result.totalSizeLimit).toBe(0); + expect(result.supportedMimeTypes).toEqual([]); + }); + + it('should work with mergeFileConfig output for enabled endpoint', () => { + const dynamicConfig = { + endpoints: { + [EModelEndpoint.anthropic]: { + disabled: false, + fileLimit: 5, + fileSizeLimit: 10, + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + endpoint: EModelEndpoint.anthropic, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(5); + /** Should convert MB to bytes */ + expect(result.fileSizeLimit).toBe(10 * 1024 * 1024); + }); + + it('should preserve disabled: false in merged config', () => { + const dynamicConfig = { + endpoints: { + [EModelEndpoint.anthropic]: { + disabled: false, + fileLimit: 8, + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + endpoint: EModelEndpoint.anthropic, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(8); + }); + + it('should not mutate base fileConfig during merge', () => { + const originalBaseAgentsConfig = { ...baseFileConfig.endpoints.agents }; + + const dynamicConfig = { + endpoints: { + [EModelEndpoint.agents]: { + disabled: true, + fileLimit: 1, + }, + }, + }; + + mergeFileConfig(dynamicConfig); + + /** Base config should not be mutated */ + expect(baseFileConfig.endpoints.agents).toEqual(originalBaseAgentsConfig); + }); + }); + + describe('lookup priority verification', () => { + it('should check endpointType before endpoint for standard endpoints', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.openAI]: { + disabled: true, + fileLimit: 1, + }, + wrongEndpoint: { + disabled: false, + fileLimit: 99, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'wrongEndpoint', + endpointType: EModelEndpoint.openAI, + }); + + /** Should use endpointType config, not endpoint */ + expect(result.fileLimit).toBe(1); + }); + + it('should check endpoint when endpointType not found', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + myCustomEndpoint: { + disabled: true, + fileLimit: 7, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: 'myCustomEndpoint', + endpointType: 'notFound', + }); + + expect(result.fileLimit).toBe(7); + }); + }); + + describe('disabled handling', () => { + it('should properly handle disabled: true', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.openAI]: { + disabled: true, + fileLimit: 0, + fileSizeLimit: 0, + totalSizeLimit: 0, + supportedMimeTypes: [], + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.openAI, + }); + + expect(result.disabled).toBe(true); + expect(result.fileLimit).toBe(0); + expect(result.fileSizeLimit).toBe(0); + expect(result.totalSizeLimit).toBe(0); + expect(result.supportedMimeTypes).toEqual([]); + }); + + it('should properly handle disabled: false', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.anthropic]: { + disabled: false, + fileLimit: 10, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.anthropic, + }); + + expect(result.disabled).toBe(false); + expect(result.fileLimit).toBe(10); + }); + + it('should treat undefined disabled as enabled', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.google]: { + fileLimit: 10, + }, + }, + }; + + const result = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.google, + }); + + /** disabled should not be explicitly true */ + expect(result.disabled).not.toBe(true); + }); + }); + + describe('partial config merging', () => { + it('should merge partial endpoint config with default config', () => { + const dynamicConfig = { + endpoints: { + google: { + fileSizeLimit: 500, + /** Note: supportedMimeTypes not configured */ + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + endpoint: EModelEndpoint.google, + }); + + /** Should have the configured fileSizeLimit */ + expect(result.fileSizeLimit).toBe(500 * 1024 * 1024); + /** Should have supportedMimeTypes from default config */ + expect(result.supportedMimeTypes).toBeDefined(); + expect(Array.isArray(result.supportedMimeTypes)).toBe(true); + expect(result.supportedMimeTypes!.length).toBeGreaterThan(0); + /** Should have other fields from default */ + expect(result.fileLimit).toBeDefined(); + expect(result.totalSizeLimit).toBeDefined(); + }); + + it('should not override explicitly set fields with default', () => { + const dynamicConfig = { + endpoints: { + anthropic: { + disabled: true, + fileLimit: 3, + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + endpoint: EModelEndpoint.anthropic, + }); + + /** Should keep explicitly configured values */ + expect(result.disabled).toBe(true); + expect(result.fileLimit).toBe(0); /** disabled: true sets to 0 in merge */ + /** But still get supportedMimeTypes from... wait, disabled: true clears this */ + expect(result.supportedMimeTypes).toEqual([]); + }); + + it('should handle endpoint with only fileSizeLimit configured', () => { + const dynamicConfig = { + endpoints: { + openAI: { + fileSizeLimit: 100, + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + endpoint: EModelEndpoint.openAI, + }); + + expect(result.fileSizeLimit).toBe(100 * 1024 * 1024); + /** Should get these from default */ + expect(result.supportedMimeTypes).toBeDefined(); + expect(result.fileLimit).toBeDefined(); + expect(result.disabled).not.toBe(true); + }); + + it('should merge supportedMimeTypes from default when only fileSizeLimit is configured', () => { + /** This tests the exact scenario from the issue */ + const dynamicConfig = { + endpoints: { + google: { + fileSizeLimit: 1000000024, + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + endpoint: EModelEndpoint.google, + }); + + /** Should have the massive fileSizeLimit configured */ + expect(result.fileSizeLimit).toBe(1000000024 * 1024 * 1024); + /** CRITICAL: Should have supportedMimeTypes from default, not undefined or [] */ + expect(result.supportedMimeTypes).toBeDefined(); + expect(Array.isArray(result.supportedMimeTypes)).toBe(true); + expect(result.supportedMimeTypes!.length).toBeGreaterThan(0); + /** Should have other default fields */ + expect(result.fileLimit).toBe(10); + expect(result.disabled).toBe(false); + }); + }); + + describe('real-world scenarios', () => { + it('should handle multi-provider custom endpoint configuration', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + ollama: { + disabled: false, + fileLimit: 5, + }, + lmstudio: { + disabled: true, + fileLimit: 3, + }, + [EModelEndpoint.custom]: { + disabled: false, + fileLimit: 10, + }, + }, + }; + + const ollamaResult = getEndpointFileConfig({ + fileConfig, + endpoint: 'ollama', + endpointType: EModelEndpoint.custom, + }); + expect(ollamaResult.fileLimit).toBe(5); + + const lmstudioResult = getEndpointFileConfig({ + fileConfig, + endpoint: 'lmstudio', + endpointType: EModelEndpoint.custom, + }); + expect(lmstudioResult.disabled).toBe(true); + expect(lmstudioResult.fileLimit).toBe(3); + + const unknownResult = getEndpointFileConfig({ + fileConfig, + endpoint: 'unknownProvider', + endpointType: EModelEndpoint.custom, + }); + expect(unknownResult.fileLimit).toBe(10); + }); + + it('should handle switching between endpoints correctly', () => { + const fileConfig: FileConfig = { + ...baseFileConfig, + endpoints: { + ...baseFileConfig.endpoints, + [EModelEndpoint.openAI]: { + disabled: true, + }, + [EModelEndpoint.anthropic]: { + disabled: false, + fileLimit: 15, + }, + }, + }; + + const openaiResult = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.openAI, + }); + expect(openaiResult.disabled).toBe(true); + + const anthropicResult = getEndpointFileConfig({ + fileConfig, + endpoint: EModelEndpoint.anthropic, + }); + expect(anthropicResult.disabled).toBe(false); + expect(anthropicResult.fileLimit).toBe(15); + }); + }); + + describe('user-configured default behavior', () => { + it('should use user-configured default as effective default when endpoint not found', () => { + const dynamicConfig = { + endpoints: { + default: { + fileLimit: 7, + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + endpoint: EModelEndpoint.google, + }); + + expect(result.fileLimit).toBe(7); + expect(result.disabled).toBe(false); + expect(result.supportedMimeTypes).toBeDefined(); + expect(Array.isArray(result.supportedMimeTypes)).toBe(true); + expect(result.supportedMimeTypes!.length).toBeGreaterThan(0); + }); + + it('should merge endpoint config against user default (not base default)', () => { + const dynamicConfig = { + endpoints: { + default: { + fileLimit: 7, + }, + google: { + fileSizeLimit: 123, + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + endpoint: EModelEndpoint.google, + }); + + /** fileLimit should come from user default */ + expect(result.fileLimit).toBe(7); + /** fileSizeLimit should come from endpoint (converted to bytes) */ + expect(result.fileSizeLimit).toBe(123 * 1024 * 1024); + }); + + it('should respect user-configured default supportedMimeTypes override', () => { + const dynamicConfig = { + endpoints: { + default: { + supportedMimeTypes: ['^text\\/plain$'], + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + }); + + /** Only text/plain should be allowed */ + expect(result.supportedMimeTypes).toBeDefined(); + expect(result.supportedMimeTypes!.length).toBe(1); + const [onlyRegex] = result.supportedMimeTypes as RegExp[]; + expect(onlyRegex.test('text/plain')).toBe(true); + expect(onlyRegex.test('image/png')).toBe(false); + }); + + it('should propagate disabled from user default across fallbacks', () => { + const dynamicConfig = { + endpoints: { + default: { + disabled: true, + }, + }, + }; + + const merged = mergeFileConfig(dynamicConfig); + const result = getEndpointFileConfig({ + fileConfig: merged, + endpoint: EModelEndpoint.google, + }); + + expect(result.disabled).toBe(true); + expect(result.fileLimit).toBe(0); + expect(result.fileSizeLimit).toBe(0); + expect(result.totalSizeLimit).toBe(0); + expect(result.supportedMimeTypes).toEqual([]); + }); + }); +}); diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index 327ce52490..1ef41f0af5 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; -import { EModelEndpoint } from './schemas'; import type { EndpointFileConfig, FileConfig } from './types/files'; +import { EModelEndpoint, isAgentsEndpoint, isDocumentSupportedProvider } from './schemas'; +import { normalizeEndpointName } from './utils'; export const supportsFiles = { [EModelEndpoint.openAI]: true, @@ -331,9 +332,146 @@ export const convertStringsToRegex = (patterns: string[]): RegExp[] => return acc; }, []); +/** + * Gets the appropriate endpoint file configuration with standardized lookup logic. + * + * @param params - Object containing fileConfig, endpoint, and optional conversationEndpoint + * @param params.fileConfig - The merged file configuration + * @param params.endpoint - The endpoint name to look up + * @param params.conversationEndpoint - Optional conversation endpoint for additional context + * @returns The endpoint file configuration or undefined + */ +/** + * Merges an endpoint config with the default config to ensure all fields are populated. + * For document-supported providers, uses the comprehensive MIME type list (includes videos/audio). + */ +function mergeWithDefault( + endpointConfig: EndpointFileConfig, + defaultConfig: EndpointFileConfig, + endpoint?: string | null, +): EndpointFileConfig { + /** Use comprehensive MIME types for document-supported providers */ + const defaultMimeTypes = isDocumentSupportedProvider(endpoint) + ? supportedMimeTypes + : defaultConfig.supportedMimeTypes; + + return { + disabled: endpointConfig.disabled ?? defaultConfig.disabled, + fileLimit: endpointConfig.fileLimit ?? defaultConfig.fileLimit, + fileSizeLimit: endpointConfig.fileSizeLimit ?? defaultConfig.fileSizeLimit, + totalSizeLimit: endpointConfig.totalSizeLimit ?? defaultConfig.totalSizeLimit, + supportedMimeTypes: endpointConfig.supportedMimeTypes ?? defaultMimeTypes, + }; +} + +export function getEndpointFileConfig(params: { + fileConfig?: FileConfig | null; + endpoint?: string | null; + endpointType?: string | null; +}): EndpointFileConfig { + const { fileConfig: mergedFileConfig, endpoint, endpointType } = params; + + if (!mergedFileConfig?.endpoints) { + return fileConfig.endpoints.default; + } + + /** Compute an effective default by merging user-configured default over the base default */ + const baseDefaultConfig = fileConfig.endpoints.default; + const userDefaultConfig = mergedFileConfig.endpoints.default; + const defaultConfig = userDefaultConfig + ? mergeWithDefault(userDefaultConfig, baseDefaultConfig, 'default') + : baseDefaultConfig; + + const normalizedEndpoint = normalizeEndpointName(endpoint ?? ''); + const standardEndpoints = new Set([ + 'default', + EModelEndpoint.agents, + EModelEndpoint.assistants, + EModelEndpoint.azureAssistants, + EModelEndpoint.openAI, + EModelEndpoint.azureOpenAI, + EModelEndpoint.anthropic, + EModelEndpoint.google, + EModelEndpoint.bedrock, + ]); + + const normalizedEndpointType = normalizeEndpointName(endpointType ?? ''); + const isCustomEndpoint = + endpointType === EModelEndpoint.custom || + (!standardEndpoints.has(normalizedEndpointType) && + normalizedEndpoint && + !standardEndpoints.has(normalizedEndpoint)); + + if (isCustomEndpoint) { + /** 1. Check direct endpoint lookup (could be normalized or not) */ + if (endpoint && mergedFileConfig.endpoints[endpoint]) { + return mergeWithDefault(mergedFileConfig.endpoints[endpoint], defaultConfig, endpoint); + } + /** 2. Check normalized endpoint lookup (skip standard endpoint keys) */ + for (const key in mergedFileConfig.endpoints) { + if (!standardEndpoints.has(key) && normalizeEndpointName(key) === normalizedEndpoint) { + return mergeWithDefault(mergedFileConfig.endpoints[key], defaultConfig, key); + } + } + /** 3. Fallback to generic 'custom' config if any */ + if (mergedFileConfig.endpoints[EModelEndpoint.custom]) { + return mergeWithDefault( + mergedFileConfig.endpoints[EModelEndpoint.custom], + defaultConfig, + endpoint, + ); + } + /** 4. Fallback to 'agents' (all custom endpoints are non-assistants) */ + if (mergedFileConfig.endpoints[EModelEndpoint.agents]) { + return mergeWithDefault( + mergedFileConfig.endpoints[EModelEndpoint.agents], + defaultConfig, + endpoint, + ); + } + /** 5. Fallback to default */ + return defaultConfig; + } + + /** Check endpointType first (most reliable for standard endpoints) */ + if (endpointType && mergedFileConfig.endpoints[endpointType]) { + return mergeWithDefault(mergedFileConfig.endpoints[endpointType], defaultConfig, endpointType); + } + + /** Check direct endpoint lookup */ + if (endpoint && mergedFileConfig.endpoints[endpoint]) { + return mergeWithDefault(mergedFileConfig.endpoints[endpoint], defaultConfig, endpoint); + } + + /** Check normalized endpoint */ + if (normalizedEndpoint && mergedFileConfig.endpoints[normalizedEndpoint]) { + return mergeWithDefault( + mergedFileConfig.endpoints[normalizedEndpoint], + defaultConfig, + normalizedEndpoint, + ); + } + + /** Fallback to agents if endpoint is explicitly agents */ + const isAgents = isAgentsEndpoint(normalizedEndpointType || normalizedEndpoint); + if (isAgents && mergedFileConfig.endpoints[EModelEndpoint.agents]) { + return mergeWithDefault( + mergedFileConfig.endpoints[EModelEndpoint.agents], + defaultConfig, + EModelEndpoint.agents, + ); + } + + /** Return default config */ + return defaultConfig; +} + export function mergeFileConfig(dynamic: z.infer | undefined): FileConfig { const mergedConfig: FileConfig = { ...fileConfig, + endpoints: { + ...fileConfig.endpoints, + }, ocr: { ...fileConfig.ocr, supportedMimeTypes: fileConfig.ocr?.supportedMimeTypes || [], @@ -398,8 +536,11 @@ export function mergeFileConfig(dynamic: z.infer | unde for (const key in dynamic.endpoints) { const dynamicEndpoint = (dynamic.endpoints as Record)[key]; + /** Deep copy the base endpoint config if it exists to prevent mutation */ if (!mergedConfig.endpoints[key]) { mergedConfig.endpoints[key] = {}; + } else { + mergedConfig.endpoints[key] = { ...mergedConfig.endpoints[key] }; } const mergedEndpoint = mergedConfig.endpoints[key]; @@ -428,6 +569,10 @@ export function mergeFileConfig(dynamic: z.infer | unde } }); + if (dynamicEndpoint.disabled !== undefined) { + mergedEndpoint.disabled = dynamicEndpoint.disabled; + } + if (dynamicEndpoint.supportedMimeTypes) { mergedEndpoint.supportedMimeTypes = convertStringsToRegex( dynamicEndpoint.supportedMimeTypes as unknown as string[], diff --git a/packages/data-provider/src/utils.ts b/packages/data-provider/src/utils.ts index a2798aac9f..57abbf0495 100644 --- a/packages/data-provider/src/utils.ts +++ b/packages/data-provider/src/utils.ts @@ -52,3 +52,11 @@ export function extractEnvVariable(value: string) { return result; } + +/** + * Normalize the endpoint name to system-expected value. + * @param name + */ +export function normalizeEndpointName(name = ''): string { + return name.toLowerCase() === 'ollama' ? 'ollama' : name; +} diff --git a/packages/data-schemas/src/app/specs.ts b/packages/data-schemas/src/app/specs.ts index 4fa82d37a4..77715fb3a2 100644 --- a/packages/data-schemas/src/app/specs.ts +++ b/packages/data-schemas/src/app/specs.ts @@ -1,15 +1,7 @@ import logger from '~/config/winston'; -import { EModelEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, normalizeEndpointName } 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.