diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/GoogleGemini.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/GoogleGemini.node.test.ts new file mode 100644 index 0000000000..f75b72ef9d --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/GoogleGemini.node.test.ts @@ -0,0 +1,1067 @@ +import { mockDeep } from 'jest-mock-extended'; +import type { IExecuteFunctions, IBinaryData, INode } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import * as helpers from '@utils/helpers'; + +import * as audio from './actions/audio'; +import * as file from './actions/file'; +import * as image from './actions/image'; +import * as text from './actions/text'; +import * as video from './actions/video'; +import * as utils from './helpers/utils'; +import * as transport from './transport'; + +describe('GoogleGemini Node', () => { + const executeFunctionsMock = mockDeep(); + const apiRequestMock = jest.spyOn(transport, 'apiRequest'); + const getConnectedToolsMock = jest.spyOn(helpers, 'getConnectedTools'); + const downloadFileMock = jest.spyOn(utils, 'downloadFile'); + const uploadFileMock = jest.spyOn(utils, 'uploadFile'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Text -> Message', () => { + it('should call the api with the correct parameters', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Hello, world!' }]; + case 'simplify': + return true; + case 'jsonOutput': + return true; + case 'options': + return { + systemMessage: 'You are a helpful assistant.', + codeExecution: true, + frequencyPenalty: 0, + maxOutputTokens: 100, + candidateCount: 1, + presencePenalty: 0, + temperature: 0.5, + topP: 0.5, + topK: 10, + }; + default: + return undefined; + } + }); + getConnectedToolsMock.mockResolvedValue([]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Hello, world!' }], + role: 'model', + }, + }, + ], + }); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: { + parts: [{ text: 'Hello, world!' }], + role: 'model', + }, + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + { + body: { + contents: [ + { + parts: [{ text: 'Hello, world!' }], + role: 'user', + }, + ], + tools: [ + { + codeExecution: {}, + }, + ], + generationConfig: { + candidateCount: 1, + frequencyPenalty: 0, + maxOutputTokens: 100, + presencePenalty: 0, + temperature: 0.5, + topP: 0.5, + topK: 10, + responseMimeType: 'application/json', + }, + systemInstruction: { + parts: [{ text: 'You are a helpful assistant.' }], + }, + }, + }, + ); + }); + }); + + describe('Audio -> Analyze', () => { + it('should analyze audio from URL', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'inputType': + return 'url'; + case 'audioUrls': + return 'https://example.com/audio.mp3'; + case 'text': + return "What's in this audio?"; + case 'simplify': + return true; + case 'options': + return { + maxOutputTokens: 300, + }; + default: + return undefined; + } + }); + downloadFileMock.mockResolvedValue({ + fileContent: Buffer.from('test'), + mimeType: 'audio/mpeg', + }); + uploadFileMock.mockResolvedValue({ + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'audio/mpeg', + }); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'This audio contains a person speaking about AI.' }], + role: 'model', + }, + }, + ], + }); + + const result = await audio.analyze.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: { + parts: [{ text: 'This audio contains a person speaking about AI.' }], + role: 'model', + }, + }, + pairedItem: { item: 0 }, + }, + ]); + expect(downloadFileMock).toHaveBeenCalledWith('https://example.com/audio.mp3', 'audio/mpeg'); + expect(uploadFileMock).toHaveBeenCalledWith(Buffer.from('test'), 'audio/mpeg'); + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + { + body: { + contents: [ + { + parts: [ + { + fileData: { + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'audio/mpeg', + }, + }, + { + text: "What's in this audio?", + }, + ], + role: 'user', + }, + ], + generationConfig: { + maxOutputTokens: 300, + }, + }, + }, + ); + }); + + it('should analyze audio from binary data', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'inputType': + return 'binary'; + case 'binaryPropertyName': + return 'data'; + case 'text': + return "What's in this audio?"; + case 'simplify': + return true; + case 'options': + return { + maxOutputTokens: 300, + }; + default: + return undefined; + } + }); + const mockBinaryData: IBinaryData = { + mimeType: 'audio/mpeg', + fileName: 'test.mp3', + fileSize: '1024', + fileExtension: 'mp3', + data: 'test', + }; + executeFunctionsMock.helpers.assertBinaryData.mockReturnValue(mockBinaryData); + executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(Buffer.from('test')); + uploadFileMock.mockResolvedValue({ + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'audio/mpeg', + }); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'This audio contains a person speaking about AI.' }], + role: 'model', + }, + }, + ], + }); + + const result = await audio.analyze.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: { + parts: [{ text: 'This audio contains a person speaking about AI.' }], + role: 'model', + }, + }, + pairedItem: { item: 0 }, + }, + ]); + expect(uploadFileMock).toHaveBeenCalledWith(Buffer.from('test'), 'audio/mpeg'); + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + { + body: { + contents: [ + { + parts: [ + { + fileData: { + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'audio/mpeg', + }, + }, + { + text: "What's in this audio?", + }, + ], + role: 'user', + }, + ], + generationConfig: { + maxOutputTokens: 300, + }, + }, + }, + ); + }); + + it('should analyze audio from Google API URL', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'inputType': + return 'url'; + case 'audioUrls': + return 'https://generativelanguage.googleapis.com/v1/files/abc123'; + case 'text': + return "What's in this audio?"; + case 'simplify': + return true; + case 'options': + return { + maxOutputTokens: 300, + }; + default: + return undefined; + } + }); + + apiRequestMock.mockImplementation(async (method: string) => { + if (method === 'GET') { + return { mimeType: 'audio/mpeg' }; + } + return { + candidates: [ + { + content: { + parts: [{ text: 'This audio contains a person speaking about AI.' }], + role: 'model', + }, + }, + ], + }; + }); + + const result = await audio.analyze.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: { + parts: [{ text: 'This audio contains a person speaking about AI.' }], + role: 'model', + }, + }, + pairedItem: { item: 0 }, + }, + ]); + + expect(downloadFileMock).not.toHaveBeenCalled(); + expect(uploadFileMock).not.toHaveBeenCalled(); + expect(apiRequestMock).toHaveBeenCalledWith('GET', '', { + option: { url: 'https://generativelanguage.googleapis.com/v1/files/abc123' }, + }); + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + { + body: { + contents: [ + { + parts: [ + { + fileData: { + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'audio/mpeg', + }, + }, + { + text: "What's in this audio?", + }, + ], + role: 'user', + }, + ], + generationConfig: { + maxOutputTokens: 300, + }, + }, + }, + ); + }); + }); + + describe('Audio -> Transcribe', () => { + it('should transcribe audio from URL', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'inputType': + return 'url'; + case 'audioUrls': + return 'https://example.com/audio.mp3'; + case 'simplify': + return true; + case 'options': + return { + startTime: '00:15', + endTime: '02:15', + }; + default: + return undefined; + } + }); + downloadFileMock.mockResolvedValue({ + fileContent: Buffer.from('test'), + mimeType: 'audio/mpeg', + }); + uploadFileMock.mockResolvedValue({ + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'audio/mpeg', + }); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'This is the transcribed text from 00:15 to 02:15.' }], + role: 'model', + }, + }, + ], + }); + + const result = await audio.transcribe.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: { + parts: [{ text: 'This is the transcribed text from 00:15 to 02:15.' }], + role: 'model', + }, + }, + pairedItem: { item: 0 }, + }, + ]); + expect(downloadFileMock).toHaveBeenCalledWith('https://example.com/audio.mp3', 'audio/mpeg'); + expect(uploadFileMock).toHaveBeenCalledWith(Buffer.from('test'), 'audio/mpeg'); + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + { + body: { + contents: [ + { + parts: [ + { + fileData: { + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'audio/mpeg', + }, + }, + { + text: 'Generate a transcript of the speech from 00:15 to 02:15', + }, + ], + role: 'user', + }, + ], + }, + }, + ); + }); + + it('should transcribe audio from binary data', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'inputType': + return 'binary'; + case 'binaryPropertyName': + return 'data'; + case 'simplify': + return true; + case 'options': + return {}; + default: + return undefined; + } + }); + const mockBinaryData: IBinaryData = { + mimeType: 'audio/mpeg', + fileName: 'test.mp3', + fileSize: '1024', + fileExtension: 'mp3', + data: 'test', + }; + executeFunctionsMock.helpers.assertBinaryData.mockReturnValue(mockBinaryData); + executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(Buffer.from('test')); + uploadFileMock.mockResolvedValue({ + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'audio/mpeg', + }); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'This is the transcribed text.' }], + role: 'model', + }, + }, + ], + }); + + const result = await audio.transcribe.execute.call(executeFunctionsMock, 0); + expect(result).toEqual([ + { + json: { + content: { + parts: [{ text: 'This is the transcribed text.' }], + role: 'model', + }, + }, + pairedItem: { item: 0 }, + }, + ]); + expect(uploadFileMock).toHaveBeenCalledWith(Buffer.from('test'), 'audio/mpeg'); + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + { + body: { + contents: [ + { + parts: [ + { + fileData: { + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'audio/mpeg', + }, + }, + { + text: 'Generate a transcript of the speech', + }, + ], + role: 'user', + }, + ], + }, + }, + ); + }); + + it('should transcribe audio from Google API URL', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'inputType': + return 'url'; + case 'audioUrls': + return 'https://generativelanguage.googleapis.com/v1/files/abc123'; + case 'text': + return "What's in this audio?"; + case 'simplify': + return true; + case 'options': + return { + startTime: '00:15', + endTime: '02:15', + }; + default: + return undefined; + } + }); + + apiRequestMock.mockImplementation(async (method: string) => { + if (method === 'GET') { + return { mimeType: 'audio/mpeg' }; + } + return { + candidates: [ + { + content: { + parts: [{ text: 'This is the transcribed text from 00:15 to 02:15.' }], + role: 'model', + }, + }, + ], + }; + }); + + const result = await audio.transcribe.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: { + parts: [{ text: 'This is the transcribed text from 00:15 to 02:15.' }], + role: 'model', + }, + }, + pairedItem: { item: 0 }, + }, + ]); + + expect(downloadFileMock).not.toHaveBeenCalled(); + expect(uploadFileMock).not.toHaveBeenCalled(); + expect(apiRequestMock).toHaveBeenCalledWith('GET', '', { + option: { url: 'https://generativelanguage.googleapis.com/v1/files/abc123' }, + }); + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + { + body: { + contents: [ + { + parts: [ + { + fileData: { + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'audio/mpeg', + }, + }, + { + text: 'Generate a transcript of the speech from 00:15 to 02:15', + }, + ], + role: 'user', + }, + ], + }, + }, + ); + }); + }); + + describe('File -> Upload', () => { + it('should upload file from URL', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'inputType': + return 'url'; + case 'fileUrl': + return 'https://example.com/file.pdf'; + default: + return undefined; + } + }); + downloadFileMock.mockResolvedValue({ + fileContent: Buffer.from('test'), + mimeType: 'application/pdf', + }); + uploadFileMock.mockResolvedValue({ + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'application/pdf', + }); + const result = await file.upload.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'application/pdf', + }, + pairedItem: { item: 0 }, + }, + ]); + expect(downloadFileMock).toHaveBeenCalledWith( + 'https://example.com/file.pdf', + 'application/octet-stream', + ); + expect(uploadFileMock).toHaveBeenCalledWith(Buffer.from('test'), 'application/pdf'); + }); + + it('should upload file from binary data', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'inputType': + return 'binary'; + case 'binaryPropertyName': + return 'data'; + default: + return undefined; + } + }); + const mockBinaryData: IBinaryData = { + mimeType: 'application/pdf', + fileName: 'test.pdf', + fileSize: '1024', + fileExtension: 'pdf', + data: 'test', + }; + executeFunctionsMock.helpers.assertBinaryData.mockReturnValue(mockBinaryData); + executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(Buffer.from('test')); + uploadFileMock.mockResolvedValue({ + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'application/pdf', + }); + + const result = await file.upload.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + fileUri: 'https://generativelanguage.googleapis.com/v1/files/abc123', + mimeType: 'application/pdf', + }, + pairedItem: { item: 0 }, + }, + ]); + expect(uploadFileMock).toHaveBeenCalledWith(Buffer.from('test'), 'application/pdf'); + }); + }); + + describe('Image -> Generate', () => { + it('should generate image using Gemini model', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.0-flash-preview-image-generation'; + case 'prompt': + return 'A cute cat eating a dinosaur'; + case 'options.binaryPropertyOutput': + return 'data'; + default: + return undefined; + } + }); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { + inlineData: { + data: 'abcdefgh', + mimeType: 'image/png', + }, + }, + ], + }, + }, + ], + }); + executeFunctionsMock.helpers.prepareBinaryData.mockResolvedValue({ + mimeType: 'image/png', + fileName: 'image.png', + fileSize: '100', + data: 'abcdefgh', + }); + + const result = await image.generate.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + binary: { + data: { + mimeType: 'image/png', + fileName: 'image.png', + fileSize: '100', + data: 'abcdefgh', + }, + }, + json: { + mimeType: 'image/png', + fileName: 'image.png', + fileSize: '100', + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.0-flash-preview-image-generation:generateContent', + { + body: { + contents: [ + { + role: 'user', + parts: [{ text: 'A cute cat eating a dinosaur' }], + }, + ], + generationConfig: { + responseModalities: ['IMAGE', 'TEXT'], + }, + }, + }, + ); + }); + + it('should generate multiple images using Imagen model', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/imagen-3.0-generate-002'; + case 'prompt': + return 'A cute cat eating a dinosaur'; + case 'options.sampleCount': + return 2; + case 'options.binaryPropertyOutput': + return 'data'; + default: + return undefined; + } + }); + apiRequestMock.mockResolvedValue({ + predictions: [ + { + bytesBase64Encoded: 'abcdefgh', + mimeType: 'image/png', + }, + { + bytesBase64Encoded: 'abcdefgh', + mimeType: 'image/png', + }, + ], + }); + executeFunctionsMock.helpers.prepareBinaryData.mockResolvedValue({ + mimeType: 'image/png', + fileName: 'image.png', + fileSize: '100', + data: 'abcdefgh', + }); + + const result = await image.generate.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + binary: { + data: { + mimeType: 'image/png', + fileName: 'image.png', + fileSize: '100', + data: 'abcdefgh', + }, + }, + json: { + mimeType: 'image/png', + fileName: 'image.png', + fileSize: '100', + }, + pairedItem: { item: 0 }, + }, + { + binary: { + data: { + mimeType: 'image/png', + fileName: 'image.png', + fileSize: '100', + data: 'abcdefgh', + }, + }, + json: { + mimeType: 'image/png', + fileName: 'image.png', + fileSize: '100', + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/imagen-3.0-generate-002:predict', + { + body: { + instances: [ + { + prompt: 'A cute cat eating a dinosaur', + }, + ], + parameters: { + sampleCount: 2, + }, + }, + }, + ); + }); + + it('should throw error for unsupported model', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/unsupported-model'; + case 'prompt': + return 'A cute cat eating a dinosaur'; + default: + return undefined; + } + }); + executeFunctionsMock.getNode.mockReturnValue({ + id: '1', + name: 'Google Gemini', + } as INode); + + await expect(image.generate.execute.call(executeFunctionsMock, 0)).rejects.toThrow( + new NodeOperationError( + executeFunctionsMock.getNode(), + 'Model models/unsupported-model is not supported for image generation', + { + description: 'Please check the model ID and try again.', + }, + ), + ); + }); + }); + + describe('Video -> Generate', () => { + beforeEach(() => { + jest.useFakeTimers({ advanceTimers: true }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should generate video using Veo model', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/veo-3.0-generate-002'; + case 'prompt': + return 'Panning wide shot of a calico kitten sleeping in the sunshine'; + case 'options': + return { + aspectRatio: '16:9', + personGeneration: 'dont_allow', + sampleCount: 1, + durationSeconds: 8, + }; + case 'options.binaryPropertyOutput': + return 'data'; + case 'returnAs': + return 'video'; + default: + return undefined; + } + }); + executeFunctionsMock.getCredentials.mockResolvedValue({ apiKey: 'test-api-key' }); + let pollCount = 0; + apiRequestMock.mockImplementation(async (_method: string, path: string) => { + if (path.includes(':predictLongRunning')) { + return { + name: 'operations/123', + done: false, + }; + } + pollCount++; + return { + name: 'operations/123', + done: pollCount > 1, + response: + pollCount > 1 + ? { + generateVideoResponse: { + generatedSamples: [ + { + video: { + uri: 'https://example.com/video.mp4', + }, + }, + ], + }, + } + : undefined, + }; + }); + downloadFileMock.mockResolvedValue({ + fileContent: Buffer.from('abcdefgh'), + mimeType: 'video/mp4', + }); + executeFunctionsMock.helpers.prepareBinaryData.mockResolvedValue({ + mimeType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '1000', + data: 'abcdefgh', + }); + + const promise = video.generate.execute.call(executeFunctionsMock, 0); + await jest.advanceTimersByTimeAsync(5000); + await jest.advanceTimersByTimeAsync(5000); + const result = await promise; + + expect(result).toEqual([ + { + binary: { + data: { + mimeType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '1000', + data: 'abcdefgh', + }, + }, + json: { + mimeType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '1000', + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/veo-3.0-generate-002:predictLongRunning', + { + body: { + instances: [ + { + prompt: 'Panning wide shot of a calico kitten sleeping in the sunshine', + }, + ], + parameters: { + aspectRatio: '16:9', + personGeneration: 'dont_allow', + sampleCount: 1, + durationSeconds: 8, + }, + }, + }, + ); + expect(apiRequestMock).toHaveBeenCalledWith('GET', '/v1beta/operations/123'); + expect(pollCount).toBe(2); + expect(downloadFileMock).toHaveBeenCalledWith('https://example.com/video.mp4', 'video/mp4', { + key: 'test-api-key', + }); + }); + + it('should handle errors from video generation', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/veo-3.0-generate-002'; + case 'prompt': + return 'Panning wide shot of a calico kitten sleeping in the sunshine'; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getCredentials.mockResolvedValue({ apiKey: 'test-api-key' }); + apiRequestMock.mockImplementationOnce(async () => { + return { + name: 'operations/123', + done: true, + error: { + message: 'Failed to generate video', + }, + }; + }); + executeFunctionsMock.getNode.mockReturnValue({ name: 'Google Gemini' } as INode); + + await expect(video.generate.execute.call(executeFunctionsMock, 0)).rejects.toThrow( + new NodeOperationError(executeFunctionsMock.getNode(), 'Failed to generate video', { + description: 'Error generating video', + }), + ); + }); + + it('should throw error for non-Veo model', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.0-flash'; + case 'prompt': + return 'Panning wide shot of a calico kitten sleeping in the sunshine'; + default: + return undefined; + } + }); + + executeFunctionsMock.getNode.mockReturnValue({ name: 'Google Gemini' } as INode); + + await expect(video.generate.execute.call(executeFunctionsMock, 0)).rejects.toThrow( + new NodeOperationError( + executeFunctionsMock.getNode(), + 'Model models/gemini-2.0-flash is not supported for video generation. Please use a Veo model', + { + description: 'Video generation is only supported by Veo models', + }, + ), + ); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/GoogleGemini.node.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/GoogleGemini.node.ts new file mode 100644 index 0000000000..91f88472cb --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/GoogleGemini.node.ts @@ -0,0 +1,17 @@ +import type { IExecuteFunctions, INodeType } from 'n8n-workflow'; + +import { router } from './actions/router'; +import { versionDescription } from './actions/versionDescription'; +import { listSearch } from './methods'; + +export class GoogleGemini implements INodeType { + description = versionDescription; + + methods = { + listSearch, + }; + + async execute(this: IExecuteFunctions) { + return await router.call(this); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/audio/analyze.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/audio/analyze.operation.ts new file mode 100644 index 0000000000..59ab48bc4b --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/audio/analyze.operation.ts @@ -0,0 +1,102 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { baseAnalyze } from '../../helpers/baseAnalyze'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC('audioModelSearch'), + { + displayName: 'Text Input', + name: 'text', + type: 'string', + placeholder: "e.g. What's in this audio?", + default: "What's in this audio?", + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Input Type', + name: 'inputType', + type: 'options', + default: 'url', + options: [ + { + name: 'Audio URL(s)', + value: 'url', + }, + { + name: 'Binary File(s)', + value: 'binary', + }, + ], + }, + { + displayName: 'URL(s)', + name: 'audioUrls', + type: 'string', + placeholder: 'e.g. https://example.com/audio.mp3', + description: 'URL(s) of the audio(s) to analyze, multiple URLs can be added separated by comma', + default: '', + displayOptions: { + show: { + inputType: ['url'], + }, + }, + }, + { + displayName: 'Input Data Field Name(s)', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + placeholder: 'e.g. data', + hint: 'The name of the input field containing the binary file data to be processed', + description: + 'Name of the binary field(s) which contains the audio(s), seperate multiple field names with commas', + displayOptions: { + show: { + inputType: ['binary'], + }, + }, + }, + { + displayName: 'Simplify Output', + name: 'simplify', + type: 'boolean', + default: true, + description: 'Whether to simplify the response or not', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Length of Description (Max Tokens)', + description: 'Fewer tokens will result in shorter, less detailed audio description', + name: 'maxOutputTokens', + type: 'number', + default: 300, + typeOptions: { + minValue: 1, + }, + }, + ], + }, +]; + +const displayOptions = { + show: { + operation: ['analyze'], + resource: ['audio'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + return await baseAnalyze.call(this, i, 'audioUrls', 'audio/mpeg'); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/audio/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/audio/index.ts new file mode 100644 index 0000000000..db7697404b --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/audio/index.ts @@ -0,0 +1,37 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as analyze from './analyze.operation'; +import * as transcribe from './transcribe.operation'; + +export { analyze, transcribe }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Analyze Audio', + value: 'analyze', + action: 'Analyze audio', + description: 'Take in audio and answer questions about it', + }, + { + name: 'Transcribe a Recording', + value: 'transcribe', + action: 'Transcribe a recording', + description: 'Transcribes audio into the text', + }, + ], + default: 'transcribe', + displayOptions: { + show: { + resource: ['audio'], + }, + }, + }, + ...analyze.description, + ...transcribe.description, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/audio/transcribe.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/audio/transcribe.operation.ts new file mode 100644 index 0000000000..c828cafa2b --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/audio/transcribe.operation.ts @@ -0,0 +1,181 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import type { Content, GenerateContentResponse } from '../../helpers/interfaces'; +import { downloadFile, uploadFile } from '../../helpers/utils'; +import { apiRequest } from '../../transport'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC('audioModelSearch'), + { + displayName: 'Input Type', + name: 'inputType', + type: 'options', + default: 'url', + options: [ + { + name: 'Audio URL(s)', + value: 'url', + }, + { + name: 'Binary File(s)', + value: 'binary', + }, + ], + }, + { + displayName: 'URL(s)', + name: 'audioUrls', + type: 'string', + placeholder: 'e.g. https://example.com/audio.mp3', + description: + 'URL(s) of the audio(s) to transcribe, multiple URLs can be added separated by comma', + default: '', + displayOptions: { + show: { + inputType: ['url'], + }, + }, + }, + { + displayName: 'Input Data Field Name(s)', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + placeholder: 'e.g. data', + hint: 'The name of the input field containing the binary file data to be processed', + description: + 'Name of the binary field(s) which contains the audio(s), seperate multiple field names with commas', + displayOptions: { + show: { + inputType: ['binary'], + }, + }, + }, + { + displayName: 'Simplify Output', + name: 'simplify', + type: 'boolean', + default: true, + description: 'Whether to simplify the response or not', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Start Time', + name: 'startTime', + type: 'string', + default: '', + description: 'The start time of the audio in MM:SS or HH:MM:SS format', + placeholder: 'e.g. 00:15', + }, + { + displayName: 'End Time', + name: 'endTime', + type: 'string', + default: '', + description: 'The end time of the audio in MM:SS or HH:MM:SS format', + placeholder: 'e.g. 02:15', + }, + ], + }, +]; + +const displayOptions = { + show: { + operation: ['transcribe'], + resource: ['audio'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string; + const inputType = this.getNodeParameter('inputType', i, 'url') as string; + const simplify = this.getNodeParameter('simplify', i, true) as boolean; + const options = this.getNodeParameter('options', i, {}); + + let contents: Content[]; + if (inputType === 'url') { + const urls = this.getNodeParameter('audioUrls', i, '') as string; + const filesDataPromises = urls + .split(',') + .map((url) => url.trim()) + .filter((url) => url) + .map(async (url) => { + if (url.startsWith('https://generativelanguage.googleapis.com')) { + const { mimeType } = (await apiRequest.call(this, 'GET', '', { + option: { url }, + })) as { mimeType: string }; + return { fileUri: url, mimeType }; + } else { + const { fileContent, mimeType } = await downloadFile.call(this, url, 'audio/mpeg'); + return await uploadFile.call(this, fileContent, mimeType); + } + }); + + const filesData = await Promise.all(filesDataPromises); + contents = [ + { + role: 'user', + parts: filesData.map((fileData) => ({ + fileData, + })), + }, + ]; + } else { + const binaryPropertyNames = this.getNodeParameter('binaryPropertyName', i, 'data'); + const promises = binaryPropertyNames + .split(',') + .map((binaryPropertyName) => binaryPropertyName.trim()) + .filter((binaryPropertyName) => binaryPropertyName) + .map(async (binaryPropertyName) => { + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + return await uploadFile.call(this, buffer, binaryData.mimeType); + }); + + const filesData = await Promise.all(promises); + contents = [ + { + role: 'user', + parts: filesData.map((fileData) => ({ + fileData, + })), + }, + ]; + } + + const text = `Generate a transcript of the speech${ + options.startTime ? ` from ${options.startTime as string}` : '' + }${options.endTime ? ` to ${options.endTime as string}` : ''}`; + contents[0].parts.push({ text }); + + const body = { + contents, + }; + + const response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:generateContent`, { + body, + })) as GenerateContentResponse; + + if (simplify) { + return response.candidates.map((candidate) => ({ + json: candidate, + pairedItem: { item: i }, + })); + } + + return [ + { + json: { ...response }, + pairedItem: { item: i }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/descriptions.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/descriptions.ts new file mode 100644 index 0000000000..87c2c329eb --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/descriptions.ts @@ -0,0 +1,26 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const modelRLC = (searchListMethod: string): INodeProperties => ({ + displayName: 'Model', + name: 'modelId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod, + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'e.g. models/gemini-2.5-flash', + }, + ], +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/document/analyze.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/document/analyze.operation.ts new file mode 100644 index 0000000000..d54f3e1ac8 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/document/analyze.operation.ts @@ -0,0 +1,103 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { baseAnalyze } from '../../helpers/baseAnalyze'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC('modelSearch'), + { + displayName: 'Text Input', + name: 'text', + type: 'string', + placeholder: "e.g. What's in this document?", + default: "What's in this document?", + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Input Type', + name: 'inputType', + type: 'options', + default: 'url', + options: [ + { + name: 'Document URL(s)', + value: 'url', + }, + { + name: 'Binary File(s)', + value: 'binary', + }, + ], + }, + { + displayName: 'URL(s)', + name: 'documentUrls', + type: 'string', + placeholder: 'e.g. https://example.com/document.pdf', + description: + 'URL(s) of the document(s) to analyze, multiple URLs can be added separated by comma', + default: '', + displayOptions: { + show: { + inputType: ['url'], + }, + }, + }, + { + displayName: 'Input Data Field Name(s)', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + placeholder: 'e.g. data', + hint: 'The name of the input field containing the binary file data to be processed', + description: + 'Name of the binary field(s) which contains the document(s), seperate multiple field names with commas', + displayOptions: { + show: { + inputType: ['binary'], + }, + }, + }, + { + displayName: 'Simplify Output', + name: 'simplify', + type: 'boolean', + default: true, + description: 'Whether to simplify the response or not', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Length of Description (Max Tokens)', + description: 'Fewer tokens will result in shorter, less detailed document description', + name: 'maxOutputTokens', + type: 'number', + default: 300, + typeOptions: { + minValue: 1, + }, + }, + ], + }, +]; + +const displayOptions = { + show: { + operation: ['analyze'], + resource: ['document'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + return await baseAnalyze.call(this, i, 'documentUrls', 'application/pdf'); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/document/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/document/index.ts new file mode 100644 index 0000000000..046f840713 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/document/index.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as analyze from './analyze.operation'; + +export { analyze }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Analyze Document', + value: 'analyze', + action: 'Analyze document', + description: 'Take in documents and answer questions about them', + }, + ], + default: 'analyze', + displayOptions: { + show: { + resource: ['document'], + }, + }, + }, + ...analyze.description, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/file/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/file/index.ts new file mode 100644 index 0000000000..c913831363 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/file/index.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as upload from './upload.operation'; + +export { upload }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Upload File', + value: 'upload', + action: 'Upload a file', + description: 'Upload a file to the Google Gemini API for later use', + }, + ], + default: 'upload', + displayOptions: { + show: { + resource: ['file'], + }, + }, + }, + ...upload.description, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/file/upload.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/file/upload.operation.ts new file mode 100644 index 0000000000..947edc9065 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/file/upload.operation.ts @@ -0,0 +1,93 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { downloadFile, uploadFile } from '../../helpers/utils'; + +export const properties: INodeProperties[] = [ + { + displayName: 'Input Type', + name: 'inputType', + type: 'options', + default: 'url', + options: [ + { + name: 'File URL', + value: 'url', + }, + { + name: 'Binary File', + value: 'binary', + }, + ], + }, + { + displayName: 'URL', + name: 'fileUrl', + type: 'string', + placeholder: 'e.g. https://example.com/file.pdf', + description: 'URL of the file to upload', + default: '', + displayOptions: { + show: { + inputType: ['url'], + }, + }, + }, + { + displayName: 'Input Data Field Name', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + placeholder: 'e.g. data', + hint: 'The name of the input field containing the binary file data to be processed', + description: 'Name of the binary property which contains the file', + displayOptions: { + show: { + inputType: ['binary'], + }, + }, + }, +]; + +const displayOptions = { + show: { + operation: ['upload'], + resource: ['file'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const inputType = this.getNodeParameter('inputType', i, 'url') as string; + if (inputType === 'url') { + const fileUrl = this.getNodeParameter('fileUrl', i, '') as string; + const { fileContent, mimeType } = await downloadFile.call( + this, + fileUrl, + 'application/octet-stream', + ); + const response = await uploadFile.call(this, fileContent, mimeType); + return [ + { + json: response, + pairedItem: { + item: i, + }, + }, + ]; + } else { + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i, 'data'); + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + const response = await uploadFile.call(this, buffer, binaryData.mimeType); + return [ + { + json: response, + pairedItem: { + item: i, + }, + }, + ]; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/analyze.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/analyze.operation.ts new file mode 100644 index 0000000000..a03ad25e36 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/analyze.operation.ts @@ -0,0 +1,102 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { baseAnalyze } from '../../helpers/baseAnalyze'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC('modelSearch'), + { + displayName: 'Text Input', + name: 'text', + type: 'string', + placeholder: "e.g. What's in this image?", + default: "What's in this image?", + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Input Type', + name: 'inputType', + type: 'options', + default: 'url', + options: [ + { + name: 'Image URL(s)', + value: 'url', + }, + { + name: 'Binary File(s)', + value: 'binary', + }, + ], + }, + { + displayName: 'URL(s)', + name: 'imageUrls', + type: 'string', + placeholder: 'e.g. https://example.com/image.png', + description: 'URL(s) of the image(s) to analyze, multiple URLs can be added separated by comma', + default: '', + displayOptions: { + show: { + inputType: ['url'], + }, + }, + }, + { + displayName: 'Input Data Field Name(s)', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + placeholder: 'e.g. data', + hint: 'The name of the input field containing the binary file data to be processed', + description: + 'Name of the binary field(s) which contains the image(s), separate multiple field names with commas', + displayOptions: { + show: { + inputType: ['binary'], + }, + }, + }, + { + displayName: 'Simplify Output', + name: 'simplify', + type: 'boolean', + default: true, + description: 'Whether to simplify the response or not', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Length of Description (Max Tokens)', + description: 'Fewer tokens will result in shorter, less detailed image description', + name: 'maxOutputTokens', + type: 'number', + default: 300, + typeOptions: { + minValue: 1, + }, + }, + ], + }, +]; + +const displayOptions = { + show: { + operation: ['analyze'], + resource: ['image'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + return await baseAnalyze.call(this, i, 'imageUrls', 'image/png'); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/generate.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/generate.operation.ts new file mode 100644 index 0000000000..86182353f7 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/generate.operation.ts @@ -0,0 +1,152 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow'; + +import type { GenerateContentResponse, ImagenResponse } from '../../helpers/interfaces'; +import { apiRequest } from '../../transport'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC('imageGenerationModelSearch'), + { + displayName: 'Prompt', + name: 'prompt', + type: 'string', + placeholder: 'e.g. A cute cat eating a dinosaur', + description: 'A text description of the desired image(s)', + default: '', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Number of Images', + name: 'sampleCount', + default: 1, + description: + 'Number of images to generate. Not supported by Gemini models, supported by Imagen models.', + type: 'number', + typeOptions: { + minValue: 1, + }, + }, + { + displayName: 'Put Output in Field', + name: 'binaryPropertyOutput', + type: 'string', + default: 'data', + hint: 'The name of the output field to put the binary file data in', + }, + ], + }, +]; + +const displayOptions = { + show: { + operation: ['generate'], + resource: ['image'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string; + const prompt = this.getNodeParameter('prompt', i, '') as string; + const binaryPropertyOutput = this.getNodeParameter( + 'options.binaryPropertyOutput', + i, + 'data', + ) as string; + + if (model.includes('gemini')) { + const generationConfig = { + responseModalities: ['IMAGE', 'TEXT'], + }; + const body = { + contents: [ + { + role: 'user', + parts: [{ text: prompt }], + }, + ], + generationConfig, + }; + + const response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:generateContent`, { + body, + })) as GenerateContentResponse; + const promises = response.candidates.map(async (candidate) => { + const imagePart = candidate.content.parts.find((part) => 'inlineData' in part); + const buffer = Buffer.from(imagePart?.inlineData.data ?? '', 'base64'); + const binaryData = await this.helpers.prepareBinaryData( + buffer, + 'image.png', + imagePart?.inlineData.mimeType, + ); + return { + binary: { + [binaryPropertyOutput]: binaryData, + }, + json: { + ...binaryData, + data: undefined, + }, + pairedItem: { item: i }, + }; + }); + + return await Promise.all(promises); + } else if (model.includes('imagen')) { + // Imagen models use a different endpoint and request/response structure + const sampleCount = this.getNodeParameter('options.sampleCount', i, 1) as number; + const body = { + instances: [ + { + prompt, + }, + ], + parameters: { + sampleCount, + }, + }; + const response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:predict`, { + body, + })) as ImagenResponse; + + const promises = response.predictions.map(async (prediction) => { + const buffer = Buffer.from(prediction.bytesBase64Encoded ?? '', 'base64'); + const binaryData = await this.helpers.prepareBinaryData( + buffer, + 'image.png', + prediction.mimeType, + ); + return { + binary: { + [binaryPropertyOutput]: binaryData, + }, + json: { + ...binaryData, + data: undefined, + }, + pairedItem: { item: i }, + }; + }); + + return await Promise.all(promises); + } + + throw new NodeOperationError( + this.getNode(), + `Model ${model} is not supported for image generation`, + { + description: 'Please check the model ID and try again.', + }, + ); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/index.ts new file mode 100644 index 0000000000..bba470bc86 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/index.ts @@ -0,0 +1,37 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as analyze from './analyze.operation'; +import * as generate from './generate.operation'; + +export { analyze, generate }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Analyze Image', + value: 'analyze', + action: 'Analyze image', + description: 'Take in images and answer questions about them', + }, + { + name: 'Generate an Image', + value: 'generate', + action: 'Generate an image', + description: 'Creates an image from a text prompt', + }, + ], + default: 'generate', + displayOptions: { + show: { + resource: ['image'], + }, + }, + }, + ...analyze.description, + ...generate.description, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/node.type.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/node.type.ts new file mode 100644 index 0000000000..0652b86f58 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/node.type.ts @@ -0,0 +1,12 @@ +import type { AllEntities } from 'n8n-workflow'; + +type NodeMap = { + text: 'message'; + image: 'analyze' | 'generate'; + video: 'analyze' | 'generate' | 'download'; + audio: 'transcribe' | 'analyze'; + document: 'analyze'; + file: 'upload'; +}; + +export type GoogleGeminiType = AllEntities; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/router.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/router.test.ts new file mode 100644 index 0000000000..a1dfaece48 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/router.test.ts @@ -0,0 +1,127 @@ +import { mockDeep } from 'jest-mock-extended'; +import type { IExecuteFunctions } from 'n8n-workflow'; + +import * as audio from './audio'; +import * as document from './document'; +import * as file from './file'; +import * as image from './image'; +import { router } from './router'; +import * as text from './text'; +import * as video from './video'; + +describe('Google Gemini router', () => { + const mockExecuteFunctions = mockDeep(); + const mockAudio = jest.spyOn(audio.analyze, 'execute'); + const mockDocument = jest.spyOn(document.analyze, 'execute'); + const mockFile = jest.spyOn(file.upload, 'execute'); + const mockImage = jest.spyOn(image.analyze, 'execute'); + const mockText = jest.spyOn(text.message, 'execute'); + const mockVideo = jest.spyOn(video.analyze, 'execute'); + const operationMocks = [ + [mockAudio, 'audio', 'analyze'], + [mockDocument, 'document', 'analyze'], + [mockFile, 'file', 'upload'], + [mockImage, 'image', 'analyze'], + [mockText, 'text', 'message'], + [mockVideo, 'video', 'analyze'], + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each(operationMocks)('should call the correct method', async (mock, resource, operation) => { + mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) => + parameter === 'resource' ? resource : operation, + ); + mockExecuteFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + (mock as jest.Mock).mockResolvedValue([ + { + json: { + foo: 'bar', + }, + }, + ]); + + const result = await router.call(mockExecuteFunctions); + + expect(mock).toHaveBeenCalledWith(0); + expect(result).toEqual([[{ json: { foo: 'bar' } }]]); + }); + + it('should return an error if the operation is not supported', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) => + parameter === 'resource' ? 'foo' : 'bar', + ); + mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]); + + await expect(router.call(mockExecuteFunctions)).rejects.toThrow( + 'The operation "bar" is not supported!', + ); + }); + + it('should loop over all items', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) => + parameter === 'resource' ? 'audio' : 'analyze', + ); + mockExecuteFunctions.getInputData.mockReturnValue([ + { + json: { + text: 'item 1', + }, + }, + { + json: { + text: 'item 2', + }, + }, + { + json: { + text: 'item 3', + }, + }, + ]); + mockAudio.mockResolvedValueOnce([{ json: { response: 'foo' } }]); + mockAudio.mockResolvedValueOnce([{ json: { response: 'bar' } }]); + mockAudio.mockResolvedValueOnce([{ json: { response: 'baz' } }]); + + const result = await router.call(mockExecuteFunctions); + + expect(result).toEqual([ + [{ json: { response: 'foo' } }, { json: { response: 'bar' } }, { json: { response: 'baz' } }], + ]); + }); + + it('should continue on fail', async () => { + mockExecuteFunctions.continueOnFail.mockReturnValue(true); + mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) => + parameter === 'resource' ? 'audio' : 'analyze', + ); + mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }, { json: {} }]); + mockAudio.mockRejectedValue(new Error('Some error')); + + const result = await router.call(mockExecuteFunctions); + + expect(result).toEqual([ + [ + { json: { error: 'Some error' }, pairedItem: { item: 0 } }, + { json: { error: 'Some error' }, pairedItem: { item: 1 } }, + ], + ]); + }); + + it('should throw an error if continueOnFail is false', async () => { + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) => + parameter === 'resource' ? 'audio' : 'analyze', + ); + mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]); + mockAudio.mockRejectedValue(new Error('Some error')); + + await expect(router.call(mockExecuteFunctions)).rejects.toThrow('Some error'); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/router.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/router.ts new file mode 100644 index 0000000000..fe9de759d9 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/router.ts @@ -0,0 +1,68 @@ +import { NodeOperationError, type IExecuteFunctions, type INodeExecutionData } from 'n8n-workflow'; + +import * as audio from './audio'; +import * as document from './document'; +import * as file from './file'; +import * as image from './image'; +import type { GoogleGeminiType } from './node.type'; +import * as text from './text'; +import * as video from './video'; + +export async function router(this: IExecuteFunctions) { + const returnData: INodeExecutionData[] = []; + + const items = this.getInputData(); + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + + const googleGeminiTypeData = { + resource, + operation, + } as GoogleGeminiType; + + let execute; + switch (googleGeminiTypeData.resource) { + case 'audio': + execute = audio[googleGeminiTypeData.operation].execute; + break; + case 'document': + execute = document[googleGeminiTypeData.operation].execute; + break; + case 'file': + execute = file[googleGeminiTypeData.operation].execute; + break; + case 'image': + execute = image[googleGeminiTypeData.operation].execute; + break; + case 'text': + execute = text[googleGeminiTypeData.operation].execute; + break; + case 'video': + execute = video[googleGeminiTypeData.operation].execute; + break; + default: + throw new NodeOperationError( + this.getNode(), + `The operation "${operation}" is not supported!`, + ); + } + + for (let i = 0; i < items.length; i++) { + try { + const responseData = await execute.call(this, i); + returnData.push(...responseData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { error: error.message }, pairedItem: { item: i } }); + continue; + } + + throw new NodeOperationError(this.getNode(), error, { + itemIndex: i, + description: error.description, + }); + } + } + + return [returnData]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/text/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/text/index.ts new file mode 100644 index 0000000000..a074900181 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/text/index.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as message from './message.operation'; + +export { message }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Message a Model', + value: 'message', + action: 'Message a model', + description: 'Create a completion with Google Gemini model', + }, + ], + default: 'message', + displayOptions: { + show: { + resource: ['text'], + }, + }, + }, + ...message.description, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/text/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/text/message.operation.ts new file mode 100644 index 0000000000..b2bb01547e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/text/message.operation.ts @@ -0,0 +1,338 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; +import zodToJsonSchema from 'zod-to-json-schema'; + +import { getConnectedTools } from '@utils/helpers'; + +import type { GenerateContentResponse, Content, Tool } from '../../helpers/interfaces'; +import { apiRequest } from '../../transport'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC('modelSearch'), + { + displayName: 'Messages', + name: 'messages', + type: 'fixedCollection', + typeOptions: { + sortable: true, + multipleValues: true, + }, + placeholder: 'Add Message', + default: { values: [{ content: '' }] }, + options: [ + { + displayName: 'Values', + name: 'values', + values: [ + { + displayName: 'Prompt', + name: 'content', + type: 'string', + description: 'The content of the message to be send', + default: '', + placeholder: 'e.g. Hello, how can you help me?', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Role', + name: 'role', + type: 'options', + description: + "Role in shaping the model's response, it tells the model how it should behave and interact with the user", + options: [ + { + name: 'User', + value: 'user', + description: 'Send a message as a user and get a response from the model', + }, + { + name: 'Model', + value: 'model', + description: 'Tell the model to adopt a specific tone or personality', + }, + ], + default: 'user', + }, + ], + }, + ], + }, + { + displayName: 'Simplify Output', + name: 'simplify', + type: 'boolean', + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + }, + { + displayName: 'Output Content as JSON', + name: 'jsonOutput', + type: 'boolean', + description: 'Whether to attempt to return the response in JSON format', + default: false, + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + type: 'collection', + default: {}, + options: [ + { + displayName: 'System Message', + name: 'systemMessage', + type: 'string', + default: '', + placeholder: 'e.g. You are a helpful assistant', + }, + { + displayName: 'Code Execution', + name: 'codeExecution', + type: 'boolean', + default: false, + description: + 'Whether to allow the model to execute code it generates to produce a response. Supported only by certain models.', + }, + { + displayName: 'Frequency Penalty', + name: 'frequencyPenalty', + default: 0, + description: + "Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim", + type: 'number', + typeOptions: { + minValue: -2, + maxValue: 2, + numberPrecision: 1, + }, + }, + { + displayName: 'Maximum Number of Tokens', + name: 'maxOutputTokens', + default: 16, + description: 'The maximum number of tokens to generate in the completion', + type: 'number', + typeOptions: { + minValue: 1, + numberPrecision: 0, + }, + }, + { + displayName: 'Number of Completions', + name: 'candidateCount', + default: 1, + description: 'How many completions to generate for each prompt', + type: 'number', + typeOptions: { + minValue: 1, + maxValue: 8, // Google Gemini supports up to 8 candidates + numberPrecision: 0, + }, + }, + { + displayName: 'Presence Penalty', + name: 'presencePenalty', + default: 0, + description: + "Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics", + type: 'number', + typeOptions: { + minValue: -2, + maxValue: 2, + numberPrecision: 1, + }, + }, + { + displayName: 'Output Randomness (Temperature)', + name: 'temperature', + default: 1, + description: + 'Controls the randomness of the output. Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 2, + numberPrecision: 1, + }, + }, + { + displayName: 'Output Randomness (Top P)', + name: 'topP', + default: 1, + description: 'The maximum cumulative probability of tokens to consider when sampling', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 1, + numberPrecision: 1, + }, + }, + { + displayName: 'Output Randomness (Top K)', + name: 'topK', + default: 1, + description: 'The maximum number of tokens to consider when sampling', + type: 'number', + typeOptions: { + minValue: 1, + numberPrecision: 0, + }, + }, + { + displayName: 'Max Tool Calls Iterations', + name: 'maxToolsIterations', + type: 'number', + default: 15, + description: + 'The maximum number of tool iteration cycles the LLM will run before stopping. A single iteration can contain multiple tool calls. Set to 0 for no limit', + typeOptions: { + minValue: 0, + numberPrecision: 0, + }, + }, + ], + }, +]; + +const displayOptions = { + show: { + operation: ['message'], + resource: ['text'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +function getToolCalls(response: GenerateContentResponse) { + return response.candidates.flatMap((c) => c.content.parts).filter((p) => 'functionCall' in p); +} + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string; + const messages = this.getNodeParameter('messages.values', i, []) as Array<{ + content: string; + role: string; + }>; + const simplify = this.getNodeParameter('simplify', i, true) as boolean; + const jsonOutput = this.getNodeParameter('jsonOutput', i, false) as boolean; + const options = this.getNodeParameter('options', i, {}); + + const generationConfig = { + frequencyPenalty: options.frequencyPenalty, + maxOutputTokens: options.maxOutputTokens, + candidateCount: options.candidateCount, + presencePenalty: options.presencePenalty, + temperature: options.temperature, + topP: options.topP, + topK: options.topK, + responseMimeType: jsonOutput ? 'application/json' : undefined, + }; + + const availableTools = await getConnectedTools(this, true); + const tools: Tool[] = [ + { + functionDeclarations: availableTools.map((t) => ({ + name: t.name, + description: t.description, + parameters: { + ...zodToJsonSchema(t.schema, { target: 'openApi3' }), + // Google Gemini API throws an error if `additionalProperties` field is present + additionalProperties: undefined, + }, + })), + }, + ]; + if (!tools[0].functionDeclarations?.length) { + tools.pop(); + } + + if (options.codeExecution) { + tools.push({ + codeExecution: {}, + }); + } + + const contents: Content[] = messages.map((m) => ({ + parts: [{ text: m.content }], + role: m.role, + })); + const body = { + tools, + contents, + generationConfig, + systemInstruction: options.systemMessage + ? { parts: [{ text: options.systemMessage }] } + : undefined, + }; + + let response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:generateContent`, { + body, + })) as GenerateContentResponse; + + const maxToolsIterations = this.getNodeParameter('options.maxToolsIterations', i, 15) as number; + const abortSignal = this.getExecutionCancelSignal(); + let currentIteration = 1; + let toolCalls = getToolCalls(response); + while (toolCalls.length) { + if ( + (maxToolsIterations > 0 && currentIteration >= maxToolsIterations) || + abortSignal?.aborted + ) { + break; + } + + contents.push(...response.candidates.map((c) => c.content)); + + for (const { functionCall } of toolCalls) { + let toolResponse; + for (const availableTool of availableTools) { + if (availableTool.name === functionCall.name) { + toolResponse = (await availableTool.invoke(functionCall.args)) as IDataObject; + } + } + + contents.push({ + parts: [ + { + functionResponse: { + id: functionCall.id, + name: functionCall.name, + response: { + result: toolResponse, + }, + }, + }, + ], + role: 'tool', + }); + } + + response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:generateContent`, { + body, + })) as GenerateContentResponse; + toolCalls = getToolCalls(response); + currentIteration++; + } + + if (simplify) { + return response.candidates.map((candidate) => ({ + json: candidate, + pairedItem: { item: i }, + })); + } + + return [ + { + json: { ...response }, + pairedItem: { item: i }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/versionDescription.ts new file mode 100644 index 0000000000..43575c1fda --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/versionDescription.ts @@ -0,0 +1,96 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow'; + +import * as audio from './audio'; +import * as document from './document'; +import * as file from './file'; +import * as image from './image'; +import * as text from './text'; +import * as video from './video'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Google Gemini', + name: 'googleGemini', + icon: 'file:gemini.svg', + group: ['transform'], + version: 1, + subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}', + description: 'Interact with Google Gemini AI models', + defaults: { + name: 'Google Gemini', + }, + usableAsTool: true, + codex: { + alias: ['LangChain', 'video', 'document', 'audio', 'transcribe', 'assistant'], + categories: ['AI'], + subcategories: { + AI: ['Agents', 'Miscellaneous', 'Root Nodes'], + }, + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.googlegemini/', + }, + ], + }, + }, + inputs: `={{ + (() => { + const resource = $parameter.resource; + const operation = $parameter.operation; + if (resource === 'text' && operation === 'message') { + return [{ type: 'main' }, { type: 'ai_tool', displayName: 'Tools' }]; + } + + return ['main']; + })() + }}`, + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'googlePalmApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Audio', + value: 'audio', + }, + { + name: 'Document', + value: 'document', + }, + { + name: 'File', + value: 'file', + }, + { + name: 'Image', + value: 'image', + }, + { + name: 'Text', + value: 'text', + }, + { + name: 'Video', + value: 'video', + }, + ], + default: 'text', + }, + ...audio.description, + ...document.description, + ...file.description, + ...image.description, + ...text.description, + ...video.description, + ], +}; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/analyze.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/analyze.operation.ts new file mode 100644 index 0000000000..a547f90ce7 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/analyze.operation.ts @@ -0,0 +1,102 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { baseAnalyze } from '../../helpers/baseAnalyze'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC('modelSearch'), + { + displayName: 'Text Input', + name: 'text', + type: 'string', + placeholder: "e.g. What's in this video?", + default: "What's in this video?", + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Input Type', + name: 'inputType', + type: 'options', + default: 'url', + options: [ + { + name: 'Video URL(s)', + value: 'url', + }, + { + name: 'Binary File(s)', + value: 'binary', + }, + ], + }, + { + displayName: 'URL(s)', + name: 'videoUrls', + type: 'string', + placeholder: 'e.g. https://example.com/video.mp4', + description: 'URL(s) of the video(s) to analyze, multiple URLs can be added separated by comma', + default: '', + displayOptions: { + show: { + inputType: ['url'], + }, + }, + }, + { + displayName: 'Input Data Field Name(s)', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + placeholder: 'e.g. data', + hint: 'The name of the input field containing the binary file data to be processed', + description: + 'Name of the binary field(s) which contains the video(s), seperate multiple field names with commas', + displayOptions: { + show: { + inputType: ['binary'], + }, + }, + }, + { + displayName: 'Simplify Output', + name: 'simplify', + type: 'boolean', + default: true, + description: 'Whether to simplify the response or not', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Length of Description (Max Tokens)', + description: 'Fewer tokens will result in shorter, less detailed video description', + name: 'maxOutputTokens', + type: 'number', + default: 300, + typeOptions: { + minValue: 1, + }, + }, + ], + }, +]; + +const displayOptions = { + show: { + operation: ['analyze'], + resource: ['video'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + return await baseAnalyze.call(this, i, 'videoUrls', 'video/mp4'); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/download.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/download.operation.ts new file mode 100644 index 0000000000..d086489fb8 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/download.operation.ts @@ -0,0 +1,64 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { downloadFile } from '../../helpers/utils'; + +const properties: INodeProperties[] = [ + { + displayName: 'URL', + name: 'url', + type: 'string', + placeholder: 'e.g. https://generativelanguage.googleapis.com/v1beta/files/abcdefg:download', + description: 'The URL from Google Gemini API to download the video from', + default: '', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Put Output in Field', + name: 'binaryPropertyOutput', + type: 'string', + default: 'data', + hint: 'The name of the output field to put the binary file data in', + }, + ], + }, +]; + +const displayOptions = { + show: { + operation: ['download'], + resource: ['video'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const url = this.getNodeParameter('url', i, '') as string; + const binaryPropertyOutput = this.getNodeParameter( + 'options.binaryPropertyOutput', + i, + 'data', + ) as string; + const credentials = await this.getCredentials('googlePalmApi'); + const { fileContent, mimeType } = await downloadFile.call(this, url, 'video/mp4', { + key: credentials.apiKey as string, + }); + const binaryData = await this.helpers.prepareBinaryData(fileContent, 'video.mp4', mimeType); + return [ + { + binary: { [binaryPropertyOutput]: binaryData }, + json: { + ...binaryData, + data: undefined, + }, + pairedItem: { item: i }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/generate.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/generate.operation.ts new file mode 100644 index 0000000000..b0f41243eb --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/generate.operation.ts @@ -0,0 +1,212 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow'; + +import type { VeoResponse } from '../../helpers/interfaces'; +import { downloadFile } from '../../helpers/utils'; +import { apiRequest } from '../../transport'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC('videoGenerationModelSearch'), + { + displayName: 'Prompt', + name: 'prompt', + type: 'string', + placeholder: 'e.g. Panning wide shot of a calico kitten sleeping in the sunshine', + description: 'A text description of the desired video', + default: '', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Return As', + name: 'returnAs', + type: 'options', + options: [ + { + name: 'Video', + value: 'video', + }, + { + name: 'URL', + value: 'url', + }, + ], + description: + 'Whether to return the video as a binary file or a URL that can be used to download the video later', + default: 'video', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Number of Videos', + name: 'sampleCount', + type: 'number', + default: 1, + description: 'How many videos to generate', + typeOptions: { + minValue: 1, + maxValue: 4, + }, + }, + { + displayName: 'Duration (Seconds)', + name: 'durationSeconds', + type: 'number', + default: 8, + description: 'Length of the generated video in seconds', + typeOptions: { + minValue: 5, + maxValue: 8, + }, + }, + { + displayName: 'Aspect Ratio', + name: 'aspectRatio', + type: 'options', + options: [ + { + name: 'Widescreen (16:9)', + value: '16:9', + description: 'Most common aspect ratio for televisions and monitors', + }, + { + name: 'Portrait (9:16)', + value: '9:16', + description: 'Popular for short-form videos like YouTube Shorts', + }, + ], + default: '16:9', + }, + { + displayName: 'Person Generation', + name: 'personGeneration', + type: 'options', + options: [ + { + name: "Don't Allow", + value: 'dont_allow', + description: 'Prevent generation of people in the video', + }, + { + name: 'Allow Adult', + value: 'allow_adult', + description: 'Allow generation of adult people in the video', + }, + { + name: 'Allow All', + value: 'allow_all', + description: 'Allow generation of all people in the video', + }, + ], + default: 'dont_allow', + }, + { + displayName: 'Put Output in Field', + name: 'binaryPropertyOutput', + type: 'string', + default: 'data', + hint: 'The name of the output field to put the binary file data in', + }, + ], + }, +]; + +const displayOptions = { + show: { + operation: ['generate'], + resource: ['video'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions, i: number): Promise { + const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string; + const prompt = this.getNodeParameter('prompt', i, '') as string; + const returnAs = this.getNodeParameter('returnAs', i, 'video'); + const options = this.getNodeParameter('options', i, {}); + const binaryPropertyOutput = this.getNodeParameter( + 'options.binaryPropertyOutput', + i, + 'data', + ) as string; + const credentials = await this.getCredentials('googlePalmApi'); + + if (!model.includes('veo')) { + throw new NodeOperationError( + this.getNode(), + `Model ${model} is not supported for video generation. Please use a Veo model`, + { + description: 'Video generation is only supported by Veo models', + }, + ); + } + + const body = { + instances: [ + { + prompt, + }, + ], + parameters: { + aspectRatio: options.aspectRatio, + personGeneration: options.personGeneration, + sampleCount: options.sampleCount ?? 1, + durationSeconds: options.durationSeconds ?? 8, + }, + }; + let response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:predictLongRunning`, { + body, + })) as VeoResponse; + + while (!response.done) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + response = (await apiRequest.call(this, 'GET', `/v1beta/${response.name}`)) as VeoResponse; + } + + if (response.error) { + throw new NodeOperationError(this.getNode(), response.error.message, { + description: 'Error generating video', + }); + } + + if (returnAs === 'video') { + const promises = response.response.generateVideoResponse.generatedSamples.map( + async (sample) => { + const { fileContent, mimeType } = await downloadFile.call( + this, + sample.video.uri, + 'video/mp4', + { + key: credentials.apiKey as string, + }, + ); + const binaryData = await this.helpers.prepareBinaryData(fileContent, 'video.mp4', mimeType); + return { + binary: { [binaryPropertyOutput]: binaryData }, + json: { + ...binaryData, + data: undefined, + }, + pairedItem: { item: i }, + }; + }, + ); + + return await Promise.all(promises); + } else { + return response.response.generateVideoResponse.generatedSamples.map((sample) => ({ + json: { + url: sample.video.uri, + }, + pairedItem: { item: i }, + })); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/index.ts new file mode 100644 index 0000000000..1ac19bacf6 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/video/index.ts @@ -0,0 +1,45 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as analyze from './analyze.operation'; +import * as download from './download.operation'; +import * as generate from './generate.operation'; + +export { analyze, download, generate }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Analyze Video', + value: 'analyze', + action: 'Analyze video', + description: 'Take in videos and answer questions about them', + }, + { + name: 'Generate a Video', + value: 'generate', + action: 'Generate a video', + description: 'Creates a video from a text prompt', + }, + { + name: 'Download Video', + value: 'download', + action: 'Download a video', + description: 'Download a generated video from the Google Gemini API using a URL', + }, + ], + default: 'generate', + displayOptions: { + show: { + resource: ['video'], + }, + }, + }, + ...analyze.description, + ...download.description, + ...generate.description, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/gemini.svg b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/gemini.svg new file mode 100644 index 0000000000..c297309b4c --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/gemini.svg @@ -0,0 +1 @@ + diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/baseAnalyze.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/baseAnalyze.ts new file mode 100644 index 0000000000..050c5ad71f --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/baseAnalyze.ts @@ -0,0 +1,98 @@ +import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; + +import type { Content, GenerateContentResponse } from './interfaces'; +import { downloadFile, uploadFile } from './utils'; +import { apiRequest } from '../transport'; + +export async function baseAnalyze( + this: IExecuteFunctions, + i: number, + urlsPropertyName: string, + fallbackMimeType: string, +): Promise { + const model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string; + const inputType = this.getNodeParameter('inputType', i, 'url') as string; + const text = this.getNodeParameter('text', i, '') as string; + const simplify = this.getNodeParameter('simplify', i, true) as boolean; + const options = this.getNodeParameter('options', i, {}); + + const generationConfig = { + maxOutputTokens: options.maxOutputTokens, + }; + + let contents: Content[]; + if (inputType === 'url') { + const urls = this.getNodeParameter(urlsPropertyName, i, '') as string; + const filesDataPromises = urls + .split(',') + .map((url) => url.trim()) + .filter((url) => url) + .map(async (url) => { + if (url.startsWith('https://generativelanguage.googleapis.com')) { + const { mimeType } = (await apiRequest.call(this, 'GET', '', { + option: { url }, + })) as { mimeType: string }; + return { fileUri: url, mimeType }; + } else { + const { fileContent, mimeType } = await downloadFile.call(this, url, fallbackMimeType); + return await uploadFile.call(this, fileContent, mimeType); + } + }); + + const filesData = await Promise.all(filesDataPromises); + contents = [ + { + role: 'user', + parts: filesData.map((fileData) => ({ + fileData, + })), + }, + ]; + } else { + const binaryPropertyNames = this.getNodeParameter('binaryPropertyName', i, 'data'); + const promises = binaryPropertyNames + .split(',') + .map((binaryPropertyName) => binaryPropertyName.trim()) + .filter((binaryPropertyName) => binaryPropertyName) + .map(async (binaryPropertyName) => { + const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); + const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + return await uploadFile.call(this, buffer, binaryData.mimeType); + }); + + const filesData = await Promise.all(promises); + contents = [ + { + role: 'user', + parts: filesData.map((fileData) => ({ + fileData, + })), + }, + ]; + } + + contents[0].parts.push({ text }); + + const body = { + contents, + generationConfig, + }; + + const response = (await apiRequest.call(this, 'POST', `/v1beta/${model}:generateContent`, { + body, + })) as GenerateContentResponse; + + if (simplify) { + return response.candidates.map((candidate) => ({ + json: candidate, + pairedItem: { item: i }, + })); + } + + return [ + { + json: { ...response }, + pairedItem: { item: i }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/interfaces.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/interfaces.ts new file mode 100644 index 0000000000..9a53e736ea --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/interfaces.ts @@ -0,0 +1,74 @@ +import type { IDataObject } from 'n8n-workflow'; + +export interface GenerateContentResponse { + candidates: Array<{ + content: Content; + }>; +} + +export interface Content { + parts: Part[]; + role: string; +} + +export type Part = + | { text: string } + | { + inlineData: { + mimeType: string; + data: string; + }; + } + | { + functionCall: { + id?: string; + name: string; + args?: IDataObject; + }; + } + | { + functionResponse: { + id?: string; + name: string; + response: IDataObject; + }; + } + | { + fileData?: { + mimeType?: string; + fileUri?: string; + }; + }; + +export interface ImagenResponse { + predictions: Array<{ + bytesBase64Encoded: string; + mimeType: string; + }>; +} + +export interface VeoResponse { + name: string; + done: boolean; + error?: { + message: string; + }; + response: { + generateVideoResponse: { + generatedSamples: Array<{ + video: { + uri: string; + }; + }>; + }; + }; +} + +export interface Tool { + functionDeclarations?: Array<{ + name: string; + description: string; + parameters: IDataObject; + }>; + codeExecution?: object; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/utils.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/utils.test.ts new file mode 100644 index 0000000000..a023e94c22 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/utils.test.ts @@ -0,0 +1,180 @@ +import { mockDeep } from 'jest-mock-extended'; +import type { IExecuteFunctions } from 'n8n-workflow'; + +import { downloadFile, uploadFile } from './utils'; +import * as transport from '../transport'; + +describe('GoogleGemini -> utils', () => { + const mockExecuteFunctions = mockDeep(); + const apiRequestMock = jest.spyOn(transport, 'apiRequest'); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers({ advanceTimers: true }); + }); + + describe('downloadFile', () => { + it('should download file', async () => { + mockExecuteFunctions.helpers.httpRequest.mockResolvedValue({ + body: new ArrayBuffer(10), + headers: { + 'content-type': 'application/pdf', + }, + }); + + const file = await downloadFile.call(mockExecuteFunctions, 'https://example.com/file.pdf'); + + expect(file).toEqual({ + fileContent: Buffer.from(new ArrayBuffer(10)), + mimeType: 'application/pdf', + }); + expect(mockExecuteFunctions.helpers.httpRequest).toHaveBeenCalledWith({ + method: 'GET', + url: 'https://example.com/file.pdf', + returnFullResponse: true, + encoding: 'arraybuffer', + }); + }); + + it('should parse mime type from content type header', async () => { + mockExecuteFunctions.helpers.httpRequest.mockResolvedValue({ + body: new ArrayBuffer(10), + headers: { + 'content-type': 'application/pdf; q=0.9', + }, + }); + + const file = await downloadFile.call(mockExecuteFunctions, 'https://example.com/file.pdf'); + + expect(file).toEqual({ + fileContent: Buffer.from(new ArrayBuffer(10)), + mimeType: 'application/pdf', + }); + }); + + it('should use fallback mime type if content type header is not present', async () => { + mockExecuteFunctions.helpers.httpRequest.mockResolvedValue({ + body: new ArrayBuffer(10), + headers: {}, + }); + + const file = await downloadFile.call( + mockExecuteFunctions, + 'https://example.com/file.pdf', + 'application/pdf', + ); + + expect(file).toEqual({ + fileContent: Buffer.from(new ArrayBuffer(10)), + mimeType: 'application/pdf', + }); + }); + }); + + describe('uploadFile', () => { + it('should upload file', async () => { + const fileContent = Buffer.from(new ArrayBuffer(10)); + const mimeType = 'application/pdf'; + + apiRequestMock.mockResolvedValue({ + headers: { + 'x-goog-upload-url': 'https://google.com/some-upload-url', + }, + }); + mockExecuteFunctions.helpers.httpRequest.mockResolvedValue({ + file: { + name: 'files/test123', + uri: 'https://google.com/files/test123', + mimeType: 'application/pdf', + state: 'ACTIVE', + }, + }); + + const file = await uploadFile.call(mockExecuteFunctions, fileContent, mimeType); + + expect(file).toEqual({ + fileUri: 'https://google.com/files/test123', + mimeType: 'application/pdf', + }); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/upload/v1beta/files', { + headers: { + 'X-Goog-Upload-Protocol': 'resumable', + 'X-Goog-Upload-Command': 'start', + 'X-Goog-Upload-Header-Content-Length': '10', + 'X-Goog-Upload-Header-Content-Type': 'application/pdf', + 'Content-Type': 'application/json', + }, + option: { + returnFullResponse: true, + }, + }); + expect(mockExecuteFunctions.helpers.httpRequest).toHaveBeenCalledWith({ + method: 'POST', + url: 'https://google.com/some-upload-url', + headers: { + 'Content-Length': '10', + 'X-Goog-Upload-Offset': '0', + 'X-Goog-Upload-Command': 'upload, finalize', + }, + body: fileContent, + }); + }); + + it('should throw error if file upload fails', async () => { + const fileContent = Buffer.from(new ArrayBuffer(10)); + const mimeType = 'application/pdf'; + apiRequestMock.mockResolvedValue({ + headers: { + 'x-goog-upload-url': 'https://google.com/some-upload-url', + }, + }); + mockExecuteFunctions.helpers.httpRequest.mockResolvedValue({ + file: { + state: 'FAILED', + error: { + message: 'File upload failed', + }, + }, + }); + + await expect(uploadFile.call(mockExecuteFunctions, fileContent, mimeType)).rejects.toThrow( + 'File upload failed', + ); + }); + + it('should upload file when its not immediately active', async () => { + const fileContent = Buffer.from(new ArrayBuffer(10)); + const mimeType = 'application/pdf'; + + apiRequestMock.mockResolvedValueOnce({ + headers: { + 'x-goog-upload-url': 'https://google.com/some-upload-url', + }, + }); + mockExecuteFunctions.helpers.httpRequest.mockResolvedValue({ + file: { + name: 'files/test123', + uri: 'https://google.com/files/test123', + mimeType: 'application/pdf', + state: 'PENDING', + }, + }); + apiRequestMock.mockResolvedValueOnce({ + name: 'files/test123', + uri: 'https://google.com/files/test123', + mimeType: 'application/pdf', + state: 'ACTIVE', + }); + + const promise = uploadFile.call(mockExecuteFunctions, fileContent, mimeType); + await jest.advanceTimersByTimeAsync(1000); + const file = await promise; + + expect(file).toEqual({ + fileUri: 'https://google.com/files/test123', + mimeType: 'application/pdf', + }); + expect(apiRequestMock).toHaveBeenCalledWith('GET', '/v1beta/files/test123'); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/utils.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/utils.ts new file mode 100644 index 0000000000..8b337fc27b --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/helpers/utils.ts @@ -0,0 +1,84 @@ +import type { IDataObject, IExecuteFunctions } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { apiRequest } from '../transport'; + +interface File { + name: string; + uri: string; + mimeType: string; + state: string; + error?: { message: string }; +} + +export async function downloadFile( + this: IExecuteFunctions, + url: string, + fallbackMimeType?: string, + qs?: IDataObject, +) { + const downloadResponse = (await this.helpers.httpRequest({ + method: 'GET', + url, + qs, + returnFullResponse: true, + encoding: 'arraybuffer', + })) as { body: ArrayBuffer; headers: IDataObject }; + + const mimeType = + (downloadResponse.headers?.['content-type'] as string)?.split(';')?.[0] ?? fallbackMimeType; + const fileContent = Buffer.from(downloadResponse.body); + return { + fileContent, + mimeType, + }; +} + +export async function uploadFile(this: IExecuteFunctions, fileContent: Buffer, mimeType: string) { + const numBytes = fileContent.length.toString(); + const uploadInitResponse = (await apiRequest.call(this, 'POST', '/upload/v1beta/files', { + headers: { + 'X-Goog-Upload-Protocol': 'resumable', + 'X-Goog-Upload-Command': 'start', + 'X-Goog-Upload-Header-Content-Length': numBytes, + 'X-Goog-Upload-Header-Content-Type': mimeType, + 'Content-Type': 'application/json', + }, + option: { + returnFullResponse: true, + }, + })) as { headers: IDataObject }; + const uploadUrl = uploadInitResponse.headers['x-goog-upload-url'] as string; + + const uploadResponse = (await this.helpers.httpRequest({ + method: 'POST', + url: uploadUrl, + headers: { + 'Content-Length': numBytes, + 'X-Goog-Upload-Offset': '0', + 'X-Goog-Upload-Command': 'upload, finalize', + }, + body: fileContent, + })) as { file: File }; + + while (uploadResponse.file.state !== 'ACTIVE' && uploadResponse.file.state !== 'FAILED') { + await new Promise((resolve) => setTimeout(resolve, 1000)); + uploadResponse.file = (await apiRequest.call( + this, + 'GET', + `/v1beta/${uploadResponse.file.name}`, + )) as File; + } + + if (uploadResponse.file.state === 'FAILED') { + throw new NodeOperationError( + this.getNode(), + uploadResponse.file.error?.message ?? 'Unknown error', + { + description: 'Error uploading file', + }, + ); + } + + return { fileUri: uploadResponse.file.uri, mimeType: uploadResponse.file.mimeType }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/methods/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/methods/index.ts new file mode 100644 index 0000000000..c7fb720e47 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/methods/index.ts @@ -0,0 +1 @@ +export * as listSearch from './listSearch'; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/methods/listSearch.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/methods/listSearch.test.ts new file mode 100644 index 0000000000..1a18216094 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/methods/listSearch.test.ts @@ -0,0 +1,150 @@ +import { mock } from 'jest-mock-extended'; +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { + audioModelSearch, + imageGenerationModelSearch, + modelSearch, + videoGenerationModelSearch, +} from './listSearch'; +import * as transport from '../transport'; + +const mockResponse = { + models: [ + { + name: 'models/gemini-pro-vision', + }, + { + name: 'models/gemini-2.5-flash', + }, + { + name: 'models/gemini-2.0-flash-exp-image-generation', + }, + { + name: 'models/gemini-2.5-pro-preview-tts', + }, + { + name: 'models/gemma-3-1b-it', + }, + { + name: 'models/embedding-001', + }, + { + name: 'models/imagen-3.0-generate-002', + }, + { + name: 'models/veo-2.0-generate-001', + }, + { + name: 'models/gemini-2.5-flash-preview-native-audio-dialog', + }, + ], +}; + +describe('GoogleGemini -> listSearch', () => { + const mockExecuteFunctions = mock(); + const apiRequestMock = jest.spyOn(transport, 'apiRequest'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('modelSearch', () => { + it('should return regular models', async () => { + apiRequestMock.mockResolvedValue(mockResponse); + + const result = await modelSearch.call(mockExecuteFunctions); + + expect(result).toEqual({ + results: [ + { + name: 'models/gemini-2.5-flash', + value: 'models/gemini-2.5-flash', + }, + { + name: 'models/gemma-3-1b-it', + value: 'models/gemma-3-1b-it', + }, + ], + }); + }); + + it('should return regular models with filter', async () => { + apiRequestMock.mockResolvedValue(mockResponse); + + const result = await modelSearch.call(mockExecuteFunctions, 'Gemma'); + + expect(result).toEqual({ + results: [ + { + name: 'models/gemma-3-1b-it', + value: 'models/gemma-3-1b-it', + }, + ], + }); + }); + }); + + describe('audioModelSearch', () => { + it('should return audio models', async () => { + apiRequestMock.mockResolvedValue(mockResponse); + + const result = await audioModelSearch.call(mockExecuteFunctions); + + expect(result).toEqual({ + results: [ + { + name: 'models/gemini-2.5-flash', + value: 'models/gemini-2.5-flash', + }, + { + name: 'models/gemma-3-1b-it', + value: 'models/gemma-3-1b-it', + }, + { + name: 'models/gemini-2.5-flash-preview-native-audio-dialog', + value: 'models/gemini-2.5-flash-preview-native-audio-dialog', + }, + ], + }); + }); + }); + + describe('imageModelSearch', () => { + it('should return image models', async () => { + apiRequestMock.mockResolvedValue(mockResponse); + + const result = await imageGenerationModelSearch.call(mockExecuteFunctions); + + expect(result).toEqual({ + results: [ + { + name: 'models/gemini-2.0-flash-exp-image-generation', + value: 'models/gemini-2.0-flash-exp-image-generation', + }, + { + name: 'models/imagen-3.0-generate-002', + value: 'models/imagen-3.0-generate-002', + }, + ], + }); + }); + }); + + describe('videoModelSearch', () => { + it('should return video models', async () => { + apiRequestMock.mockResolvedValue(mockResponse); + + const result = await videoGenerationModelSearch.call(mockExecuteFunctions); + + expect(result).toEqual({ + results: [ + { + name: 'models/veo-2.0-generate-001', + value: 'models/veo-2.0-generate-001', + }, + ], + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/methods/listSearch.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/methods/listSearch.ts new file mode 100644 index 0000000000..b59bb2d388 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/methods/listSearch.ts @@ -0,0 +1,79 @@ +import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow'; + +import { apiRequest } from '../transport'; + +async function baseModelSearch( + this: ILoadOptionsFunctions, + modelFilter: (model: string) => boolean, + filter?: string, +): Promise { + const response = (await apiRequest.call(this, 'GET', '/v1beta/models', { + qs: { + pageSize: 1000, + }, + })) as { + models: Array<{ name: string }>; + }; + + let models = response.models.filter((model) => modelFilter(model.name)); + if (filter) { + models = models.filter((model) => model.name.toLowerCase().includes(filter.toLowerCase())); + } + + return { + results: models.map((model) => ({ name: model.name, value: model.name })), + }; +} + +export async function modelSearch( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + return await baseModelSearch.call( + this, + (model) => + !model.includes('embedding') && + !model.includes('aqa') && + !model.includes('image') && + !model.includes('vision') && + !model.includes('veo') && + !model.includes('audio') && + !model.includes('tts'), + filter, + ); +} + +export async function audioModelSearch( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + return await baseModelSearch.call( + this, + (model) => + !model.includes('embedding') && + !model.includes('aqa') && + !model.includes('image') && + !model.includes('vision') && + !model.includes('veo') && + !model.includes('tts'), // we don't have a tts operation + filter, + ); +} + +export async function imageGenerationModelSearch( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + return await baseModelSearch.call( + this, + (model) => model.includes('imagen') || model.includes('image-generation'), + filter, + ); +} + +export async function videoGenerationModelSearch( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + return await baseModelSearch.call(this, (model) => model.includes('veo'), filter); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/transport/index.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/transport/index.test.ts new file mode 100644 index 0000000000..15c8a21f01 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/transport/index.test.ts @@ -0,0 +1,83 @@ +import type { IExecuteFunctions } from 'n8n-workflow'; +import { mockDeep } from 'jest-mock-extended'; +import { apiRequest } from '.'; + +describe('GoogleGemini transport', () => { + const executeFunctionsMock = mockDeep(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call httpRequestWithAuthentication with correct parameters', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + url: 'https://custom-url.com', + }); + + await apiRequest.call(executeFunctionsMock, 'GET', '/v1beta/models', { + headers: { + 'Content-Type': 'application/json', + }, + body: { + foo: 'bar', + }, + qs: { + test: 123, + }, + }); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'googlePalmApi', + { + method: 'GET', + url: 'https://custom-url.com/v1beta/models', + json: true, + body: { + foo: 'bar', + }, + qs: { + test: 123, + }, + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + }); + + it('should use the default url if no custom url is provided', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({}); + + await apiRequest.call(executeFunctionsMock, 'GET', '/v1beta/models'); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'googlePalmApi', + { + method: 'GET', + url: 'https://generativelanguage.googleapis.com/v1beta/models', + json: true, + }, + ); + }); + + it('should override the values with `option`', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({}); + + await apiRequest.call(executeFunctionsMock, 'GET', '', { + option: { + url: 'https://custom-url.com', + returnFullResponse: true, + }, + }); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'googlePalmApi', + { + method: 'GET', + url: 'https://custom-url.com', + json: true, + returnFullResponse: true, + }, + ); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/transport/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/transport/index.ts new file mode 100644 index 0000000000..c9995a8cd8 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/transport/index.ts @@ -0,0 +1,45 @@ +import type { + IDataObject, + IExecuteFunctions, + IHttpRequestMethods, + ILoadOptionsFunctions, +} from 'n8n-workflow'; + +type RequestParameters = { + headers?: IDataObject; + body?: IDataObject | string; + qs?: IDataObject; + option?: IDataObject; +}; + +export async function apiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + endpoint: string, + parameters?: RequestParameters, +) { + const { body, qs, option, headers } = parameters ?? {}; + + const credentials = await this.getCredentials('googlePalmApi'); + + let url = `https://generativelanguage.googleapis.com${endpoint}`; + + if (credentials.url) { + url = `${credentials?.url as string}${endpoint}`; + } + + const options = { + headers, + method, + body, + qs, + url, + json: true, + }; + + if (option && Object.keys(option).length !== 0) { + Object.assign(options, option); + } + + return await this.helpers.httpRequestWithAuthentication.call(this, 'googlePalmApi', options); +} diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index ede0aa2499..3484881975 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -48,6 +48,7 @@ "dist/credentials/ZepApi.credentials.js" ], "nodes": [ + "dist/nodes/vendors/GoogleGemini/GoogleGemini.node.js", "dist/nodes/vendors/OpenAi/OpenAi.node.js", "dist/nodes/agents/Agent/Agent.node.js", "dist/nodes/agents/Agent/AgentTool.node.js",