Skip to content

Commit 2ac62a2

Browse files
authored
⛵ fix: Resolve Agent Provider Endpoint Type for File Upload Support (danny-avila#12117)
* 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 danny-avila#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 danny-avila#3) - Fix misleading test name in AgentFileConfig.spec.tsx (Finding danny-avila#4) - Fix import order in FileSearch.tsx, FileContext.tsx, Code/Files.tsx (Finding danny-avila#5) - Add comment about cache gap in useDragHelpers (Finding danny-avila#6) - Clarify resolveEndpointType JSDoc (Finding danny-avila#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
1 parent cfaa633 commit 2ac62a2

22 files changed

Lines changed: 1572 additions & 581 deletions

client/src/Providers/DragDropContext.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { createContext, useContext, useMemo } from 'react';
2-
import { getEndpointField, isAgentsEndpoint } from 'librechat-data-provider';
2+
import { isAgentsEndpoint, resolveEndpointType } from 'librechat-data-provider';
33
import type { EModelEndpoint } from 'librechat-data-provider';
44
import { useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider';
55
import { useAgentsMapContext } from './AgentsMapContext';
@@ -9,7 +9,7 @@ interface DragDropContextValue {
99
conversationId: string | null | undefined;
1010
agentId: string | null | undefined;
1111
endpoint: string | null | undefined;
12-
endpointType?: EModelEndpoint | undefined;
12+
endpointType?: EModelEndpoint | string | undefined;
1313
useResponsesApi?: boolean;
1414
}
1515

@@ -20,13 +20,6 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
2020
const { data: endpointsConfig } = useGetEndpointsQuery();
2121
const agentsMap = useAgentsMapContext();
2222

23-
const endpointType = useMemo(() => {
24-
return (
25-
getEndpointField(endpointsConfig, conversation?.endpoint, 'type') ||
26-
(conversation?.endpoint as EModelEndpoint | undefined)
27-
);
28-
}, [conversation?.endpoint, endpointsConfig]);
29-
3023
const needsAgentFetch = useMemo(() => {
3124
const isAgents = isAgentsEndpoint(conversation?.endpoint);
3225
if (!isAgents || !conversation?.agent_id) {
@@ -40,6 +33,20 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
4033
enabled: needsAgentFetch,
4134
});
4235

36+
const agentProvider = useMemo(() => {
37+
const isAgents = isAgentsEndpoint(conversation?.endpoint);
38+
if (!isAgents || !conversation?.agent_id) {
39+
return undefined;
40+
}
41+
const agent = agentData || agentsMap?.[conversation.agent_id];
42+
return agent?.provider;
43+
}, [conversation?.endpoint, conversation?.agent_id, agentData, agentsMap]);
44+
45+
const endpointType = useMemo(
46+
() => resolveEndpointType(endpointsConfig, conversation?.endpoint, agentProvider),
47+
[endpointsConfig, conversation?.endpoint, agentProvider],
48+
);
49+
4350
const useResponsesApi = useMemo(() => {
4451
const isAgents = isAgentsEndpoint(conversation?.endpoint);
4552
if (!isAgents || !conversation?.agent_id || conversation?.useResponsesApi) {
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import React from 'react';
2+
import { renderHook } from '@testing-library/react';
3+
import { EModelEndpoint } from 'librechat-data-provider';
4+
import type { TEndpointsConfig, Agent } from 'librechat-data-provider';
5+
import { DragDropProvider, useDragDropContext } from '../DragDropContext';
6+
7+
const mockEndpointsConfig: TEndpointsConfig = {
8+
[EModelEndpoint.openAI]: { userProvide: false, order: 0 },
9+
[EModelEndpoint.agents]: { userProvide: false, order: 1 },
10+
[EModelEndpoint.anthropic]: { userProvide: false, order: 6 },
11+
Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
12+
'Some Endpoint': { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
13+
};
14+
15+
let mockConversation: Record<string, unknown> | null = null;
16+
let mockAgentsMap: Record<string, Partial<Agent>> = {};
17+
let mockAgentQueryData: Partial<Agent> | undefined;
18+
19+
jest.mock('~/data-provider', () => ({
20+
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
21+
useGetAgentByIdQuery: () => ({ data: mockAgentQueryData }),
22+
}));
23+
24+
jest.mock('../AgentsMapContext', () => ({
25+
useAgentsMapContext: () => mockAgentsMap,
26+
}));
27+
28+
jest.mock('../ChatContext', () => ({
29+
useChatContext: () => ({ conversation: mockConversation }),
30+
}));
31+
32+
function wrapper({ children }: { children: React.ReactNode }) {
33+
return <DragDropProvider>{children}</DragDropProvider>;
34+
}
35+
36+
describe('DragDropContext endpointType resolution', () => {
37+
beforeEach(() => {
38+
mockConversation = null;
39+
mockAgentsMap = {};
40+
mockAgentQueryData = undefined;
41+
});
42+
43+
describe('non-agents endpoints', () => {
44+
it('resolves custom endpoint type for a custom endpoint', () => {
45+
mockConversation = { endpoint: 'Moonshot' };
46+
const { result } = renderHook(() => useDragDropContext(), { wrapper });
47+
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
48+
});
49+
50+
it('resolves endpoint name for a standard endpoint', () => {
51+
mockConversation = { endpoint: EModelEndpoint.openAI };
52+
const { result } = renderHook(() => useDragDropContext(), { wrapper });
53+
expect(result.current.endpointType).toBe(EModelEndpoint.openAI);
54+
});
55+
});
56+
57+
describe('agents endpoint with provider from agentsMap', () => {
58+
it('resolves to custom for agent with Moonshot provider', () => {
59+
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
60+
mockAgentsMap = {
61+
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
62+
};
63+
const { result } = renderHook(() => useDragDropContext(), { wrapper });
64+
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
65+
});
66+
67+
it('resolves to custom for agent with custom provider with spaces', () => {
68+
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
69+
mockAgentsMap = {
70+
'agent-1': { provider: 'Some Endpoint', model_parameters: {} } as Partial<Agent>,
71+
};
72+
const { result } = renderHook(() => useDragDropContext(), { wrapper });
73+
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
74+
});
75+
76+
it('resolves to openAI for agent with openAI provider', () => {
77+
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
78+
mockAgentsMap = {
79+
'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial<Agent>,
80+
};
81+
const { result } = renderHook(() => useDragDropContext(), { wrapper });
82+
expect(result.current.endpointType).toBe(EModelEndpoint.openAI);
83+
});
84+
85+
it('resolves to anthropic for agent with anthropic provider', () => {
86+
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
87+
mockAgentsMap = {
88+
'agent-1': { provider: EModelEndpoint.anthropic, model_parameters: {} } as Partial<Agent>,
89+
};
90+
const { result } = renderHook(() => useDragDropContext(), { wrapper });
91+
expect(result.current.endpointType).toBe(EModelEndpoint.anthropic);
92+
});
93+
});
94+
95+
describe('agents endpoint with provider from agentData query', () => {
96+
it('uses agentData when agent is not in agentsMap', () => {
97+
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-2' };
98+
mockAgentsMap = {};
99+
mockAgentQueryData = { provider: 'Moonshot' } as Partial<Agent>;
100+
const { result } = renderHook(() => useDragDropContext(), { wrapper });
101+
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
102+
});
103+
});
104+
105+
describe('agents endpoint without provider', () => {
106+
it('falls back to agents when no agent_id', () => {
107+
mockConversation = { endpoint: EModelEndpoint.agents };
108+
const { result } = renderHook(() => useDragDropContext(), { wrapper });
109+
expect(result.current.endpointType).toBe(EModelEndpoint.agents);
110+
});
111+
112+
it('falls back to agents when agent has no provider', () => {
113+
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
114+
mockAgentsMap = { 'agent-1': { model_parameters: {} } as Partial<Agent> };
115+
const { result } = renderHook(() => useDragDropContext(), { wrapper });
116+
expect(result.current.endpointType).toBe(EModelEndpoint.agents);
117+
});
118+
});
119+
120+
describe('consistency: same endpoint type whether used directly or through agents', () => {
121+
it('Moonshot resolves to the same type as direct endpoint and as agent provider', () => {
122+
mockConversation = { endpoint: 'Moonshot' };
123+
const { result: directResult } = renderHook(() => useDragDropContext(), { wrapper });
124+
125+
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
126+
mockAgentsMap = {
127+
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
128+
};
129+
const { result: agentResult } = renderHook(() => useDragDropContext(), { wrapper });
130+
131+
expect(directResult.current.endpointType).toBe(agentResult.current.endpointType);
132+
});
133+
});
134+
});

client/src/components/Chat/Footer.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import React, { useEffect } from 'react';
2-
import ReactMarkdown from 'react-markdown';
1+
import React, { useEffect, memo } from 'react';
32
import TagManager from 'react-gtm-module';
3+
import ReactMarkdown from 'react-markdown';
44
import { Constants } from 'librechat-data-provider';
55
import { useGetStartupConfig } from '~/data-provider';
66
import { useLocalize } from '~/hooks';
77

8-
export default function Footer({ className }: { className?: string }) {
8+
function Footer({ className }: { className?: string }) {
99
const { data: config } = useGetStartupConfig();
1010
const localize = useLocalize();
1111

@@ -98,3 +98,8 @@ export default function Footer({ className }: { className?: string }) {
9898
</div>
9999
);
100100
}
101+
102+
const MemoizedFooter = memo(Footer);
103+
MemoizedFooter.displayName = 'Footer';
104+
105+
export default MemoizedFooter;

client/src/components/Chat/Input/Files/AttachFileChat.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import { memo, useMemo } from 'react';
22
import {
33
Constants,
44
supportsFiles,
5-
EModelEndpoint,
65
mergeFileConfig,
76
isAgentsEndpoint,
8-
getEndpointField,
7+
resolveEndpointType,
98
isAssistantsEndpoint,
109
getEndpointFileConfig,
1110
} from 'librechat-data-provider';
@@ -55,21 +54,31 @@ function AttachFileChat({
5554

5655
const { data: endpointsConfig } = useGetEndpointsQuery();
5756

58-
const endpointType = useMemo(() => {
59-
return (
60-
getEndpointField(endpointsConfig, endpoint, 'type') ||
61-
(endpoint as EModelEndpoint | undefined)
62-
);
63-
}, [endpoint, endpointsConfig]);
57+
const agentProvider = useMemo(() => {
58+
if (!isAgents || !conversation?.agent_id) {
59+
return undefined;
60+
}
61+
const agent = agentData || agentsMap?.[conversation.agent_id];
62+
return agent?.provider;
63+
}, [isAgents, conversation?.agent_id, agentData, agentsMap]);
64+
65+
const endpointType = useMemo(
66+
() => resolveEndpointType(endpointsConfig, endpoint, agentProvider),
67+
[endpointsConfig, endpoint, agentProvider],
68+
);
6469

70+
const fileConfigEndpoint = useMemo(
71+
() => (isAgents && agentProvider ? agentProvider : endpoint),
72+
[isAgents, agentProvider, endpoint],
73+
);
6574
const endpointFileConfig = useMemo(
6675
() =>
6776
getEndpointFileConfig({
68-
endpoint,
6977
fileConfig,
7078
endpointType,
79+
endpoint: fileConfigEndpoint,
7180
}),
72-
[endpoint, fileConfig, endpointType],
81+
[fileConfigEndpoint, fileConfig, endpointType],
7382
);
7483
const endpointSupportsFiles: boolean = useMemo(
7584
() => supportsFiles[endpointType ?? endpoint ?? ''] ?? false,

client/src/components/Chat/Input/Files/AttachFileMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ interface AttachFileMenuProps {
5050
endpoint?: string | null;
5151
disabled?: boolean | null;
5252
conversationId: string;
53-
endpointType?: EModelEndpoint;
53+
endpointType?: EModelEndpoint | string;
5454
endpointFileConfig?: EndpointFileConfig;
5555
useResponsesApi?: boolean;
5656
}

0 commit comments

Comments
 (0)