Merge branch 'main' into docs/azure-instance-name-clarification

This commit is contained in:
Rakshit 2025-11-05 21:37:57 +05:30 committed by GitHub
commit af4520df29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1467 additions and 11 deletions

View file

@ -254,6 +254,10 @@ AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
# OpenAI Image Tools Customization
#----------------
# IMAGE_GEN_OAI_API_KEY= # Create or reuse OpenAI API key for image generation tool
# IMAGE_GEN_OAI_BASEURL= # Custom OpenAI base URL for image generation tool
# IMAGE_GEN_OAI_AZURE_API_VERSION= # Custom Azure OpenAI deployments
# IMAGE_GEN_OAI_DESCRIPTION=
# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present
# IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present
# IMAGE_EDIT_OAI_DESCRIPTION=Custom description for image editing tool

View file

@ -227,7 +227,6 @@ class STTService {
}
const headers = {
'Content-Type': 'multipart/form-data',
...(apiKey && { 'api-key': apiKey }),
};

View file

@ -304,6 +304,7 @@ describe('Apple Login Strategy', () => {
fileStrategy: 'local',
balance: { enabled: false },
}),
'jane.doe@example.com',
);
});

View file

@ -5,22 +5,25 @@ const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { updateUser, createUser, getUserById } = require('~/models');
/**
* Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter
* Updates the avatar URL and email of an existing user. If the user's avatar URL does not include the query parameter
* '?manual=true', it updates the user's avatar with the provided URL. For local file storage, it directly updates
* the avatar URL, while for other storage types, it processes the avatar URL using the specified file strategy.
* Also updates the email if it has changed (e.g., when a Google Workspace email is updated).
*
* @param {IUser} oldUser - The existing user object that needs to be updated.
* @param {string} avatarUrl - The new avatar URL to be set for the user.
* @param {AppConfig} appConfig - The application configuration object.
* @param {string} [email] - Optional. The new email address to update if it has changed.
*
* @returns {Promise<void>}
* The function updates the user's avatar and saves the user object. It does not return any value.
* The function updates the user's avatar and/or email and saves the user object. It does not return any value.
*
* @throws {Error} Throws an error if there's an issue saving the updated user object.
*/
const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
const handleExistingUser = async (oldUser, avatarUrl, appConfig, email) => {
const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
const isLocal = fileStrategy === FileSources.local;
const updates = {};
let updatedAvatar = false;
const hasManualFlag =
@ -39,7 +42,16 @@ const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
}
if (updatedAvatar) {
await updateUser(oldUser._id, { avatar: updatedAvatar });
updates.avatar = updatedAvatar;
}
/** Update email if it has changed */
if (email && email.trim() !== oldUser.email) {
updates.email = email.trim();
}
if (Object.keys(updates).length > 0) {
await updateUser(oldUser._id, updates);
}
};

View file

@ -167,4 +167,76 @@ describe('handleExistingUser', () => {
// This should throw an error when trying to access oldUser._id
await expect(handleExistingUser(null, avatarUrl)).rejects.toThrow();
});
it('should update email when it has changed', async () => {
const oldUser = {
_id: 'user123',
email: 'old@example.com',
avatar: 'https://example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/avatar.png';
const newEmail = 'new@example.com';
await handleExistingUser(oldUser, avatarUrl, {}, newEmail);
expect(updateUser).toHaveBeenCalledWith('user123', { email: 'new@example.com' });
});
it('should update both avatar and email when both have changed', async () => {
const oldUser = {
_id: 'user123',
email: 'old@example.com',
avatar: null,
};
const avatarUrl = 'https://example.com/new-avatar.png';
const newEmail = 'new@example.com';
await handleExistingUser(oldUser, avatarUrl, {}, newEmail);
expect(updateUser).toHaveBeenCalledWith('user123', {
avatar: avatarUrl,
email: 'new@example.com',
});
});
it('should not update email when it has not changed', async () => {
const oldUser = {
_id: 'user123',
email: 'same@example.com',
avatar: 'https://example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/avatar.png';
const sameEmail = 'same@example.com';
await handleExistingUser(oldUser, avatarUrl, {}, sameEmail);
expect(updateUser).not.toHaveBeenCalled();
});
it('should trim email before comparison and update', async () => {
const oldUser = {
_id: 'user123',
email: 'test@example.com',
avatar: 'https://example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/avatar.png';
const newEmailWithSpaces = ' newemail@example.com ';
await handleExistingUser(oldUser, avatarUrl, {}, newEmailWithSpaces);
expect(updateUser).toHaveBeenCalledWith('user123', { email: 'newemail@example.com' });
});
it('should not update when email parameter is not provided', async () => {
const oldUser = {
_id: 'user123',
email: 'test@example.com',
avatar: 'https://example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/avatar.png';
await handleExistingUser(oldUser, avatarUrl, {});
expect(updateUser).not.toHaveBeenCalled();
});
});

View file

@ -25,10 +25,24 @@ const socialLogin =
return cb(error);
}
const existingUser = await findUser({ email: email.trim() });
const providerKey = `${provider}Id`;
let existingUser = null;
/** First try to find user by provider ID (e.g., googleId, facebookId) */
if (id && typeof id === 'string') {
existingUser = await findUser({ [providerKey]: id });
}
/** If not found by provider ID, try finding by email */
if (!existingUser) {
existingUser = await findUser({ email: email?.trim() });
if (existingUser) {
logger.warn(`[${provider}Login] User found by email: ${email} but not by ${providerKey}`);
}
}
if (existingUser?.provider === provider) {
await handleExistingUser(existingUser, avatarUrl, appConfig);
await handleExistingUser(existingUser, avatarUrl, appConfig, email);
return cb(null, existingUser);
} else if (existingUser) {
logger.info(

View file

@ -0,0 +1,276 @@
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const { createSocialUser, handleExistingUser } = require('./process');
const socialLogin = require('./socialLogin');
const { findUser } = require('~/models');
jest.mock('@librechat/data-schemas', () => {
const actualModule = jest.requireActual('@librechat/data-schemas');
return {
...actualModule,
logger: {
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
},
};
});
jest.mock('./process', () => ({
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn().mockReturnValue(true),
isEmailDomainAllowed: jest.fn().mockReturnValue(true),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({
fileStrategy: 'local',
balance: { enabled: false },
}),
}));
describe('socialLogin', () => {
const mockGetProfileDetails = ({ profile }) => ({
email: profile.emails[0].value,
id: profile.id,
avatarUrl: profile.photos?.[0]?.value || null,
username: profile.name?.givenName || 'user',
name: `${profile.name?.givenName || ''} ${profile.name?.familyName || ''}`.trim(),
emailVerified: profile.emails[0].verified || false,
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('Finding users by provider ID', () => {
it('should find user by provider ID (googleId) when email has changed', async () => {
const provider = 'google';
const googleId = 'google-user-123';
const oldEmail = 'old@example.com';
const newEmail = 'new@example.com';
const existingUser = {
_id: 'user123',
email: oldEmail,
provider: 'google',
googleId: googleId,
};
/** Mock findUser to return user on first call (by googleId), null on second call */
findUser
.mockResolvedValueOnce(existingUser) // First call: finds by googleId
.mockResolvedValueOnce(null); // Second call would be by email, but won't be reached
const mockProfile = {
id: googleId,
emails: [{ value: newEmail, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'John', familyName: 'Doe' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify it searched by googleId first */
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
/** Verify it did NOT search by email (because it found user by googleId) */
expect(findUser).toHaveBeenCalledTimes(1);
/** Verify handleExistingUser was called with the new email */
expect(handleExistingUser).toHaveBeenCalledWith(
existingUser,
'https://example.com/avatar.png',
expect.any(Object),
newEmail,
);
/** Verify callback was called with success */
expect(callback).toHaveBeenCalledWith(null, existingUser);
});
it('should find user by provider ID (facebookId) when using Facebook', async () => {
const provider = 'facebook';
const facebookId = 'fb-user-456';
const email = 'user@example.com';
const existingUser = {
_id: 'user456',
email: email,
provider: 'facebook',
facebookId: facebookId,
};
findUser.mockResolvedValue(existingUser); // Always returns user
const mockProfile = {
id: facebookId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/fb-avatar.png' }],
name: { givenName: 'Jane', familyName: 'Smith' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify it searched by facebookId first */
expect(findUser).toHaveBeenCalledWith({ facebookId: facebookId });
expect(findUser.mock.calls[0]).toEqual([{ facebookId: facebookId }]);
expect(handleExistingUser).toHaveBeenCalledWith(
existingUser,
'https://example.com/fb-avatar.png',
expect.any(Object),
email,
);
expect(callback).toHaveBeenCalledWith(null, existingUser);
});
it('should fallback to finding user by email if not found by provider ID', async () => {
const provider = 'google';
const googleId = 'google-user-789';
const email = 'user@example.com';
const existingUser = {
_id: 'user789',
email: email,
provider: 'google',
googleId: 'old-google-id', // Different googleId (edge case)
};
/** First call (by googleId) returns null, second call (by email) returns user */
findUser
.mockResolvedValueOnce(null) // By googleId
.mockResolvedValueOnce(existingUser); // By email
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'Bob', familyName: 'Johnson' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify both searches happened */
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
expect(findUser).toHaveBeenCalledTimes(2);
/** Verify warning log */
expect(logger.warn).toHaveBeenCalledWith(
`[${provider}Login] User found by email: ${email} but not by ${provider}Id`,
);
expect(handleExistingUser).toHaveBeenCalled();
expect(callback).toHaveBeenCalledWith(null, existingUser);
});
it('should create new user if not found by provider ID or email', async () => {
const provider = 'google';
const googleId = 'google-new-user';
const email = 'newuser@example.com';
const newUser = {
_id: 'newuser123',
email: email,
provider: 'google',
googleId: googleId,
};
/** Both searches return null */
findUser.mockResolvedValue(null);
createSocialUser.mockResolvedValue(newUser);
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'New', familyName: 'User' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify both searches happened */
expect(findUser).toHaveBeenCalledTimes(2);
/** Verify createSocialUser was called */
expect(createSocialUser).toHaveBeenCalledWith({
email: email,
avatarUrl: 'https://example.com/avatar.png',
provider: provider,
providerKey: 'googleId',
providerId: googleId,
username: 'New',
name: 'New User',
emailVerified: true,
appConfig: expect.any(Object),
});
expect(callback).toHaveBeenCalledWith(null, newUser);
});
});
describe('Error handling', () => {
it('should return error if user exists with different provider', async () => {
const provider = 'google';
const googleId = 'google-user-123';
const email = 'user@example.com';
const existingUser = {
_id: 'user123',
email: email,
provider: 'local', // Different provider
};
findUser
.mockResolvedValueOnce(null) // By googleId
.mockResolvedValueOnce(existingUser); // By email
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'John', familyName: 'Doe' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify error callback */
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
code: ErrorTypes.AUTH_FAILED,
provider: 'local',
}),
);
expect(logger.info).toHaveBeenCalledWith(
`[${provider}Login] User ${email} already exists with provider local`,
);
});
});
});

View file

@ -117,8 +117,10 @@ const AttachFileMenu = ({
const items: MenuItemProps[] = [];
const currentProvider = provider || endpoint;
if (isDocumentSupportedProvider(currentProvider || endpointType)) {
if (
isDocumentSupportedProvider(endpointType) ||
isDocumentSupportedProvider(currentProvider)
) {
items.push({
label: localize('com_ui_upload_provider'),
onClick: () => {

View file

@ -57,7 +57,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
const currentProvider = provider || endpoint;
// Check if provider supports document upload
if (isDocumentSupportedProvider(currentProvider || endpointType)) {
if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) {
const isGoogleProvider = currentProvider === EModelEndpoint.google;
const validFileTypes = isGoogleProvider
? files.every(

View file

@ -133,7 +133,7 @@ export default function FileRow({
>
{isImage ? (
<Image
url={file.preview ?? file.filepath}
url={file.progress === 1 ? file.filepath : (file.preview ?? file.filepath)}
onDelete={handleDelete}
progress={file.progress}
source={file.source}

View file

@ -0,0 +1,602 @@
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 AttachFileMenu from '../AttachFileMenu';
// Mock all the hooks
jest.mock('~/hooks', () => ({
useAgentToolPermissions: jest.fn(),
useAgentCapabilities: jest.fn(),
useGetAgentsConfig: jest.fn(),
useFileHandling: jest.fn(),
useLocalize: jest.fn(),
}));
jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('~/data-provider', () => ({
useGetStartupConfig: jest.fn(),
}));
jest.mock('~/components/SharePoint', () => ({
SharePointPickerDialog: jest.fn(() => null),
}));
jest.mock('@librechat/client', () => {
const React = jest.requireActual('react');
return {
FileUpload: React.forwardRef(({ children, handleFileChange }: any, ref: any) => (
<div data-testid="file-upload">
<input ref={ref} type="file" onChange={handleFileChange} data-testid="file-input" />
{children}
</div>
)),
TooltipAnchor: ({ render }: any) => render,
DropdownPopup: ({ trigger, items, isOpen, setIsOpen }: any) => {
const handleTriggerClick = () => {
if (setIsOpen) {
setIsOpen(!isOpen);
}
};
return (
<div>
<div onClick={handleTriggerClick}>{trigger}</div>
{isOpen && (
<div data-testid="dropdown-menu">
{items.map((item: any, idx: number) => (
<button key={idx} onClick={item.onClick} data-testid={`menu-item-${idx}`}>
{item.label}
</button>
))}
</div>
)}
</div>
);
},
AttachmentIcon: () => <span data-testid="attachment-icon">📎</span>,
SharePointIcon: () => <span data-testid="sharepoint-icon">SP</span>,
};
});
jest.mock('@ariakit/react', () => ({
MenuButton: ({ children, onClick, disabled, ...props }: any) => (
<button onClick={onClick} disabled={disabled} {...props}>
{children}
</button>
),
}));
const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions;
const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities;
const mockUseGetAgentsConfig = jest.requireMock('~/hooks').useGetAgentsConfig;
const mockUseFileHandling = jest.requireMock('~/hooks').useFileHandling;
const mockUseLocalize = jest.requireMock('~/hooks').useLocalize;
const mockUseSharePointFileHandling = jest.requireMock(
'~/hooks/Files/useSharePointFileHandling',
).default;
const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig;
describe('AttachFileMenu', () => {
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<string, string> = {
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(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<AttachFileMenu conversationId="test-conversation" {...props} />
</RecoilRoot>
</QueryClientProvider>,
);
};
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();
});
});
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
});
renderAttachFileMenu({
endpoint: 'litellm',
endpointType: EModelEndpoint.openAI, // Backend override IS in documentSupportedProviders
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
// With the fix, should show "Upload to Provider" because endpointType is checked first
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);
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',
});
renderAttachFileMenu({
endpoint: 'unsupported-provider',
endpointType: 'unsupported-endpoint' as any,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
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);
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();
});
});
describe('Supported Providers', () => {
const supportedProviders = [
{ name: 'OpenAI', endpoint: EModelEndpoint.openAI },
{ name: 'Anthropic', endpoint: EModelEndpoint.anthropic },
{ name: 'Google', endpoint: EModelEndpoint.google },
{ name: 'Azure OpenAI', endpoint: EModelEndpoint.azureOpenAI },
{ 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 Capabilities', () => {
it('should show OCR Text option when context is enabled', () => {
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();
});
it('should show File Search option when enabled and allowed by agent', () => {
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);
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
});
it('should NOT show File Search when enabled but not allowed by agent', () => {
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);
expect(screen.queryByText('Upload for File Search')).not.toBeInTheDocument();
});
it('should show Code Files option when enabled and allowed by agent', () => {
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);
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
});
it('should show all options when all capabilities are enabled', () => {
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);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
expect(screen.getByText('Upload OCR 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', () => {
mockUseGetStartupConfig.mockReturnValue({
data: {
sharePointFilePickerEnabled: true,
},
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
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);
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,
});
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('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('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 google_multimodal 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 multimodal 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 - multimodal 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();
});
});
});

View file

@ -0,0 +1,121 @@
import { EModelEndpoint, isDocumentSupportedProvider } from 'librechat-data-provider';
describe('DragDropModal - Provider Detection', () => {
describe('endpointType priority over currentProvider', () => {
it('should show upload option for LiteLLM with OpenAI endpointType', () => {
const currentProvider = 'litellm'; // NOT in documentSupportedProviders
const endpointType = EModelEndpoint.openAI; // IS in documentSupportedProviders
// With fix: endpointType checked
const withFix =
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
expect(withFix).toBe(true);
// Without fix: only currentProvider checked = false
const withoutFix = isDocumentSupportedProvider(currentProvider || endpointType);
expect(withoutFix).toBe(false);
});
it('should show upload option for any custom gateway with OpenAI endpointType', () => {
const currentProvider = 'my-custom-gateway';
const endpointType = EModelEndpoint.openAI;
const result =
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
expect(result).toBe(true);
});
it('should fallback to currentProvider when endpointType is undefined', () => {
const currentProvider = EModelEndpoint.openAI;
const endpointType = undefined;
const result =
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
expect(result).toBe(true);
});
it('should fallback to currentProvider when endpointType is null', () => {
const currentProvider = EModelEndpoint.anthropic;
const endpointType = null;
const result =
isDocumentSupportedProvider(endpointType as any) ||
isDocumentSupportedProvider(currentProvider);
expect(result).toBe(true);
});
it('should return false when neither provider supports documents', () => {
const currentProvider = 'unsupported-provider';
const endpointType = 'unsupported-endpoint' as any;
const result =
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
expect(result).toBe(false);
});
});
describe('supported providers', () => {
const supportedProviders = [
{ name: 'OpenAI', value: EModelEndpoint.openAI },
{ name: 'Anthropic', value: EModelEndpoint.anthropic },
{ name: 'Google', value: EModelEndpoint.google },
{ name: 'Azure OpenAI', value: EModelEndpoint.azureOpenAI },
{ name: 'Custom', value: EModelEndpoint.custom },
];
supportedProviders.forEach(({ name, value }) => {
it(`should recognize ${name} as supported`, () => {
expect(isDocumentSupportedProvider(value)).toBe(true);
});
});
});
describe('real-world scenarios', () => {
it('should handle LiteLLM gateway pointing to OpenAI', () => {
const scenario = {
currentProvider: 'litellm',
endpointType: EModelEndpoint.openAI,
};
expect(
isDocumentSupportedProvider(scenario.endpointType) ||
isDocumentSupportedProvider(scenario.currentProvider),
).toBe(true);
});
it('should handle direct OpenAI connection', () => {
const scenario = {
currentProvider: EModelEndpoint.openAI,
endpointType: EModelEndpoint.openAI,
};
expect(
isDocumentSupportedProvider(scenario.endpointType) ||
isDocumentSupportedProvider(scenario.currentProvider),
).toBe(true);
});
it('should handle unsupported custom endpoint without override', () => {
const scenario = {
currentProvider: 'my-unsupported-endpoint',
endpointType: undefined,
};
expect(
isDocumentSupportedProvider(scenario.endpointType) ||
isDocumentSupportedProvider(scenario.currentProvider),
).toBe(false);
});
it('should handle agents endpoints with document supported providers', () => {
const scenario = {
currentProvider: EModelEndpoint.google,
endpointType: EModelEndpoint.agents,
};
expect(
isDocumentSupportedProvider(scenario.endpointType) ||
isDocumentSupportedProvider(scenario.currentProvider),
).toBe(true);
});
});
});

View file

@ -0,0 +1,347 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { FileSources } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import FileRow from '../FileRow';
jest.mock('~/hooks', () => ({
useLocalize: jest.fn(),
}));
jest.mock('~/data-provider', () => ({
useDeleteFilesMutation: jest.fn(),
}));
jest.mock('~/hooks/Files', () => ({
useFileDeletion: jest.fn(),
}));
jest.mock('~/utils', () => ({
logger: {
log: jest.fn(),
},
}));
jest.mock('../Image', () => {
return function MockImage({ url, progress, source }: any) {
return (
<div data-testid="mock-image">
<span data-testid="image-url">{url}</span>
<span data-testid="image-progress">{progress}</span>
<span data-testid="image-source">{source}</span>
</div>
);
};
});
jest.mock('../FileContainer', () => {
return function MockFileContainer({ file }: any) {
return (
<div data-testid="mock-file-container">
<span data-testid="file-name">{file.filename}</span>
</div>
);
};
});
const mockUseLocalize = jest.requireMock('~/hooks').useLocalize;
const mockUseDeleteFilesMutation = jest.requireMock('~/data-provider').useDeleteFilesMutation;
const mockUseFileDeletion = jest.requireMock('~/hooks/Files').useFileDeletion;
describe('FileRow', () => {
const mockSetFiles = jest.fn();
const mockSetFilesLoading = jest.fn();
const mockDeleteFile = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockUseLocalize.mockReturnValue((key: string) => {
const translations: Record<string, string> = {
com_ui_deleting_file: 'Deleting file...',
};
return translations[key] || key;
});
mockUseDeleteFilesMutation.mockReturnValue({
mutateAsync: jest.fn(),
});
mockUseFileDeletion.mockReturnValue({
deleteFile: mockDeleteFile,
});
});
/**
* Creates a mock ExtendedFile with sensible defaults
*/
const createMockFile = (overrides: Partial<ExtendedFile> = {}): ExtendedFile => ({
file_id: 'test-file-id',
type: 'image/png',
preview: 'blob:http://localhost:3080/preview-blob-url',
filepath: '/images/user123/test-file-id__image.png',
filename: 'test-image.png',
progress: 1,
size: 1024,
source: FileSources.local,
...overrides,
});
const renderFileRow = (files: Map<string, ExtendedFile>) => {
return render(
<FileRow files={files} setFiles={mockSetFiles} setFilesLoading={mockSetFilesLoading} />,
);
};
describe('Image URL Selection Logic', () => {
it('should use filepath instead of preview when progress is 1 (upload complete)', () => {
const file = createMockFile({
file_id: 'uploaded-file',
preview: 'blob:http://localhost:3080/temp-preview',
filepath: '/images/user123/uploaded-file__image.png',
progress: 1,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('/images/user123/uploaded-file__image.png');
expect(imageUrl).not.toContain('blob:');
});
it('should use preview when progress is less than 1 (uploading)', () => {
const file = createMockFile({
file_id: 'uploading-file',
preview: 'blob:http://localhost:3080/temp-preview',
filepath: undefined,
progress: 0.5,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('blob:http://localhost:3080/temp-preview');
});
it('should fallback to filepath when preview is undefined and progress is less than 1', () => {
const file = createMockFile({
file_id: 'file-without-preview',
preview: undefined,
filepath: '/images/user123/file-without-preview__image.png',
progress: 0.7,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('/images/user123/file-without-preview__image.png');
});
it('should use filepath when both preview and filepath exist and progress is exactly 1', () => {
const file = createMockFile({
file_id: 'complete-file',
preview: 'blob:http://localhost:3080/old-blob',
filepath: '/images/user123/complete-file__image.png',
progress: 1.0,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('/images/user123/complete-file__image.png');
});
});
describe('Progress States', () => {
it('should pass correct progress value during upload', () => {
const file = createMockFile({
progress: 0.65,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const progress = screen.getByTestId('image-progress').textContent;
expect(progress).toBe('0.65');
});
it('should pass progress value of 1 when upload is complete', () => {
const file = createMockFile({
progress: 1,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const progress = screen.getByTestId('image-progress').textContent;
expect(progress).toBe('1');
});
});
describe('File Source', () => {
it('should pass local source to Image component', () => {
const file = createMockFile({
source: FileSources.local,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const source = screen.getByTestId('image-source').textContent;
expect(source).toBe(FileSources.local);
});
it('should pass openai source to Image component', () => {
const file = createMockFile({
source: FileSources.openai,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const source = screen.getByTestId('image-source').textContent;
expect(source).toBe(FileSources.openai);
});
});
describe('File Type Detection', () => {
it('should render Image component for image files', () => {
const file = createMockFile({
type: 'image/jpeg',
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
expect(screen.getByTestId('mock-image')).toBeInTheDocument();
expect(screen.queryByTestId('mock-file-container')).not.toBeInTheDocument();
});
it('should render FileContainer for non-image files', () => {
const file = createMockFile({
type: 'application/pdf',
filename: 'document.pdf',
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
expect(screen.getByTestId('mock-file-container')).toBeInTheDocument();
expect(screen.queryByTestId('mock-image')).not.toBeInTheDocument();
});
});
describe('Multiple Files', () => {
it('should render multiple image files with correct URLs based on their progress', () => {
const filesMap = new Map<string, ExtendedFile>();
const uploadingFile = createMockFile({
file_id: 'file-1',
preview: 'blob:http://localhost:3080/preview-1',
filepath: undefined,
progress: 0.3,
});
const completedFile = createMockFile({
file_id: 'file-2',
preview: 'blob:http://localhost:3080/preview-2',
filepath: '/images/user123/file-2__image.png',
progress: 1,
});
filesMap.set(uploadingFile.file_id, uploadingFile);
filesMap.set(completedFile.file_id, completedFile);
renderFileRow(filesMap);
const images = screen.getAllByTestId('mock-image');
expect(images).toHaveLength(2);
const urls = screen.getAllByTestId('image-url').map((el) => el.textContent);
expect(urls).toContain('blob:http://localhost:3080/preview-1');
expect(urls).toContain('/images/user123/file-2__image.png');
});
it('should deduplicate files with the same file_id', () => {
const filesMap = new Map<string, ExtendedFile>();
const file1 = createMockFile({ file_id: 'duplicate-id' });
const file2 = createMockFile({ file_id: 'duplicate-id' });
filesMap.set('key-1', file1);
filesMap.set('key-2', file2);
renderFileRow(filesMap);
const images = screen.getAllByTestId('mock-image');
expect(images).toHaveLength(1);
});
});
describe('Empty State', () => {
it('should render nothing when files map is empty', () => {
const filesMap = new Map<string, ExtendedFile>();
const { container } = renderFileRow(filesMap);
expect(container.firstChild).toBeNull();
});
it('should render nothing when files is undefined', () => {
const { container } = render(
<FileRow files={undefined} setFiles={mockSetFiles} setFilesLoading={mockSetFilesLoading} />,
);
expect(container.firstChild).toBeNull();
});
});
describe('Regression: Blob URL Bug Fix', () => {
it('should NOT use revoked blob URL after upload completes', () => {
const file = createMockFile({
file_id: 'regression-test',
preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b',
filepath:
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
progress: 1,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).not.toContain('blob:');
expect(imageUrl).toBe(
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
);
});
});
});

View file

@ -25,6 +25,12 @@ export const genAzureEndpoint = ({
azureOpenAIApiInstanceName: string;
azureOpenAIApiDeploymentName: string;
}): string => {
// Support both old (.openai.azure.com) and new (.cognitiveservices.azure.com) endpoint formats
// If instanceName already includes a full domain, use it as-is
if (azureOpenAIApiInstanceName.includes('.azure.com')) {
return `https://${azureOpenAIApiInstanceName}/openai/deployments/${azureOpenAIApiDeploymentName}`;
}
// Legacy format for backward compatibility
return `https://${azureOpenAIApiInstanceName}.openai.azure.com/openai/deployments/${azureOpenAIApiDeploymentName}`;
};