diff --git a/.env.example b/.env.example index 24c74487aa..10e299e72b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index 60d6a48a14..16f806de4e 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -227,7 +227,6 @@ class STTService { } const headers = { - 'Content-Type': 'multipart/form-data', ...(apiKey && { 'api-key': apiKey }), }; diff --git a/api/strategies/appleStrategy.test.js b/api/strategies/appleStrategy.test.js index d8ba4616f2..d142d27eac 100644 --- a/api/strategies/appleStrategy.test.js +++ b/api/strategies/appleStrategy.test.js @@ -304,6 +304,7 @@ describe('Apple Login Strategy', () => { fileStrategy: 'local', balance: { enabled: false }, }), + 'jane.doe@example.com', ); }); diff --git a/api/strategies/process.js b/api/strategies/process.js index 8f70cd86ce..c1e0ad0bbc 100644 --- a/api/strategies/process.js +++ b/api/strategies/process.js @@ -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} - * 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); } }; diff --git a/api/strategies/process.test.js b/api/strategies/process.test.js index ceb7d21a64..ab5fdb651f 100644 --- a/api/strategies/process.test.js +++ b/api/strategies/process.test.js @@ -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(); + }); }); diff --git a/api/strategies/socialLogin.js b/api/strategies/socialLogin.js index bad70cc040..88fb347042 100644 --- a/api/strategies/socialLogin.js +++ b/api/strategies/socialLogin.js @@ -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( diff --git a/api/strategies/socialLogin.test.js b/api/strategies/socialLogin.test.js new file mode 100644 index 0000000000..11ada17975 --- /dev/null +++ b/api/strategies/socialLogin.test.js @@ -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`, + ); + }); + }); +}); diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index a3e5a8d304..821678cfc8 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -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: () => { diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index d9003de3dc..015a590d55 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -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( diff --git a/client/src/components/Chat/Input/Files/FileRow.tsx b/client/src/components/Chat/Input/Files/FileRow.tsx index babb0aef69..ea0b648015 100644 --- a/client/src/components/Chat/Input/Files/FileRow.tsx +++ b/client/src/components/Chat/Input/Files/FileRow.tsx @@ -133,7 +133,7 @@ export default function FileRow({ > {isImage ? ( ({ + 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) => ( +
+ + {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, + }; +}); + +jest.mock('@ariakit/react', () => ({ + MenuButton: ({ children, onClick, disabled, ...props }: any) => ( + + ), +})); + +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 = { + 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( + + + + + , + ); + }; + + 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(); + }); + }); +}); diff --git a/client/src/components/Chat/Input/Files/__tests__/DragDropModal.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/DragDropModal.spec.tsx new file mode 100644 index 0000000000..2adad63b9a --- /dev/null +++ b/client/src/components/Chat/Input/Files/__tests__/DragDropModal.spec.tsx @@ -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); + }); + }); +}); diff --git a/client/src/components/Chat/Input/Files/__tests__/FileRow.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/FileRow.spec.tsx new file mode 100644 index 0000000000..90c1c3a7b5 --- /dev/null +++ b/client/src/components/Chat/Input/Files/__tests__/FileRow.spec.tsx @@ -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 ( +
+ {url} + {progress} + {source} +
+ ); + }; +}); + +jest.mock('../FileContainer', () => { + return function MockFileContainer({ file }: any) { + return ( +
+ {file.filename} +
+ ); + }; +}); + +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 = { + 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 => ({ + 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) => { + return render( + , + ); + }; + + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + + 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(); + + 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(); + + const { container } = renderFileRow(filesMap); + + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when files is undefined', () => { + const { container } = render( + , + ); + + 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(); + 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', + ); + }); + }); +}); diff --git a/packages/api/src/utils/azure.ts b/packages/api/src/utils/azure.ts index b4051d3d80..1bbd0e29b2 100644 --- a/packages/api/src/utils/azure.ts +++ b/packages/api/src/utils/azure.ts @@ -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}`; };