fix: Resolve Agent Provider Endpoint Type for File Upload Support (#12117)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* 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
This commit is contained in:
Danny Avila 2026-03-07 10:45:43 -05:00 committed by GitHub
parent cfaa6337c1
commit 2ac62a2e71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1573 additions and 582 deletions

View file

@ -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);
},
);
});

View file

@ -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,