From 2ac62a2e71376163eb1718676ee7d9c405470696 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 7 Mar 2026 10:45:43 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9B=B5=20fix:=20Resolve=20Agent=20Provider?= =?UTF-8?q?=20Endpoint=20Type=20for=20File=20Upload=20Support=20(#12117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Remove unused setValueOnChange prop from MCPServerMenuItem component * fix: Resolve agent provider endpoint type for file upload support When using the agents endpoint with a custom provider (e.g., Moonshot), the endpointType was resolving to "agents" instead of the provider's actual type ("custom"), causing "Upload to Provider" to not appear in the file attach menu. Adds `resolveEndpointType` utility in data-provider that follows the chain: endpoint (if not agents) → agent.provider → agents. Applied consistently across AttachFileChat, DragDropContext, useDragHelpers, and AgentPanel file components (FileContext, FileSearch, Code/Files). * refactor: Extract useAgentFileConfig hook, restore deleted tests, fix review findings - Extract shared provider resolution logic into useAgentFileConfig hook (Finding #2: DRY violation across FileContext, FileSearch, Code/Files) - Restore 18 deleted test cases in AttachFileMenu.spec.tsx covering agent capabilities, SharePoint, edge cases, and button state (Finding #1: accidental test deletion) - Wrap fileConfigEndpoint in useMemo in AttachFileChat (Finding #3) - Fix misleading test name in AgentFileConfig.spec.tsx (Finding #4) - Fix import order in FileSearch.tsx, FileContext.tsx, Code/Files.tsx (Finding #5) - Add comment about cache gap in useDragHelpers (Finding #6) - Clarify resolveEndpointType JSDoc (Finding #7) * refactor: Memoize Footer component for performance optimization - Converted Footer component to a memoized version to prevent unnecessary re-renders. - Improved import structure by adding memo to the React import statement for clarity. * chore: Fix remaining review nits - Widen useAgentFileConfig return type to EModelEndpoint | string - Fix import order in FileContext.tsx and FileSearch.tsx - Remove dead endpointType param from setupMocks in AttachFileMenu test * fix: Pass resolved provider endpoint to file upload validation AgentPanel file components (FileContext, FileSearch, Code/Files) were hardcoding endpointOverride to "agents", causing both client-side validation (file limits, MIME types) and server-side validation to use the agents config instead of the provider-specific config. Adds endpointTypeOverride to UseFileHandling params so endpoint and endpointType can be set independently. Components now pass the resolved provider name and type from useAgentFileConfig, so the full fallback chain (provider → custom → agents → default) applies to file upload validation on both client and server. * test: Verify any custom endpoint is document-supported regardless of name Adds parameterized tests with arbitrary endpoint names (spaces, hyphens, colons, etc.) confirming that all custom endpoints resolve to document-supported through resolveEndpointType, both as direct endpoints and as agent providers. * fix: Use || for provider fallback, test endpointOverride wiring - Change providerValue ?? to providerValue || so empty string is treated as "no provider" consistently with resolveEndpointType - Add wiring tests to CodeFiles, FileContext, FileSearch verifying endpointOverride and endpointTypeOverride are passed correctly - Update endpointOverride JSDoc to document endpointType fallback --- client/src/Providers/DragDropContext.tsx | 25 +- .../__tests__/DragDropContext.spec.tsx | 134 ++++ client/src/components/Chat/Footer.tsx | 11 +- .../Chat/Input/Files/AttachFileChat.tsx | 29 +- .../Chat/Input/Files/AttachFileMenu.tsx | 2 +- .../Files/__tests__/AttachFileChat.spec.tsx | 176 +++++ .../Files/__tests__/AttachFileMenu.spec.tsx | 675 +++++------------- .../src/components/MCP/MCPServerMenuItem.tsx | 1 - .../SidePanel/Agents/Code/Files.tsx | 25 +- .../SidePanel/Agents/FileContext.tsx | 35 +- .../SidePanel/Agents/FileSearch.tsx | 34 +- .../Agents/__tests__/AgentFileConfig.spec.tsx | 151 ++++ .../Agents/__tests__/CodeFiles.spec.tsx | 138 ++++ .../Agents/__tests__/FileContext.spec.tsx | 151 ++++ .../Agents/__tests__/FileSearch.spec.tsx | 147 ++++ client/src/hooks/Agents/index.ts | 1 + client/src/hooks/Agents/useAgentFileConfig.ts | 36 + client/src/hooks/Files/useDragHelpers.ts | 21 +- client/src/hooks/Files/useFileHandling.ts | 11 +- .../hooks/Files/useSharePointFileHandling.ts | 3 +- packages/data-provider/src/config.spec.ts | 315 ++++++++ packages/data-provider/src/config.ts | 34 +- 22 files changed, 1573 insertions(+), 582 deletions(-) create mode 100644 client/src/Providers/__tests__/DragDropContext.spec.tsx create mode 100644 client/src/components/Chat/Input/Files/__tests__/AttachFileChat.spec.tsx create mode 100644 client/src/components/SidePanel/Agents/__tests__/AgentFileConfig.spec.tsx create mode 100644 client/src/components/SidePanel/Agents/__tests__/CodeFiles.spec.tsx create mode 100644 client/src/components/SidePanel/Agents/__tests__/FileContext.spec.tsx create mode 100644 client/src/components/SidePanel/Agents/__tests__/FileSearch.spec.tsx create mode 100644 client/src/hooks/Agents/useAgentFileConfig.ts create mode 100644 packages/data-provider/src/config.spec.ts 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,