diff --git a/client/src/Providers/DragDropContext.tsx b/client/src/Providers/DragDropContext.tsx index e5a2177f2d..b519c0171f 100644 --- a/client/src/Providers/DragDropContext.tsx +++ b/client/src/Providers/DragDropContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useMemo } from 'react'; -import { getEndpointField, isAgentsEndpoint } from 'librechat-data-provider'; +import { isAgentsEndpoint, resolveEndpointType } from 'librechat-data-provider'; import type { EModelEndpoint } from 'librechat-data-provider'; import { useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider'; import { useAgentsMapContext } from './AgentsMapContext'; @@ -9,7 +9,7 @@ interface DragDropContextValue { conversationId: string | null | undefined; agentId: string | null | undefined; endpoint: string | null | undefined; - endpointType?: EModelEndpoint | undefined; + endpointType?: EModelEndpoint | string | undefined; useResponsesApi?: boolean; } @@ -20,13 +20,6 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) { const { data: endpointsConfig } = useGetEndpointsQuery(); const agentsMap = useAgentsMapContext(); - const endpointType = useMemo(() => { - return ( - getEndpointField(endpointsConfig, conversation?.endpoint, 'type') || - (conversation?.endpoint as EModelEndpoint | undefined) - ); - }, [conversation?.endpoint, endpointsConfig]); - const needsAgentFetch = useMemo(() => { const isAgents = isAgentsEndpoint(conversation?.endpoint); if (!isAgents || !conversation?.agent_id) { @@ -40,6 +33,20 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) { enabled: needsAgentFetch, }); + const agentProvider = useMemo(() => { + const isAgents = isAgentsEndpoint(conversation?.endpoint); + if (!isAgents || !conversation?.agent_id) { + return undefined; + } + const agent = agentData || agentsMap?.[conversation.agent_id]; + return agent?.provider; + }, [conversation?.endpoint, conversation?.agent_id, agentData, agentsMap]); + + const endpointType = useMemo( + () => resolveEndpointType(endpointsConfig, conversation?.endpoint, agentProvider), + [endpointsConfig, conversation?.endpoint, agentProvider], + ); + const useResponsesApi = useMemo(() => { const isAgents = isAgentsEndpoint(conversation?.endpoint); if (!isAgents || !conversation?.agent_id || conversation?.useResponsesApi) { diff --git a/client/src/Providers/__tests__/DragDropContext.spec.tsx b/client/src/Providers/__tests__/DragDropContext.spec.tsx new file mode 100644 index 0000000000..3c5e0f0796 --- /dev/null +++ b/client/src/Providers/__tests__/DragDropContext.spec.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TEndpointsConfig, Agent } from 'librechat-data-provider'; +import { DragDropProvider, useDragDropContext } from '../DragDropContext'; + +const mockEndpointsConfig: TEndpointsConfig = { + [EModelEndpoint.openAI]: { userProvide: false, order: 0 }, + [EModelEndpoint.agents]: { userProvide: false, order: 1 }, + [EModelEndpoint.anthropic]: { userProvide: false, order: 6 }, + Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, + 'Some Endpoint': { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, +}; + +let mockConversation: Record | null = null; +let mockAgentsMap: Record> = {}; +let mockAgentQueryData: Partial | undefined; + +jest.mock('~/data-provider', () => ({ + useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }), + useGetAgentByIdQuery: () => ({ data: mockAgentQueryData }), +})); + +jest.mock('../AgentsMapContext', () => ({ + useAgentsMapContext: () => mockAgentsMap, +})); + +jest.mock('../ChatContext', () => ({ + useChatContext: () => ({ conversation: mockConversation }), +})); + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe('DragDropContext endpointType resolution', () => { + beforeEach(() => { + mockConversation = null; + mockAgentsMap = {}; + mockAgentQueryData = undefined; + }); + + describe('non-agents endpoints', () => { + it('resolves custom endpoint type for a custom endpoint', () => { + mockConversation = { endpoint: 'Moonshot' }; + const { result } = renderHook(() => useDragDropContext(), { wrapper }); + expect(result.current.endpointType).toBe(EModelEndpoint.custom); + }); + + it('resolves endpoint name for a standard endpoint', () => { + mockConversation = { endpoint: EModelEndpoint.openAI }; + const { result } = renderHook(() => useDragDropContext(), { wrapper }); + expect(result.current.endpointType).toBe(EModelEndpoint.openAI); + }); + }); + + describe('agents endpoint with provider from agentsMap', () => { + it('resolves to custom for agent with Moonshot provider', () => { + mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }; + mockAgentsMap = { + 'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial, + }; + const { result } = renderHook(() => useDragDropContext(), { wrapper }); + expect(result.current.endpointType).toBe(EModelEndpoint.custom); + }); + + it('resolves to custom for agent with custom provider with spaces', () => { + mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }; + mockAgentsMap = { + 'agent-1': { provider: 'Some Endpoint', model_parameters: {} } as Partial, + }; + const { result } = renderHook(() => useDragDropContext(), { wrapper }); + expect(result.current.endpointType).toBe(EModelEndpoint.custom); + }); + + it('resolves to openAI for agent with openAI provider', () => { + mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }; + mockAgentsMap = { + 'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial, + }; + const { result } = renderHook(() => useDragDropContext(), { wrapper }); + expect(result.current.endpointType).toBe(EModelEndpoint.openAI); + }); + + it('resolves to anthropic for agent with anthropic provider', () => { + mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }; + mockAgentsMap = { + 'agent-1': { provider: EModelEndpoint.anthropic, model_parameters: {} } as Partial, + }; + const { result } = renderHook(() => useDragDropContext(), { wrapper }); + expect(result.current.endpointType).toBe(EModelEndpoint.anthropic); + }); + }); + + describe('agents endpoint with provider from agentData query', () => { + it('uses agentData when agent is not in agentsMap', () => { + mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-2' }; + mockAgentsMap = {}; + mockAgentQueryData = { provider: 'Moonshot' } as Partial; + const { result } = renderHook(() => useDragDropContext(), { wrapper }); + expect(result.current.endpointType).toBe(EModelEndpoint.custom); + }); + }); + + describe('agents endpoint without provider', () => { + it('falls back to agents when no agent_id', () => { + mockConversation = { endpoint: EModelEndpoint.agents }; + const { result } = renderHook(() => useDragDropContext(), { wrapper }); + expect(result.current.endpointType).toBe(EModelEndpoint.agents); + }); + + it('falls back to agents when agent has no provider', () => { + mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }; + mockAgentsMap = { 'agent-1': { model_parameters: {} } as Partial }; + const { result } = renderHook(() => useDragDropContext(), { wrapper }); + expect(result.current.endpointType).toBe(EModelEndpoint.agents); + }); + }); + + describe('consistency: same endpoint type whether used directly or through agents', () => { + it('Moonshot resolves to the same type as direct endpoint and as agent provider', () => { + mockConversation = { endpoint: 'Moonshot' }; + const { result: directResult } = renderHook(() => useDragDropContext(), { wrapper }); + + mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }; + mockAgentsMap = { + 'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial, + }; + const { result: agentResult } = renderHook(() => useDragDropContext(), { wrapper }); + + expect(directResult.current.endpointType).toBe(agentResult.current.endpointType); + }); + }); +}); diff --git a/client/src/components/Chat/Footer.tsx b/client/src/components/Chat/Footer.tsx index 75dd853c4f..541647a8d0 100644 --- a/client/src/components/Chat/Footer.tsx +++ b/client/src/components/Chat/Footer.tsx @@ -1,11 +1,11 @@ -import React, { useEffect } from 'react'; -import ReactMarkdown from 'react-markdown'; +import React, { useEffect, memo } from 'react'; import TagManager from 'react-gtm-module'; +import ReactMarkdown from 'react-markdown'; import { Constants } from 'librechat-data-provider'; import { useGetStartupConfig } from '~/data-provider'; import { useLocalize } from '~/hooks'; -export default function Footer({ className }: { className?: string }) { +function Footer({ className }: { className?: string }) { const { data: config } = useGetStartupConfig(); const localize = useLocalize(); @@ -98,3 +98,8 @@ export default function Footer({ className }: { className?: string }) { ); } + +const MemoizedFooter = memo(Footer); +MemoizedFooter.displayName = 'Footer'; + +export default MemoizedFooter; diff --git a/client/src/components/Chat/Input/Files/AttachFileChat.tsx b/client/src/components/Chat/Input/Files/AttachFileChat.tsx index 37b3584d3e..00a0b7aaa8 100644 --- a/client/src/components/Chat/Input/Files/AttachFileChat.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileChat.tsx @@ -2,10 +2,9 @@ import { memo, useMemo } from 'react'; import { Constants, supportsFiles, - EModelEndpoint, mergeFileConfig, isAgentsEndpoint, - getEndpointField, + resolveEndpointType, isAssistantsEndpoint, getEndpointFileConfig, } from 'librechat-data-provider'; @@ -55,21 +54,31 @@ function AttachFileChat({ const { data: endpointsConfig } = useGetEndpointsQuery(); - const endpointType = useMemo(() => { - return ( - getEndpointField(endpointsConfig, endpoint, 'type') || - (endpoint as EModelEndpoint | undefined) - ); - }, [endpoint, endpointsConfig]); + const agentProvider = useMemo(() => { + if (!isAgents || !conversation?.agent_id) { + return undefined; + } + const agent = agentData || agentsMap?.[conversation.agent_id]; + return agent?.provider; + }, [isAgents, conversation?.agent_id, agentData, agentsMap]); + const endpointType = useMemo( + () => resolveEndpointType(endpointsConfig, endpoint, agentProvider), + [endpointsConfig, endpoint, agentProvider], + ); + + const fileConfigEndpoint = useMemo( + () => (isAgents && agentProvider ? agentProvider : endpoint), + [isAgents, agentProvider, endpoint], + ); const endpointFileConfig = useMemo( () => getEndpointFileConfig({ - endpoint, fileConfig, endpointType, + endpoint: fileConfigEndpoint, }), - [endpoint, fileConfig, endpointType], + [fileConfigEndpoint, fileConfig, endpointType], ); const endpointSupportsFiles: boolean = useMemo( () => supportsFiles[endpointType ?? endpoint ?? ''] ?? false, diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index 5b7346f646..62072e49e5 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -50,7 +50,7 @@ interface AttachFileMenuProps { endpoint?: string | null; disabled?: boolean | null; conversationId: string; - endpointType?: EModelEndpoint; + endpointType?: EModelEndpoint | string; endpointFileConfig?: EndpointFileConfig; useResponsesApi?: boolean; } diff --git a/client/src/components/Chat/Input/Files/__tests__/AttachFileChat.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/AttachFileChat.spec.tsx new file mode 100644 index 0000000000..d12c25c4a3 --- /dev/null +++ b/client/src/components/Chat/Input/Files/__tests__/AttachFileChat.spec.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { EModelEndpoint, mergeFileConfig } from 'librechat-data-provider'; +import type { TEndpointsConfig, Agent } from 'librechat-data-provider'; +import AttachFileChat from '../AttachFileChat'; + +const mockEndpointsConfig: TEndpointsConfig = { + [EModelEndpoint.openAI]: { userProvide: false, order: 0 }, + [EModelEndpoint.agents]: { userProvide: false, order: 1 }, + [EModelEndpoint.assistants]: { userProvide: false, order: 2 }, + Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, +}; + +const mockFileConfig = mergeFileConfig({ + endpoints: { + Moonshot: { fileLimit: 5 }, + [EModelEndpoint.agents]: { fileLimit: 20 }, + default: { fileLimit: 10 }, + }, +}); + +let mockAgentsMap: Record> = {}; +let mockAgentQueryData: Partial | undefined; + +jest.mock('~/data-provider', () => ({ + useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }), + useGetFileConfig: ({ select }: { select?: (data: unknown) => unknown }) => ({ + data: select != null ? select(mockFileConfig) : mockFileConfig, + }), + useGetAgentByIdQuery: () => ({ data: mockAgentQueryData }), +})); + +jest.mock('~/Providers', () => ({ + useAgentsMapContext: () => mockAgentsMap, +})); + +/** Capture the props passed to AttachFileMenu */ +let mockAttachFileMenuProps: Record = {}; +jest.mock('../AttachFileMenu', () => { + return function MockAttachFileMenu(props: Record) { + mockAttachFileMenuProps = props; + return
; + }; +}); + +jest.mock('../AttachFile', () => { + return function MockAttachFile() { + return
; + }; +}); + +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +function renderComponent(conversation: Record | null, disableInputs = false) { + return render( + + + + + , + ); +} + +describe('AttachFileChat', () => { + beforeEach(() => { + mockAgentsMap = {}; + mockAgentQueryData = undefined; + mockAttachFileMenuProps = {}; + }); + + describe('rendering decisions', () => { + it('renders AttachFileMenu for agents endpoint', () => { + renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }); + expect(screen.getByTestId('attach-file-menu')).toBeInTheDocument(); + }); + + it('renders AttachFileMenu for custom endpoint with file support', () => { + renderComponent({ endpoint: 'Moonshot' }); + expect(screen.getByTestId('attach-file-menu')).toBeInTheDocument(); + }); + + it('renders null for null conversation', () => { + const { container } = renderComponent(null); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('endpointType resolution for agents', () => { + it('passes custom endpointType when agent provider is a custom endpoint', () => { + mockAgentsMap = { + 'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial, + }; + renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }); + expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom); + }); + + it('passes openAI endpointType when agent provider is openAI', () => { + mockAgentsMap = { + 'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial, + }; + renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }); + expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.openAI); + }); + + it('passes agents endpointType when no agent provider', () => { + renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }); + expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.agents); + }); + + it('passes agents endpointType when no agent_id', () => { + renderComponent({ endpoint: EModelEndpoint.agents }); + expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.agents); + }); + + it('uses agentData query when agent not in agentsMap', () => { + mockAgentQueryData = { provider: 'Moonshot' } as Partial; + renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-2' }); + expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom); + }); + }); + + describe('endpointType resolution for non-agents', () => { + it('passes custom endpointType for a custom endpoint', () => { + renderComponent({ endpoint: 'Moonshot' }); + expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom); + }); + + it('passes openAI endpointType for openAI endpoint', () => { + renderComponent({ endpoint: EModelEndpoint.openAI }); + expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.openAI); + }); + }); + + describe('consistency: same endpoint type for direct vs agent usage', () => { + it('resolves Moonshot the same way whether used directly or through an agent', () => { + renderComponent({ endpoint: 'Moonshot' }); + const directType = mockAttachFileMenuProps.endpointType; + + mockAgentsMap = { + 'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial, + }; + renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }); + const agentType = mockAttachFileMenuProps.endpointType; + + expect(directType).toBe(agentType); + }); + }); + + describe('endpointFileConfig resolution', () => { + it('passes Moonshot-specific file config for agent with Moonshot provider', () => { + mockAgentsMap = { + 'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial, + }; + renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }); + const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number }; + expect(config?.fileLimit).toBe(5); + }); + + it('passes agents file config when agent has no specific provider config', () => { + mockAgentsMap = { + 'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial, + }; + renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' }); + const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number }; + expect(config?.fileLimit).toBe(10); + }); + + it('passes agents file config when no agent provider', () => { + renderComponent({ endpoint: EModelEndpoint.agents }); + const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number }; + expect(config?.fileLimit).toBe(20); + }); + }); +}); diff --git a/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx index d3f0fb65bc..cf08721207 100644 --- a/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx +++ b/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx @@ -1,12 +1,10 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; -import '@testing-library/jest-dom'; import { RecoilRoot } from 'recoil'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { EModelEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, Providers } from 'librechat-data-provider'; import AttachFileMenu from '../AttachFileMenu'; -// Mock all the hooks jest.mock('~/hooks', () => ({ useAgentToolPermissions: jest.fn(), useAgentCapabilities: jest.fn(), @@ -25,53 +23,45 @@ jest.mock('~/data-provider', () => ({ })); jest.mock('~/components/SharePoint', () => ({ - SharePointPickerDialog: jest.fn(() => null), + SharePointPickerDialog: () => null, })); jest.mock('@librechat/client', () => { - const React = jest.requireActual('react'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const R = require('react'); return { - FileUpload: React.forwardRef(({ children, handleFileChange }: any, ref: any) => ( -
- - {children} -
- )), - TooltipAnchor: ({ render }: any) => render, - DropdownPopup: ({ trigger, items, isOpen, setIsOpen }: any) => { - const handleTriggerClick = () => { - if (setIsOpen) { - setIsOpen(!isOpen); - } - }; - - return ( -
-
{trigger}
- {isOpen && ( -
- {items.map((item: any, idx: number) => ( - - ))} -
- )} -
- ); - }, - AttachmentIcon: () => 📎, - SharePointIcon: () => SP, + FileUpload: (props) => R.createElement('div', { 'data-testid': 'file-upload' }, props.children), + TooltipAnchor: (props) => props.render, + DropdownPopup: (props) => + R.createElement( + 'div', + null, + R.createElement('div', { onClick: () => props.setIsOpen(!props.isOpen) }, props.trigger), + props.isOpen && + R.createElement( + 'div', + { 'data-testid': 'dropdown-menu' }, + props.items.map((item, idx) => + R.createElement( + 'button', + { key: idx, onClick: item.onClick, 'data-testid': `menu-item-${idx}` }, + item.label, + ), + ), + ), + ), + AttachmentIcon: () => R.createElement('span', { 'data-testid': 'attachment-icon' }), + SharePointIcon: () => R.createElement('span', { 'data-testid': 'sharepoint-icon' }), }; }); -jest.mock('@ariakit/react', () => ({ - MenuButton: ({ children, onClick, disabled, ...props }: any) => ( - - ), -})); +jest.mock('@ariakit/react', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const R = require('react'); + return { + MenuButton: (props) => R.createElement('button', props, props.children), + }; +}); const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions; const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities; @@ -83,558 +73,283 @@ const mockUseSharePointFileHandling = jest.requireMock( ).default; const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig; -describe('AttachFileMenu', () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const mockHandleFileChange = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - // Default mock implementations - mockUseLocalize.mockReturnValue((key: string) => { - const translations: Record = { - com_ui_upload_provider: 'Upload to Provider', - com_ui_upload_image_input: 'Upload Image', - com_ui_upload_ocr_text: 'Upload OCR Text', - com_ui_upload_file_search: 'Upload for File Search', - com_ui_upload_code_files: 'Upload Code Files', - com_sidepanel_attach_files: 'Attach Files', - com_files_upload_sharepoint: 'Upload from SharePoint', - }; - return translations[key] || key; - }); - - mockUseAgentCapabilities.mockReturnValue({ - contextEnabled: false, - fileSearchEnabled: false, - codeEnabled: false, - }); - - mockUseGetAgentsConfig.mockReturnValue({ - agentsConfig: { - capabilities: { - contextEnabled: false, - fileSearchEnabled: false, - codeEnabled: false, - }, - }, - }); - - mockUseFileHandling.mockReturnValue({ - handleFileChange: mockHandleFileChange, - }); - - mockUseSharePointFileHandling.mockReturnValue({ - handleSharePointFiles: jest.fn(), - isProcessing: false, - downloadProgress: 0, - }); - - mockUseGetStartupConfig.mockReturnValue({ - data: { - sharePointFilePickerEnabled: false, - }, - }); - - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: undefined, - }); - }); - - const renderAttachFileMenu = (props: any = {}) => { - return render( - - - - - , - ); +function setupMocks(overrides: { provider?: string } = {}) { + const translations: Record = { + com_ui_upload_provider: 'Upload to Provider', + com_ui_upload_image_input: 'Upload Image', + com_ui_upload_ocr_text: 'Upload as Text', + com_ui_upload_file_search: 'Upload for File Search', + com_ui_upload_code_files: 'Upload Code Files', + com_sidepanel_attach_files: 'Attach Files', + com_files_upload_sharepoint: 'Upload from SharePoint', }; - - describe('Basic Rendering', () => { - it('should render the attachment button', () => { - renderAttachFileMenu(); - const button = screen.getByRole('button', { name: /attach file options/i }); - expect(button).toBeInTheDocument(); - }); - - it('should be disabled when disabled prop is true', () => { - renderAttachFileMenu({ disabled: true }); - const button = screen.getByRole('button', { name: /attach file options/i }); - expect(button).toBeDisabled(); - }); - - it('should not be disabled when disabled prop is false', () => { - renderAttachFileMenu({ disabled: false }); - const button = screen.getByRole('button', { name: /attach file options/i }); - expect(button).not.toBeDisabled(); - }); + mockUseLocalize.mockReturnValue((key: string) => translations[key] || key); + mockUseAgentCapabilities.mockReturnValue({ + contextEnabled: false, + fileSearchEnabled: false, + codeEnabled: false, }); + mockUseGetAgentsConfig.mockReturnValue({ agentsConfig: {} }); + mockUseFileHandling.mockReturnValue({ handleFileChange: jest.fn() }); + mockUseSharePointFileHandling.mockReturnValue({ + handleSharePointFiles: jest.fn(), + isProcessing: false, + downloadProgress: 0, + }); + mockUseGetStartupConfig.mockReturnValue({ data: { sharePointFilePickerEnabled: false } }); + mockUseAgentToolPermissions.mockReturnValue({ + fileSearchAllowedByAgent: false, + codeAllowedByAgent: false, + provider: overrides.provider ?? undefined, + }); +} - describe('Provider Detection Fix - endpointType Priority', () => { - it('should prioritize endpointType over currentProvider for LiteLLM gateway', () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: 'litellm', // Custom gateway name NOT in documentSupportedProviders - }); +function renderMenu(props: Record = {}) { + return render( + + + + + , + ); +} - renderAttachFileMenu({ - endpoint: 'litellm', - endpointType: EModelEndpoint.openAI, // Backend override IS in documentSupportedProviders - }); +function openMenu() { + fireEvent.click(screen.getByRole('button', { name: /attach file options/i })); +} - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); +describe('AttachFileMenu', () => { + beforeEach(jest.clearAllMocks); - // With the fix, should show "Upload to Provider" because endpointType is checked first + describe('Upload to Provider vs Upload Image', () => { + it('shows "Upload to Provider" when endpointType is custom (resolved from agent provider)', () => { + setupMocks({ provider: 'Moonshot' }); + renderMenu({ endpointType: EModelEndpoint.custom }); + openMenu(); expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); expect(screen.queryByText('Upload Image')).not.toBeInTheDocument(); }); - it('should show Upload to Provider for custom endpoints with OpenAI endpointType', () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: 'my-custom-gateway', - }); - - renderAttachFileMenu({ - endpoint: 'my-custom-gateway', - endpointType: EModelEndpoint.openAI, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - + it('shows "Upload to Provider" when endpointType is openAI', () => { + setupMocks({ provider: EModelEndpoint.openAI }); + renderMenu({ endpointType: EModelEndpoint.openAI }); + openMenu(); expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); }); - it('should show Upload Image when neither endpointType nor provider support documents', () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: 'unsupported-provider', - }); + it('shows "Upload to Provider" when endpointType is anthropic', () => { + setupMocks({ provider: EModelEndpoint.anthropic }); + renderMenu({ endpointType: EModelEndpoint.anthropic }); + openMenu(); + expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); + }); - renderAttachFileMenu({ - endpoint: 'unsupported-provider', - endpointType: 'unsupported-endpoint' as any, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); + it('shows "Upload to Provider" when endpointType is google', () => { + setupMocks({ provider: Providers.GOOGLE }); + renderMenu({ endpointType: EModelEndpoint.google }); + openMenu(); + expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); + }); + it('shows "Upload Image" when endpointType is agents (no provider resolution)', () => { + setupMocks(); + renderMenu({ endpointType: EModelEndpoint.agents }); + openMenu(); expect(screen.getByText('Upload Image')).toBeInTheDocument(); expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument(); }); - it('should fallback to currentProvider when endpointType is undefined', () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: EModelEndpoint.openAI, - }); - - renderAttachFileMenu({ - endpoint: EModelEndpoint.openAI, - endpointType: undefined, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); + it('shows "Upload Image" when neither endpointType nor provider supports documents', () => { + setupMocks({ provider: 'unknown-provider' }); + renderMenu({ endpointType: 'unknown-type' }); + openMenu(); + expect(screen.getByText('Upload Image')).toBeInTheDocument(); + }); + it('shows "Upload to Provider" for azureOpenAI with useResponsesApi', () => { + setupMocks({ provider: EModelEndpoint.azureOpenAI }); + renderMenu({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: true }); + openMenu(); expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); }); - it('should fallback to currentProvider when endpointType is null', () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: EModelEndpoint.anthropic, - }); - - renderAttachFileMenu({ - endpoint: EModelEndpoint.anthropic, - endpointType: null, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - - expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); + it('shows "Upload Image" for azureOpenAI without useResponsesApi', () => { + setupMocks({ provider: EModelEndpoint.azureOpenAI }); + renderMenu({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: false }); + openMenu(); + expect(screen.getByText('Upload Image')).toBeInTheDocument(); }); }); - describe('Supported Providers', () => { - const supportedProviders = [ - { name: 'OpenAI', endpoint: EModelEndpoint.openAI }, - { name: 'Anthropic', endpoint: EModelEndpoint.anthropic }, - { name: 'Google', endpoint: EModelEndpoint.google }, - { name: 'Custom', endpoint: EModelEndpoint.custom }, - ]; - - supportedProviders.forEach(({ name, endpoint }) => { - it(`should show Upload to Provider for ${name}`, () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: endpoint, - }); - - renderAttachFileMenu({ - endpoint, - endpointType: endpoint, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - - expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); + describe('agent provider resolution scenario', () => { + it('shows "Upload to Provider" when agents endpoint has custom endpointType from provider', () => { + setupMocks({ provider: 'Moonshot' }); + renderMenu({ + endpoint: EModelEndpoint.agents, + endpointType: EModelEndpoint.custom, }); - }); - - it('should show Upload to Provider for Azure OpenAI with useResponsesApi', () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: EModelEndpoint.azureOpenAI, - }); - - renderAttachFileMenu({ - endpoint: EModelEndpoint.azureOpenAI, - endpointType: EModelEndpoint.azureOpenAI, - useResponsesApi: true, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - + openMenu(); expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); }); - it('should NOT show Upload to Provider for Azure OpenAI without useResponsesApi', () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: EModelEndpoint.azureOpenAI, + it('shows "Upload Image" when agents endpoint has no resolved provider type', () => { + setupMocks(); + renderMenu({ + endpoint: EModelEndpoint.agents, + endpointType: EModelEndpoint.agents, }); - - renderAttachFileMenu({ - endpoint: EModelEndpoint.azureOpenAI, - endpointType: EModelEndpoint.azureOpenAI, - useResponsesApi: false, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - - expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument(); + openMenu(); expect(screen.getByText('Upload Image')).toBeInTheDocument(); }); }); + describe('Basic Rendering', () => { + it('renders the attachment button', () => { + setupMocks(); + renderMenu(); + expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument(); + }); + + it('is disabled when disabled prop is true', () => { + setupMocks(); + renderMenu({ disabled: true }); + expect(screen.getByRole('button', { name: /attach file options/i })).toBeDisabled(); + }); + + it('is not disabled when disabled prop is false', () => { + setupMocks(); + renderMenu({ disabled: false }); + expect(screen.getByRole('button', { name: /attach file options/i })).not.toBeDisabled(); + }); + }); + describe('Agent Capabilities', () => { - it('should show OCR Text option when context is enabled', () => { + it('shows OCR Text option when context is enabled', () => { + setupMocks(); mockUseAgentCapabilities.mockReturnValue({ contextEnabled: true, fileSearchEnabled: false, codeEnabled: false, }); - - renderAttachFileMenu({ - endpointType: EModelEndpoint.openAI, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - - expect(screen.getByText('Upload OCR Text')).toBeInTheDocument(); + renderMenu({ endpointType: EModelEndpoint.openAI }); + openMenu(); + expect(screen.getByText('Upload as Text')).toBeInTheDocument(); }); - it('should show File Search option when enabled and allowed by agent', () => { + it('shows File Search option when enabled and allowed by agent', () => { + setupMocks(); mockUseAgentCapabilities.mockReturnValue({ contextEnabled: false, fileSearchEnabled: true, codeEnabled: false, }); - mockUseAgentToolPermissions.mockReturnValue({ fileSearchAllowedByAgent: true, codeAllowedByAgent: false, provider: undefined, }); - - renderAttachFileMenu({ - endpointType: EModelEndpoint.openAI, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - + renderMenu({ endpointType: EModelEndpoint.openAI }); + openMenu(); expect(screen.getByText('Upload for File Search')).toBeInTheDocument(); }); - it('should NOT show File Search when enabled but not allowed by agent', () => { + it('does NOT show File Search when enabled but not allowed by agent', () => { + setupMocks(); mockUseAgentCapabilities.mockReturnValue({ contextEnabled: false, fileSearchEnabled: true, codeEnabled: false, }); - - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: undefined, - }); - - renderAttachFileMenu({ - endpointType: EModelEndpoint.openAI, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - + renderMenu({ endpointType: EModelEndpoint.openAI }); + openMenu(); expect(screen.queryByText('Upload for File Search')).not.toBeInTheDocument(); }); - it('should show Code Files option when enabled and allowed by agent', () => { + it('shows Code Files option when enabled and allowed by agent', () => { + setupMocks(); mockUseAgentCapabilities.mockReturnValue({ contextEnabled: false, fileSearchEnabled: false, codeEnabled: true, }); - mockUseAgentToolPermissions.mockReturnValue({ fileSearchAllowedByAgent: false, codeAllowedByAgent: true, provider: undefined, }); - - renderAttachFileMenu({ - endpointType: EModelEndpoint.openAI, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - + renderMenu({ endpointType: EModelEndpoint.openAI }); + openMenu(); expect(screen.getByText('Upload Code Files')).toBeInTheDocument(); }); - it('should show all options when all capabilities are enabled', () => { + it('shows all options when all capabilities are enabled', () => { + setupMocks(); mockUseAgentCapabilities.mockReturnValue({ contextEnabled: true, fileSearchEnabled: true, codeEnabled: true, }); - mockUseAgentToolPermissions.mockReturnValue({ fileSearchAllowedByAgent: true, codeAllowedByAgent: true, provider: undefined, }); - - renderAttachFileMenu({ - endpointType: EModelEndpoint.openAI, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - + renderMenu({ endpointType: EModelEndpoint.openAI }); + openMenu(); expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); - expect(screen.getByText('Upload OCR Text')).toBeInTheDocument(); + expect(screen.getByText('Upload as Text')).toBeInTheDocument(); expect(screen.getByText('Upload for File Search')).toBeInTheDocument(); expect(screen.getByText('Upload Code Files')).toBeInTheDocument(); }); }); describe('SharePoint Integration', () => { - it('should show SharePoint option when enabled', () => { + it('shows SharePoint option when enabled', () => { + setupMocks(); mockUseGetStartupConfig.mockReturnValue({ - data: { - sharePointFilePickerEnabled: true, - }, + data: { sharePointFilePickerEnabled: true }, }); - - renderAttachFileMenu({ - endpointType: EModelEndpoint.openAI, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - + renderMenu({ endpointType: EModelEndpoint.openAI }); + openMenu(); expect(screen.getByText('Upload from SharePoint')).toBeInTheDocument(); }); - it('should NOT show SharePoint option when disabled', () => { - mockUseGetStartupConfig.mockReturnValue({ - data: { - sharePointFilePickerEnabled: false, - }, - }); - - renderAttachFileMenu({ - endpointType: EModelEndpoint.openAI, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - + it('does NOT show SharePoint option when disabled', () => { + setupMocks(); + renderMenu({ endpointType: EModelEndpoint.openAI }); + openMenu(); expect(screen.queryByText('Upload from SharePoint')).not.toBeInTheDocument(); }); }); describe('Edge Cases', () => { - it('should handle undefined endpoint and provider gracefully', () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: undefined, - }); - - renderAttachFileMenu({ - endpoint: undefined, - endpointType: undefined, - }); - + it('handles undefined endpoint and provider gracefully', () => { + setupMocks(); + renderMenu({ endpoint: undefined, endpointType: undefined }); const button = screen.getByRole('button', { name: /attach file options/i }); expect(button).toBeInTheDocument(); fireEvent.click(button); - - // Should show Upload Image as fallback expect(screen.getByText('Upload Image')).toBeInTheDocument(); }); - it('should handle null endpoint and provider gracefully', () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: null, - }); - - renderAttachFileMenu({ - endpoint: null, - endpointType: null, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - expect(button).toBeInTheDocument(); + it('handles null endpoint and provider gracefully', () => { + setupMocks(); + renderMenu({ endpoint: null, endpointType: null }); + expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument(); }); - it('should handle missing agentId gracefully', () => { - renderAttachFileMenu({ - agentId: undefined, - endpointType: EModelEndpoint.openAI, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - expect(button).toBeInTheDocument(); + it('handles missing agentId gracefully', () => { + setupMocks(); + renderMenu({ agentId: undefined, endpointType: EModelEndpoint.openAI }); + expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument(); }); - it('should handle empty string agentId', () => { - renderAttachFileMenu({ - agentId: '', - endpointType: EModelEndpoint.openAI, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - expect(button).toBeInTheDocument(); - }); - }); - - describe('Google Provider Special Case', () => { - it('should use image_document_video_audio file type for Google provider', () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: EModelEndpoint.google, - }); - - renderAttachFileMenu({ - endpoint: EModelEndpoint.google, - endpointType: EModelEndpoint.google, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - - const uploadProviderButton = screen.getByText('Upload to Provider'); - expect(uploadProviderButton).toBeInTheDocument(); - - // Click the upload to provider option - fireEvent.click(uploadProviderButton); - - // The file input should have been clicked (indirectly tested through the implementation) - }); - - it('should use image_document file type for non-Google providers', () => { - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: EModelEndpoint.openAI, - }); - - renderAttachFileMenu({ - endpoint: EModelEndpoint.openAI, - endpointType: EModelEndpoint.openAI, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - - const uploadProviderButton = screen.getByText('Upload to Provider'); - expect(uploadProviderButton).toBeInTheDocument(); - fireEvent.click(uploadProviderButton); - - // Implementation detail - image_document type is used - }); - }); - - describe('Regression Tests', () => { - it('should not break the previous behavior for direct provider attachments', () => { - // When using a direct supported provider (not through a gateway) - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: EModelEndpoint.anthropic, - }); - - renderAttachFileMenu({ - endpoint: EModelEndpoint.anthropic, - endpointType: EModelEndpoint.anthropic, - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - - expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); - }); - - it('should maintain correct priority when both are supported', () => { - // Both endpointType and provider are supported, endpointType should be checked first - mockUseAgentToolPermissions.mockReturnValue({ - fileSearchAllowedByAgent: false, - codeAllowedByAgent: false, - provider: EModelEndpoint.google, - }); - - renderAttachFileMenu({ - endpoint: EModelEndpoint.google, - endpointType: EModelEndpoint.openAI, // Different but both supported - }); - - const button = screen.getByRole('button', { name: /attach file options/i }); - fireEvent.click(button); - - // Should still work because endpointType (openAI) is supported - expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); + it('handles empty string agentId', () => { + setupMocks(); + renderMenu({ agentId: '', endpointType: EModelEndpoint.openAI }); + expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument(); }); }); }); diff --git a/client/src/components/MCP/MCPServerMenuItem.tsx b/client/src/components/MCP/MCPServerMenuItem.tsx index 2291a5233e..7fcb773bb9 100644 --- a/client/src/components/MCP/MCPServerMenuItem.tsx +++ b/client/src/components/MCP/MCPServerMenuItem.tsx @@ -46,7 +46,6 @@ export default function MCPServerMenuItem({ name="mcp-servers" value={server.serverName} checked={isSelected} - setValueOnChange={false} onChange={() => onToggle(server.serverName)} aria-label={accessibleLabel} className={cn( diff --git a/client/src/components/SidePanel/Agents/Code/Files.tsx b/client/src/components/SidePanel/Agents/Code/Files.tsx index 64524e66da..16360a7a0b 100644 --- a/client/src/components/SidePanel/Agents/Code/Files.tsx +++ b/client/src/components/SidePanel/Agents/Code/Files.tsx @@ -1,18 +1,11 @@ import { memo, useMemo, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { AttachmentIcon } from '@librechat/client'; -import { - EToolResources, - EModelEndpoint, - mergeFileConfig, - AgentCapabilities, - getEndpointFileConfig, -} from 'librechat-data-provider'; +import { EToolResources, EModelEndpoint, AgentCapabilities } from 'librechat-data-provider'; import type { ExtendedFile, AgentForm } from '~/common'; import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling'; +import { useAgentFileConfig, useLocalize, useLazyEffect } from '~/hooks'; import FileRow from '~/components/Chat/Input/Files/FileRow'; -import { useLocalize, useLazyEffect } from '~/hooks'; -import { useGetFileConfig } from '~/data-provider'; import { isEphemeralAgent } from '~/common'; const tool_resource = EToolResources.execute_code; @@ -29,14 +22,14 @@ function Files({ const fileInputRef = useRef(null); const [files, setFiles] = useState>(new Map()); const fileHandlingState = useMemo(() => ({ files, setFiles, conversation: null }), [files]); - const { data: fileConfig = null } = useGetFileConfig({ - select: (data) => mergeFileConfig(data), - }); + const { endpointFileConfig, providerValue, endpointType } = useAgentFileConfig(); + const endpointOverride = providerValue || EModelEndpoint.agents; const { abortUpload, handleFileChange } = useFileHandlingNoChatContext( { fileSetter: setFiles, additionalMetadata: { agent_id, tool_resource }, - endpointOverride: EModelEndpoint.agents, + endpointOverride, + endpointTypeOverride: endpointType, }, fileHandlingState, ); @@ -52,12 +45,6 @@ function Files({ ); const codeChecked = watch(AgentCapabilities.execute_code); - - 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 433992b1d0..906d742127 100644 --- a/client/src/components/SidePanel/Agents/FileContext.tsx +++ b/client/src/components/SidePanel/Agents/FileContext.tsx @@ -1,12 +1,7 @@ import { memo, useMemo, useRef, useState } from 'react'; import { Folder } from 'lucide-react'; import * as Ariakit from '@ariakit/react'; -import { - EModelEndpoint, - EToolResources, - mergeFileConfig, - getEndpointFileConfig, -} from 'librechat-data-provider'; +import { EModelEndpoint, EToolResources } from 'librechat-data-provider'; import { HoverCard, DropdownPopup, @@ -18,13 +13,13 @@ import { HoverCardTrigger, } from '@librechat/client'; import type { ExtendedFile } from '~/common'; -import { useLocalize, useLazyEffect } from '~/hooks'; -import { useGetFileConfig, useGetStartupConfig } from '~/data-provider'; -import { SharePointPickerDialog } from '~/components/SharePoint'; -import FileRow from '~/components/Chat/Input/Files/FileRow'; -import { ESide, isEphemeralAgent } from '~/common'; import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling'; import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling'; +import { useAgentFileConfig, useLocalize, useLazyEffect } from '~/hooks'; +import { SharePointPickerDialog } from '~/components/SharePoint'; +import FileRow from '~/components/Chat/Input/Files/FileRow'; +import { useGetStartupConfig } from '~/data-provider'; +import { ESide, isEphemeralAgent } from '~/common'; function FileContext({ agent_id, @@ -41,15 +36,14 @@ function FileContext({ const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false); const { data: startupConfig } = useGetStartupConfig(); const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled; - - const { data: fileConfig = null } = useGetFileConfig({ - select: (data) => mergeFileConfig(data), - }); + const { endpointFileConfig, providerValue, endpointType } = useAgentFileConfig(); + const endpointOverride = providerValue || EModelEndpoint.agents; const { handleFileChange } = useFileHandlingNoChatContext( { additionalMetadata: { agent_id, tool_resource: EToolResources.context }, - endpointOverride: EModelEndpoint.agents, + endpointOverride, + endpointTypeOverride: endpointType, fileSetter: setFiles, }, fileHandlingState, @@ -58,7 +52,8 @@ function FileContext({ useSharePointFileHandlingNoChatContext( { additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, - endpointOverride: EModelEndpoint.agents, + endpointOverride, + endpointTypeOverride: endpointType, fileSetter: setFiles, }, fileHandlingState, @@ -72,12 +67,6 @@ function FileContext({ [_files], 750, ); - - const endpointFileConfig = getEndpointFileConfig({ - fileConfig, - endpoint: EModelEndpoint.agents, - endpointType: EModelEndpoint.agents, - }); const isUploadDisabled = endpointFileConfig?.disabled ?? false; const handleSharePointFilesSelected = async (sharePointFiles: any[]) => { try { diff --git a/client/src/components/SidePanel/Agents/FileSearch.tsx b/client/src/components/SidePanel/Agents/FileSearch.tsx index bb7d272d90..79a08de0ed 100644 --- a/client/src/components/SidePanel/Agents/FileSearch.tsx +++ b/client/src/components/SidePanel/Agents/FileSearch.tsx @@ -3,22 +3,16 @@ import { Folder } from 'lucide-react'; import * as Ariakit from '@ariakit/react'; import { useFormContext } from 'react-hook-form'; import { SharePointIcon, AttachmentIcon, DropdownPopup } from '@librechat/client'; -import { - EModelEndpoint, - EToolResources, - mergeFileConfig, - AgentCapabilities, - getEndpointFileConfig, -} from 'librechat-data-provider'; +import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-data-provider'; import type { ExtendedFile, AgentForm } from '~/common'; -import { useGetFileConfig, useGetStartupConfig } from '~/data-provider'; -import { useLocalize, useLazyEffect } from '~/hooks'; +import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling'; +import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling'; +import { useAgentFileConfig, useLocalize, useLazyEffect } from '~/hooks'; import { SharePointPickerDialog } from '~/components/SharePoint'; import FileRow from '~/components/Chat/Input/Files/FileRow'; +import { useGetStartupConfig } from '~/data-provider'; import FileSearchCheckbox from './FileSearchCheckbox'; import { isEphemeralAgent } from '~/common'; -import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling'; -import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling'; function FileSearch({ agent_id, @@ -37,15 +31,14 @@ function FileSearch({ // Get startup configuration for SharePoint feature flag const { data: startupConfig } = useGetStartupConfig(); - - const { data: fileConfig = null } = useGetFileConfig({ - select: (data) => mergeFileConfig(data), - }); + const { endpointFileConfig, providerValue, endpointType } = useAgentFileConfig(); + const endpointOverride = providerValue || EModelEndpoint.agents; const { handleFileChange } = useFileHandlingNoChatContext( { additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, - endpointOverride: EModelEndpoint.agents, + endpointOverride, + endpointTypeOverride: endpointType, fileSetter: setFiles, }, fileHandlingState, @@ -55,7 +48,8 @@ function FileSearch({ useSharePointFileHandlingNoChatContext( { additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, - endpointOverride: EModelEndpoint.agents, + endpointOverride, + endpointTypeOverride: endpointType, fileSetter: setFiles, }, fileHandlingState, @@ -72,12 +66,6 @@ function FileSearch({ ); const fileSearchChecked = watch(AgentCapabilities.file_search); - - const endpointFileConfig = getEndpointFileConfig({ - fileConfig, - endpoint: EModelEndpoint.agents, - endpointType: EModelEndpoint.agents, - }); const isUploadDisabled = endpointFileConfig?.disabled ?? false; const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled; diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentFileConfig.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/AgentFileConfig.spec.tsx new file mode 100644 index 0000000000..aeb0dd3ff9 --- /dev/null +++ b/client/src/components/SidePanel/Agents/__tests__/AgentFileConfig.spec.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { EModelEndpoint, mergeFileConfig, resolveEndpointType } from 'librechat-data-provider'; +import type { TEndpointsConfig } from 'librechat-data-provider'; +import type { AgentForm } from '~/common'; +import useAgentFileConfig from '~/hooks/Agents/useAgentFileConfig'; + +/** + * Tests the useAgentFileConfig hook used by FileContext, FileSearch, and Code/Files. + * Uses the real hook with mocked data-fetching layer. + */ + +const mockEndpointsConfig: TEndpointsConfig = { + [EModelEndpoint.openAI]: { userProvide: false, order: 0 }, + [EModelEndpoint.agents]: { userProvide: false, order: 1 }, + Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, + 'Some Endpoint': { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, +}; + +let mockFileConfig = mergeFileConfig({ + endpoints: { + Moonshot: { fileLimit: 5 }, + [EModelEndpoint.agents]: { fileLimit: 20 }, + default: { fileLimit: 10 }, + }, +}); + +jest.mock('~/data-provider', () => ({ + useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }), + useGetFileConfig: ({ select }: { select?: (data: unknown) => unknown }) => ({ + data: select != null ? select(mockFileConfig) : mockFileConfig, + }), +})); + +function FileConfigProbe() { + const { endpointType, endpointFileConfig } = useAgentFileConfig(); + return ( +
+ {String(endpointType)} + {endpointFileConfig.fileLimit} + {String(endpointFileConfig.disabled ?? false)} +
+ ); +} + +function TestWrapper({ provider }: { provider?: string | { label: string; value: string } }) { + const methods = useForm({ + defaultValues: { provider: provider as AgentForm['provider'] }, + }); + return ( + + + + ); +} + +describe('AgentPanel file config resolution (useAgentFileConfig)', () => { + describe('endpointType resolution from form provider', () => { + it('resolves to custom when provider is a custom endpoint string', () => { + render(); + expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.custom); + }); + + it('resolves to custom when provider is a custom endpoint with spaces', () => { + render(); + expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.custom); + }); + + it('resolves to openAI when provider is openAI', () => { + render(); + expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.openAI); + }); + + it('falls back to agents when provider is undefined', () => { + render(); + expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.agents); + }); + + it('falls back to agents when provider is empty string', () => { + render(); + expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.agents); + expect(screen.getByTestId('fileLimit').textContent).toBe('20'); + }); + + it('falls back to agents when provider option has empty value', () => { + render(); + expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.agents); + expect(screen.getByTestId('fileLimit').textContent).toBe('20'); + }); + + it('resolves correctly when provider is an option object', () => { + render(); + expect(screen.getByTestId('endpointType').textContent).toBe(EModelEndpoint.custom); + }); + }); + + describe('file config fallback chain', () => { + it('uses Moonshot-specific file config when provider is Moonshot', () => { + render(); + expect(screen.getByTestId('fileLimit').textContent).toBe('5'); + }); + + it('falls back to agents file config when provider has no specific config', () => { + render(); + expect(screen.getByTestId('fileLimit').textContent).toBe('20'); + }); + + it('uses agents file config when no provider is set', () => { + render(); + expect(screen.getByTestId('fileLimit').textContent).toBe('20'); + }); + + it('falls back to default config for openAI provider (no openAI-specific config)', () => { + render(); + expect(screen.getByTestId('fileLimit').textContent).toBe('10'); + }); + }); + + describe('disabled state', () => { + it('reports not disabled for standard config', () => { + render(); + expect(screen.getByTestId('disabled').textContent).toBe('false'); + }); + + it('reports disabled when provider-specific config is disabled', () => { + const original = mockFileConfig; + mockFileConfig = mergeFileConfig({ + endpoints: { + Moonshot: { disabled: true }, + [EModelEndpoint.agents]: { fileLimit: 20 }, + default: { fileLimit: 10 }, + }, + }); + + render(); + expect(screen.getByTestId('disabled').textContent).toBe('true'); + + mockFileConfig = original; + }); + }); + + describe('consistency with direct custom endpoint', () => { + it('resolves to the same type as a direct custom endpoint would', () => { + render(); + const agentEndpointType = screen.getByTestId('endpointType').textContent; + const directEndpointType = resolveEndpointType(mockEndpointsConfig, 'Moonshot'); + expect(agentEndpointType).toBe(directEndpointType); + }); + }); +}); diff --git a/client/src/components/SidePanel/Agents/__tests__/CodeFiles.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/CodeFiles.spec.tsx new file mode 100644 index 0000000000..0e965e4c84 --- /dev/null +++ b/client/src/components/SidePanel/Agents/__tests__/CodeFiles.spec.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { EModelEndpoint, mergeFileConfig } from 'librechat-data-provider'; +import type { TEndpointsConfig } from 'librechat-data-provider'; +import type { AgentForm } from '~/common'; +import Files from '../Code/Files'; + +const mockEndpointsConfig: TEndpointsConfig = { + [EModelEndpoint.agents]: { userProvide: false, order: 1 }, + Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, +}; + +let mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + +jest.mock('~/data-provider', () => ({ + useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }), + useGetFileConfig: ({ select }: { select?: (d: unknown) => unknown }) => ({ + data: select != null ? select(mockFileConfig) : mockFileConfig, + }), +})); + +jest.mock('~/hooks', () => ({ + useAgentFileConfig: jest.requireActual('~/hooks/Agents/useAgentFileConfig').default, + useLocalize: () => (key: string) => key, + useLazyEffect: () => {}, +})); + +const mockUseFileHandlingNoChatContext = jest.fn().mockReturnValue({ + abortUpload: jest.fn(), + handleFileChange: jest.fn(), +}); + +jest.mock('~/hooks/Files/useFileHandling', () => ({ + useFileHandlingNoChatContext: (...args: unknown[]) => mockUseFileHandlingNoChatContext(...args), +})); + +jest.mock('~/components/Chat/Input/Files/FileRow', () => () => null); + +jest.mock('@librechat/client', () => ({ + AttachmentIcon: () => , +})); + +function Wrapper({ provider, children }: { provider?: string; children: React.ReactNode }) { + const methods = useForm({ + defaultValues: { provider: provider as AgentForm['provider'] }, + }); + return {children}; +} + +describe('Code/Files', () => { + it('renders upload UI when file uploads are not disabled', () => { + mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + render( + + + , + ); + expect(screen.getByText('com_assistants_code_interpreter_files')).toBeInTheDocument(); + }); + + it('returns null when file config is disabled for provider', () => { + mockFileConfig = mergeFileConfig({ + endpoints: { Moonshot: { disabled: true }, default: { fileLimit: 10 } }, + }); + const { container } = render( + + + , + ); + expect(container.innerHTML).toBe(''); + }); + + it('returns null when agents endpoint config is disabled and no provider config', () => { + mockFileConfig = mergeFileConfig({ + endpoints: { [EModelEndpoint.agents]: { disabled: true }, default: { fileLimit: 10 } }, + }); + const { container } = render( + + + , + ); + expect(container.innerHTML).toBe(''); + }); + + it('passes provider as endpointOverride and resolved type as endpointTypeOverride', () => { + mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + mockUseFileHandlingNoChatContext.mockClear(); + render( + + + , + ); + const params = mockUseFileHandlingNoChatContext.mock.calls[0][0]; + expect(params.endpointOverride).toBe('Moonshot'); + expect(params.endpointTypeOverride).toBe(EModelEndpoint.custom); + }); + + it('falls back to agents for endpointOverride when no provider', () => { + mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + mockUseFileHandlingNoChatContext.mockClear(); + render( + + + , + ); + const params = mockUseFileHandlingNoChatContext.mock.calls[0][0]; + expect(params.endpointOverride).toBe(EModelEndpoint.agents); + expect(params.endpointTypeOverride).toBe(EModelEndpoint.agents); + }); + + it('falls back to agents for endpointOverride when provider is empty string', () => { + mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + mockUseFileHandlingNoChatContext.mockClear(); + render( + + + , + ); + const params = mockUseFileHandlingNoChatContext.mock.calls[0][0]; + expect(params.endpointOverride).toBe(EModelEndpoint.agents); + }); + + it('renders when provider has no specific config and agents config is enabled', () => { + mockFileConfig = mergeFileConfig({ + endpoints: { + [EModelEndpoint.agents]: { fileLimit: 20 }, + default: { fileLimit: 10 }, + }, + }); + render( + + + , + ); + expect(screen.getByText('com_assistants_code_interpreter_files')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/SidePanel/Agents/__tests__/FileContext.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/FileContext.spec.tsx new file mode 100644 index 0000000000..f99d71d2b7 --- /dev/null +++ b/client/src/components/SidePanel/Agents/__tests__/FileContext.spec.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { EModelEndpoint, mergeFileConfig } from 'librechat-data-provider'; +import type { TEndpointsConfig } from 'librechat-data-provider'; +import type { AgentForm } from '~/common'; +import FileContext from '../FileContext'; + +const mockEndpointsConfig: TEndpointsConfig = { + [EModelEndpoint.agents]: { userProvide: false, order: 1 }, + Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, +}; + +let mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + +jest.mock('~/data-provider', () => ({ + useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }), + useGetFileConfig: ({ select }: { select?: (d: unknown) => unknown }) => ({ + data: select != null ? select(mockFileConfig) : mockFileConfig, + }), + useGetStartupConfig: () => ({ data: { sharePointFilePickerEnabled: false } }), +})); + +jest.mock('~/hooks', () => ({ + useAgentFileConfig: jest.requireActual('~/hooks/Agents/useAgentFileConfig').default, + useLocalize: () => (key: string) => key, + useLazyEffect: () => {}, +})); + +const mockUseFileHandlingNoChatContext = jest.fn().mockReturnValue({ + handleFileChange: jest.fn(), +}); + +jest.mock('~/hooks/Files/useFileHandling', () => ({ + useFileHandlingNoChatContext: (...args: unknown[]) => mockUseFileHandlingNoChatContext(...args), +})); + +jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({ + useSharePointFileHandlingNoChatContext: () => ({ + handleSharePointFiles: jest.fn(), + isProcessing: false, + downloadProgress: 0, + }), +})); + +jest.mock('~/components/SharePoint', () => ({ + SharePointPickerDialog: () => null, +})); + +jest.mock('~/components/Chat/Input/Files/FileRow', () => () => null); + +jest.mock('@ariakit/react', () => ({ + MenuButton: ({ children, ...props }: { children: React.ReactNode }) => ( + + ), +})); + +jest.mock('@librechat/client', () => ({ + HoverCard: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownPopup: () => null, + AttachmentIcon: () => , + CircleHelpIcon: () => , + SharePointIcon: () => , + HoverCardPortal: ({ children }: { children: React.ReactNode }) =>
{children}
, + HoverCardContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + HoverCardTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +function Wrapper({ provider, children }: { provider?: string; children: React.ReactNode }) { + const methods = useForm({ + defaultValues: { provider: provider as AgentForm['provider'] }, + }); + return {children}; +} + +describe('FileContext', () => { + it('renders upload UI when file uploads are not disabled', () => { + mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + render( + + + , + ); + expect(screen.getByText('com_agents_file_context_label')).toBeInTheDocument(); + }); + + it('returns null when file config is disabled', () => { + mockFileConfig = mergeFileConfig({ + endpoints: { Moonshot: { disabled: true }, default: { fileLimit: 10 } }, + }); + const { container } = render( + + + , + ); + expect(container.innerHTML).toBe(''); + }); + + it('returns null when agents endpoint config is disabled and provider has no specific config', () => { + mockFileConfig = mergeFileConfig({ + endpoints: { [EModelEndpoint.agents]: { disabled: true }, default: { fileLimit: 10 } }, + }); + const { container } = render( + + + , + ); + expect(container.innerHTML).toBe(''); + }); + + it('passes provider as endpointOverride and resolved type as endpointTypeOverride', () => { + mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + mockUseFileHandlingNoChatContext.mockClear(); + render( + + + , + ); + const params = mockUseFileHandlingNoChatContext.mock.calls[0][0]; + expect(params.endpointOverride).toBe('Moonshot'); + expect(params.endpointTypeOverride).toBe(EModelEndpoint.custom); + }); + + it('falls back to agents for endpointOverride when no provider', () => { + mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + mockUseFileHandlingNoChatContext.mockClear(); + render( + + + , + ); + const params = mockUseFileHandlingNoChatContext.mock.calls[0][0]; + expect(params.endpointOverride).toBe(EModelEndpoint.agents); + expect(params.endpointTypeOverride).toBe(EModelEndpoint.agents); + }); + + it('renders when provider has no specific config and agents config is enabled', () => { + mockFileConfig = mergeFileConfig({ + endpoints: { + [EModelEndpoint.agents]: { fileLimit: 20 }, + default: { fileLimit: 10 }, + }, + }); + render( + + + , + ); + expect(screen.getByText('com_agents_file_context_label')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/SidePanel/Agents/__tests__/FileSearch.spec.tsx b/client/src/components/SidePanel/Agents/__tests__/FileSearch.spec.tsx new file mode 100644 index 0000000000..003388f5d8 --- /dev/null +++ b/client/src/components/SidePanel/Agents/__tests__/FileSearch.spec.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { EModelEndpoint, mergeFileConfig } from 'librechat-data-provider'; +import type { TEndpointsConfig } from 'librechat-data-provider'; +import type { AgentForm } from '~/common'; +import FileSearch from '../FileSearch'; + +const mockEndpointsConfig: TEndpointsConfig = { + [EModelEndpoint.agents]: { userProvide: false, order: 1 }, + Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, +}; + +let mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + +jest.mock('~/data-provider', () => ({ + useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }), + useGetFileConfig: ({ select }: { select?: (d: unknown) => unknown }) => ({ + data: select != null ? select(mockFileConfig) : mockFileConfig, + }), + useGetStartupConfig: () => ({ data: { sharePointFilePickerEnabled: false } }), +})); + +jest.mock('~/hooks', () => ({ + useAgentFileConfig: jest.requireActual('~/hooks/Agents/useAgentFileConfig').default, + useLocalize: () => (key: string) => key, + useLazyEffect: () => {}, +})); + +const mockUseFileHandlingNoChatContext = jest.fn().mockReturnValue({ + handleFileChange: jest.fn(), +}); + +jest.mock('~/hooks/Files/useFileHandling', () => ({ + useFileHandlingNoChatContext: (...args: unknown[]) => mockUseFileHandlingNoChatContext(...args), +})); + +jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({ + useSharePointFileHandlingNoChatContext: () => ({ + handleSharePointFiles: jest.fn(), + isProcessing: false, + downloadProgress: 0, + }), +})); + +jest.mock('~/components/SharePoint', () => ({ + SharePointPickerDialog: () => null, +})); + +jest.mock('~/components/Chat/Input/Files/FileRow', () => () => null); +jest.mock('../FileSearchCheckbox', () => () => null); + +jest.mock('@ariakit/react', () => ({ + MenuButton: ({ children, ...props }: { children: React.ReactNode }) => ( + + ), +})); + +jest.mock('@librechat/client', () => ({ + SharePointIcon: () => , + AttachmentIcon: () => , + DropdownPopup: () => null, +})); + +function Wrapper({ provider, children }: { provider?: string; children: React.ReactNode }) { + const methods = useForm({ + defaultValues: { provider: provider as AgentForm['provider'] }, + }); + return {children}; +} + +describe('FileSearch', () => { + it('renders upload UI when file uploads are not disabled', () => { + mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + render( + + + , + ); + expect(screen.getByText('com_assistants_file_search')).toBeInTheDocument(); + }); + + it('returns null when file config is disabled for provider', () => { + mockFileConfig = mergeFileConfig({ + endpoints: { Moonshot: { disabled: true }, default: { fileLimit: 10 } }, + }); + const { container } = render( + + + , + ); + expect(container.innerHTML).toBe(''); + }); + + it('returns null when agents endpoint config is disabled and no provider config', () => { + mockFileConfig = mergeFileConfig({ + endpoints: { [EModelEndpoint.agents]: { disabled: true }, default: { fileLimit: 10 } }, + }); + const { container } = render( + + + , + ); + expect(container.innerHTML).toBe(''); + }); + + it('passes provider as endpointOverride and resolved type as endpointTypeOverride', () => { + mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + mockUseFileHandlingNoChatContext.mockClear(); + render( + + + , + ); + const params = mockUseFileHandlingNoChatContext.mock.calls[0][0]; + expect(params.endpointOverride).toBe('Moonshot'); + expect(params.endpointTypeOverride).toBe(EModelEndpoint.custom); + }); + + it('falls back to agents for endpointOverride when no provider', () => { + mockFileConfig = mergeFileConfig({ endpoints: { default: { fileLimit: 10 } } }); + mockUseFileHandlingNoChatContext.mockClear(); + render( + + + , + ); + const params = mockUseFileHandlingNoChatContext.mock.calls[0][0]; + expect(params.endpointOverride).toBe(EModelEndpoint.agents); + expect(params.endpointTypeOverride).toBe(EModelEndpoint.agents); + }); + + it('renders when provider has no specific config and agents config is enabled', () => { + mockFileConfig = mergeFileConfig({ + endpoints: { + [EModelEndpoint.agents]: { fileLimit: 20 }, + default: { fileLimit: 10 }, + }, + }); + render( + + + , + ); + expect(screen.getByText('com_assistants_file_search')).toBeInTheDocument(); + }); +}); diff --git a/client/src/hooks/Agents/index.ts b/client/src/hooks/Agents/index.ts index f75d045cc0..a553da24a0 100644 --- a/client/src/hooks/Agents/index.ts +++ b/client/src/hooks/Agents/index.ts @@ -5,6 +5,7 @@ export type { ProcessedAgentCategory } from './useAgentCategories'; export { default as useAgentCapabilities } from './useAgentCapabilities'; export { default as useGetAgentsConfig } from './useGetAgentsConfig'; export { default as useAgentDefaultPermissionLevel } from './useAgentDefaultPermissionLevel'; +export { default as useAgentFileConfig } from './useAgentFileConfig'; export { default as useAgentToolPermissions } from './useAgentToolPermissions'; export { default as useMCPToolOptions } from './useMCPToolOptions'; export * from './useApplyModelSpecAgents'; diff --git a/client/src/hooks/Agents/useAgentFileConfig.ts b/client/src/hooks/Agents/useAgentFileConfig.ts new file mode 100644 index 0000000000..7f98f8d575 --- /dev/null +++ b/client/src/hooks/Agents/useAgentFileConfig.ts @@ -0,0 +1,36 @@ +import { useWatch } from 'react-hook-form'; +import { + EModelEndpoint, + mergeFileConfig, + resolveEndpointType, + getEndpointFileConfig, +} from 'librechat-data-provider'; +import type { EndpointFileConfig } from 'librechat-data-provider'; +import type { AgentForm } from '~/common'; +import { useGetFileConfig, useGetEndpointsQuery } from '~/data-provider'; + +export default function useAgentFileConfig(): { + endpointType: EModelEndpoint | string | undefined; + providerValue: string | undefined; + endpointFileConfig: EndpointFileConfig; +} { + const providerOption = useWatch({ name: 'provider' }); + const { data: endpointsConfig } = useGetEndpointsQuery(); + const { data: fileConfig = null } = useGetFileConfig({ + select: (data) => mergeFileConfig(data), + }); + + const providerValue = + typeof providerOption === 'string' + ? providerOption + : (providerOption as { value?: string } | undefined)?.value; + + const endpointType = resolveEndpointType(endpointsConfig, EModelEndpoint.agents, providerValue); + const endpointFileConfig = getEndpointFileConfig({ + fileConfig, + endpointType, + endpoint: providerValue || EModelEndpoint.agents, + }); + + return { endpointType, providerValue, endpointFileConfig }; +} diff --git a/client/src/hooks/Files/useDragHelpers.ts b/client/src/hooks/Files/useDragHelpers.ts index f931da408c..7c6c3bd155 100644 --- a/client/src/hooks/Files/useDragHelpers.ts +++ b/client/src/hooks/Files/useDragHelpers.ts @@ -13,6 +13,7 @@ import { EModelEndpoint, mergeFileConfig, AgentCapabilities, + resolveEndpointType, isAssistantsEndpoint, getEndpointFileConfig, defaultAgentCapabilities, @@ -69,7 +70,19 @@ export default function useDragHelpers() { (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 endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); + + /** Get agent data from cache; if absent, provider-specific file config restrictions are bypassed client-side */ + const agentId = conversationRef.current?.agent_id; + const agent = agentId + ? queryClient.getQueryData([QueryKeys.agent, agentId]) + : undefined; + + const currentEndpointType = resolveEndpointType( + endpointsConfig, + currentEndpoint, + agent?.provider, + ); const cfg = queryClient.getQueryData([QueryKeys.fileConfig]); if (cfg) { const mergedCfg = mergeFileConfig(cfg); @@ -92,27 +105,21 @@ export default function useDragHelpers() { return; } - const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); const agentsConfig = endpointsConfig?.[EModelEndpoint.agents]; const capabilities = agentsConfig?.capabilities ?? defaultAgentCapabilities; const fileSearchEnabled = capabilities.includes(AgentCapabilities.file_search) === true; const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true; const contextEnabled = capabilities.includes(AgentCapabilities.context) === true; - /** Get agent permissions at drop time */ - const agentId = conversationRef.current?.agent_id; let fileSearchAllowedByAgent = true; let codeAllowedByAgent = true; if (agentId && !isEphemeralAgent(agentId)) { - /** Agent data from cache */ - const agent = queryClient.getQueryData([QueryKeys.agent, agentId]); if (agent) { const agentTools = agent.tools as string[] | undefined; fileSearchAllowedByAgent = agentTools?.includes(Tools.file_search) ?? false; codeAllowedByAgent = agentTools?.includes(Tools.execute_code) ?? false; } else { - /** If agent exists but not found, disallow */ fileSearchAllowedByAgent = false; codeAllowedByAgent = false; } diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index be62700651..635937a6fa 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -30,8 +30,10 @@ type UseFileHandling = { fileSetter?: FileSetter; fileFilter?: (file: File) => boolean; additionalMetadata?: Record; - /** Overrides both `endpoint` and `endpointType` for validation and upload routing */ - endpointOverride?: EModelEndpoint; + /** Overrides `endpoint` for upload routing; also used as `endpointType` fallback when `endpointTypeOverride` is not set */ + endpointOverride?: EModelEndpoint | string; + /** Overrides `endpointType` independently from `endpointOverride` */ + endpointTypeOverride?: EModelEndpoint | string; }; export type FileHandlingState = { @@ -64,9 +66,10 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil const agent_id = params?.additionalMetadata?.agent_id ?? ''; const assistant_id = params?.additionalMetadata?.assistant_id ?? ''; const endpointOverride = params?.endpointOverride; + const endpointTypeOverride = params?.endpointTypeOverride; const endpointType = useMemo( - () => endpointOverride ?? conversation?.endpointType, - [endpointOverride, conversation?.endpointType], + () => endpointTypeOverride ?? endpointOverride ?? conversation?.endpointType, + [endpointTypeOverride, endpointOverride, conversation?.endpointType], ); const endpoint = useMemo( () => endpointOverride ?? conversation?.endpoint ?? 'default', diff --git a/client/src/hooks/Files/useSharePointFileHandling.ts b/client/src/hooks/Files/useSharePointFileHandling.ts index a398bd594b..a04ef0104b 100644 --- a/client/src/hooks/Files/useSharePointFileHandling.ts +++ b/client/src/hooks/Files/useSharePointFileHandling.ts @@ -10,7 +10,8 @@ interface UseSharePointFileHandlingProps { toolResource?: string; fileFilter?: (file: File) => boolean; additionalMetadata?: Record; - endpointOverride?: EModelEndpoint; + endpointOverride?: EModelEndpoint | string; + endpointTypeOverride?: EModelEndpoint | string; } interface UseSharePointFileHandlingReturn { diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts new file mode 100644 index 0000000000..4197cb754e --- /dev/null +++ b/packages/data-provider/src/config.spec.ts @@ -0,0 +1,315 @@ +import type { TEndpointsConfig } from './types'; +import { EModelEndpoint, isDocumentSupportedProvider } from './schemas'; +import { getEndpointFileConfig, mergeFileConfig } from './file-config'; +import { resolveEndpointType } from './config'; + +const endpointsConfig: TEndpointsConfig = { + [EModelEndpoint.openAI]: { userProvide: false, order: 0 }, + [EModelEndpoint.agents]: { userProvide: false, order: 1 }, + [EModelEndpoint.anthropic]: { userProvide: false, order: 6 }, + [EModelEndpoint.bedrock]: { userProvide: false, order: 7 }, + Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, + 'Some Endpoint': { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, + Gemini: { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, +}; + +describe('resolveEndpointType', () => { + describe('non-agents endpoints', () => { + it('returns the config type for a custom endpoint', () => { + expect(resolveEndpointType(endpointsConfig, 'Moonshot')).toBe(EModelEndpoint.custom); + }); + + it('returns the config type for a custom endpoint with spaces', () => { + expect(resolveEndpointType(endpointsConfig, 'Some Endpoint')).toBe(EModelEndpoint.custom); + }); + + it('returns the endpoint itself for a standard endpoint without a type field', () => { + expect(resolveEndpointType(endpointsConfig, EModelEndpoint.openAI)).toBe( + EModelEndpoint.openAI, + ); + }); + + it('returns the endpoint itself for anthropic', () => { + expect(resolveEndpointType(endpointsConfig, EModelEndpoint.anthropic)).toBe( + EModelEndpoint.anthropic, + ); + }); + + it('ignores agentProvider when endpoint is not agents', () => { + expect(resolveEndpointType(endpointsConfig, EModelEndpoint.openAI, 'Moonshot')).toBe( + EModelEndpoint.openAI, + ); + }); + }); + + describe('agents endpoint with provider', () => { + it('resolves to custom for a custom agent provider', () => { + expect(resolveEndpointType(endpointsConfig, EModelEndpoint.agents, 'Moonshot')).toBe( + EModelEndpoint.custom, + ); + }); + + it('resolves to custom for a custom agent provider with spaces', () => { + expect(resolveEndpointType(endpointsConfig, EModelEndpoint.agents, 'Some Endpoint')).toBe( + EModelEndpoint.custom, + ); + }); + + it('returns the provider itself for a standard agent provider (no type field)', () => { + expect( + resolveEndpointType(endpointsConfig, EModelEndpoint.agents, EModelEndpoint.openAI), + ).toBe(EModelEndpoint.openAI); + }); + + it('returns bedrock for a bedrock agent provider', () => { + expect( + resolveEndpointType(endpointsConfig, EModelEndpoint.agents, EModelEndpoint.bedrock), + ).toBe(EModelEndpoint.bedrock); + }); + + it('returns the provider name when provider is not in endpointsConfig', () => { + expect(resolveEndpointType(endpointsConfig, EModelEndpoint.agents, 'UnknownProvider')).toBe( + 'UnknownProvider', + ); + }); + }); + + describe('agents endpoint without provider', () => { + it('falls back to agents when no provider', () => { + expect(resolveEndpointType(endpointsConfig, EModelEndpoint.agents)).toBe( + EModelEndpoint.agents, + ); + }); + + it('falls back to agents when provider is null', () => { + expect(resolveEndpointType(endpointsConfig, EModelEndpoint.agents, null)).toBe( + EModelEndpoint.agents, + ); + }); + + it('falls back to agents when provider is undefined', () => { + expect(resolveEndpointType(endpointsConfig, EModelEndpoint.agents, undefined)).toBe( + EModelEndpoint.agents, + ); + }); + }); + + describe('edge cases', () => { + it('returns undefined for null endpoint', () => { + expect(resolveEndpointType(endpointsConfig, null)).toBeUndefined(); + }); + + it('returns undefined for undefined endpoint', () => { + expect(resolveEndpointType(endpointsConfig, undefined)).toBeUndefined(); + }); + + it('handles null endpointsConfig', () => { + expect(resolveEndpointType(null, EModelEndpoint.agents, 'Moonshot')).toBe('Moonshot'); + }); + + it('handles undefined endpointsConfig', () => { + expect(resolveEndpointType(undefined, 'Moonshot')).toBe('Moonshot'); + }); + }); +}); + +describe('resolveEndpointType + getEndpointFileConfig integration', () => { + const fileConfig = mergeFileConfig({ + endpoints: { + Moonshot: { fileLimit: 5 }, + [EModelEndpoint.agents]: { fileLimit: 20 }, + default: { fileLimit: 10 }, + }, + }); + + it('agent with Moonshot provider uses Moonshot-specific config', () => { + const endpointType = resolveEndpointType(endpointsConfig, EModelEndpoint.agents, 'Moonshot'); + const config = getEndpointFileConfig({ + fileConfig, + endpointType, + endpoint: 'Moonshot', + }); + expect(config.fileLimit).toBe(5); + }); + + it('agent with provider not in fileConfig falls back through custom → agents', () => { + const endpointType = resolveEndpointType(endpointsConfig, EModelEndpoint.agents, 'Gemini'); + const config = getEndpointFileConfig({ + fileConfig, + endpointType, + endpoint: 'Gemini', + }); + expect(config.fileLimit).toBe(20); + }); + + it('agent without provider falls back to agents config', () => { + const endpointType = resolveEndpointType(endpointsConfig, EModelEndpoint.agents); + const config = getEndpointFileConfig({ + fileConfig, + endpointType, + endpoint: EModelEndpoint.agents, + }); + expect(config.fileLimit).toBe(20); + }); + + it('custom fallback is used when present and provider has no specific config', () => { + const fileConfigWithCustom = mergeFileConfig({ + endpoints: { + custom: { fileLimit: 15 }, + [EModelEndpoint.agents]: { fileLimit: 20 }, + default: { fileLimit: 10 }, + }, + }); + const endpointType = resolveEndpointType(endpointsConfig, EModelEndpoint.agents, 'Gemini'); + const config = getEndpointFileConfig({ + fileConfig: fileConfigWithCustom, + endpointType, + endpoint: 'Gemini', + }); + expect(config.fileLimit).toBe(15); + }); + + it('non-agents custom endpoint uses its specific config directly', () => { + const endpointType = resolveEndpointType(endpointsConfig, 'Moonshot'); + const config = getEndpointFileConfig({ + fileConfig, + endpointType, + endpoint: 'Moonshot', + }); + expect(config.fileLimit).toBe(5); + }); + + it('non-agents standard endpoint falls back to default when no specific config', () => { + const endpointType = resolveEndpointType(endpointsConfig, EModelEndpoint.openAI); + const config = getEndpointFileConfig({ + fileConfig, + endpointType, + endpoint: EModelEndpoint.openAI, + }); + expect(config.fileLimit).toBe(10); + }); +}); + +describe('resolveEndpointType + isDocumentSupportedProvider (upload menu)', () => { + it('agent with custom provider shows "Upload to Provider" (custom is document-supported)', () => { + const endpointType = resolveEndpointType(endpointsConfig, EModelEndpoint.agents, 'Moonshot'); + expect(isDocumentSupportedProvider(endpointType)).toBe(true); + }); + + it('agent with custom provider with spaces shows "Upload to Provider"', () => { + const endpointType = resolveEndpointType( + endpointsConfig, + EModelEndpoint.agents, + 'Some Endpoint', + ); + expect(isDocumentSupportedProvider(endpointType)).toBe(true); + }); + + it('agent without provider falls back to agents (not document-supported)', () => { + const endpointType = resolveEndpointType(endpointsConfig, EModelEndpoint.agents); + expect(isDocumentSupportedProvider(endpointType)).toBe(false); + }); + + it('agent with openAI provider is document-supported', () => { + const endpointType = resolveEndpointType( + endpointsConfig, + EModelEndpoint.agents, + EModelEndpoint.openAI, + ); + expect(isDocumentSupportedProvider(endpointType)).toBe(true); + }); + + it('agent with anthropic provider is document-supported', () => { + const endpointType = resolveEndpointType( + endpointsConfig, + EModelEndpoint.agents, + EModelEndpoint.anthropic, + ); + expect(isDocumentSupportedProvider(endpointType)).toBe(true); + }); + + it('agent with bedrock provider is document-supported', () => { + const endpointType = resolveEndpointType( + endpointsConfig, + EModelEndpoint.agents, + EModelEndpoint.bedrock, + ); + expect(isDocumentSupportedProvider(endpointType)).toBe(true); + }); + + it('direct custom endpoint (not agents) is document-supported', () => { + const endpointType = resolveEndpointType(endpointsConfig, 'Moonshot'); + expect(isDocumentSupportedProvider(endpointType)).toBe(true); + }); + + it('direct standard endpoint is document-supported', () => { + const endpointType = resolveEndpointType(endpointsConfig, EModelEndpoint.openAI); + expect(isDocumentSupportedProvider(endpointType)).toBe(true); + }); + + it('agent with unknown provider not in endpointsConfig is not document-supported', () => { + const endpointType = resolveEndpointType( + endpointsConfig, + EModelEndpoint.agents, + 'UnknownProvider', + ); + expect(isDocumentSupportedProvider(endpointType)).toBe(false); + }); + + it('same custom endpoint shows same result whether used directly or through agents', () => { + const directType = resolveEndpointType(endpointsConfig, 'Moonshot'); + const agentType = resolveEndpointType(endpointsConfig, EModelEndpoint.agents, 'Moonshot'); + expect(isDocumentSupportedProvider(directType)).toBe(isDocumentSupportedProvider(agentType)); + }); +}); + +describe('any custom endpoint is document-supported regardless of name', () => { + const arbitraryNames = [ + 'My LLM Gateway', + 'company-internal-api', + 'LiteLLM Proxy', + 'test_endpoint_123', + 'AI Studio', + 'ACME Corp', + 'localhost:8080', + ]; + + const configWithArbitraryEndpoints: TEndpointsConfig = { + [EModelEndpoint.agents]: { userProvide: false, order: 1 }, + ...Object.fromEntries( + arbitraryNames.map((name) => [ + name, + { type: EModelEndpoint.custom, userProvide: false, order: 9999 }, + ]), + ), + }; + + it.each(arbitraryNames)('direct custom endpoint "%s" is document-supported', (name) => { + const endpointType = resolveEndpointType(configWithArbitraryEndpoints, name); + expect(endpointType).toBe(EModelEndpoint.custom); + expect(isDocumentSupportedProvider(endpointType)).toBe(true); + }); + + it.each(arbitraryNames)('agent with custom provider "%s" is document-supported', (name) => { + const endpointType = resolveEndpointType( + configWithArbitraryEndpoints, + EModelEndpoint.agents, + name, + ); + expect(endpointType).toBe(EModelEndpoint.custom); + expect(isDocumentSupportedProvider(endpointType)).toBe(true); + }); + + it.each(arbitraryNames)( + '"%s" resolves the same whether used directly or through an agent', + (name) => { + const directType = resolveEndpointType(configWithArbitraryEndpoints, name); + const agentType = resolveEndpointType( + configWithArbitraryEndpoints, + EModelEndpoint.agents, + name, + ); + expect(directType).toBe(agentType); + }, + ); +}); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 6a77508f59..61be4b2116 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import type { ZodError } from 'zod'; import type { TEndpointsConfig, TModelsConfig, TConfig } from './types'; -import { EModelEndpoint, eModelEndpointSchema } from './schemas'; +import { EModelEndpoint, eModelEndpointSchema, isAgentsEndpoint } from './schemas'; import { specsConfigSchema, TSpecsConfig } from './models'; import { fileConfigSchema } from './file-config'; import { apiBaseUrl } from './api-endpoints'; @@ -1926,6 +1926,38 @@ export function getEndpointField< return config[property]; } +/** + * Resolves the effective endpoint type: + * - Non-agents endpoint: config.type || endpoint + * - Agents + provider: config[provider].type || provider + * - Agents, no provider: EModelEndpoint.agents + * + * Returns `undefined` when endpoint is null/undefined. + */ +export function resolveEndpointType( + endpointsConfig: TEndpointsConfig | undefined | null, + endpoint: string | null | undefined, + agentProvider?: string | null, +): EModelEndpoint | string | undefined { + if (!endpoint) { + return undefined; + } + + if (!isAgentsEndpoint(endpoint)) { + return getEndpointField(endpointsConfig, endpoint, 'type') || endpoint; + } + + if (agentProvider) { + const providerType = getEndpointField(endpointsConfig, agentProvider, 'type'); + if (providerType) { + return providerType; + } + return agentProvider; + } + + return EModelEndpoint.agents; +} + /** Resolves the `defaultParamsEndpoint` for a given endpoint from its custom params config */ export function getDefaultParamsEndpoint( endpointsConfig: TEndpointsConfig | undefined | null,