first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -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);
}
}
@@ -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<INodeExecutionData[]> {
return await baseAnalyze.call(this, i, 'audioUrls', 'audio/mpeg');
}
@@ -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,
];
@@ -0,0 +1,185 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import type {
Content,
GenerateContentRequest,
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<INodeExecutionData[]> {
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: GenerateContentRequest = {
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 },
},
];
}
@@ -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',
},
],
});
@@ -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<INodeExecutionData[]> {
return await baseAnalyze.call(this, i, 'documentUrls', 'application/pdf');
}
@@ -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,
];
@@ -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 Media File',
value: 'upload',
action: 'Upload a media file',
description: 'Upload a file to the Google Gemini API for later use',
},
],
default: 'upload',
displayOptions: {
show: {
resource: ['file'],
},
},
},
...upload.description,
];
@@ -0,0 +1,78 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { transferFile } 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<INodeExecutionData[]> {
const inputType = this.getNodeParameter('inputType', i, 'url') as string;
let fileUrl: string | undefined;
if (inputType === 'url') {
fileUrl = this.getNodeParameter('fileUrl', i, '') as string;
}
const response = await transferFile.call(this, i, fileUrl, 'application/octet-stream');
return [
{
json: response,
pairedItem: {
item: i,
},
},
];
}
@@ -0,0 +1,39 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { createFileSearchStore } from '../../helpers/utils';
export const properties: INodeProperties[] = [
{
displayName: 'Display Name',
name: 'displayName',
type: 'string',
placeholder: 'e.g. My File Search Store',
description: 'A human-readable name for the File Search store',
default: '',
required: true,
},
];
const displayOptions = {
show: {
operation: ['createStore'],
resource: ['fileSearch'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const displayName = this.getNodeParameter('displayName', i, '') as string;
const response = await createFileSearchStore.call(this, displayName);
return [
{
json: response,
pairedItem: {
item: i,
},
},
];
}
@@ -0,0 +1,48 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { deleteFileSearchStore } from '../../helpers/utils';
export const properties: INodeProperties[] = [
{
displayName: 'File Search Store Name',
name: 'fileSearchStoreName',
type: 'string',
placeholder: 'e.g. fileSearchStores/abc123',
description: 'The full name of the File Search store to delete (format: fileSearchStores/...)',
default: '',
required: true,
},
{
displayName: 'Force Delete',
name: 'force',
type: 'boolean',
description:
'Whether to delete related Documents and objects. If false, deletion will fail if the store contains any Documents.',
default: false,
},
];
const displayOptions = {
show: {
operation: ['deleteStore'],
resource: ['fileSearch'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const fileSearchStoreName = this.getNodeParameter('fileSearchStoreName', i, '') as string;
const force = this.getNodeParameter('force', i, false) as boolean | undefined;
const response = await deleteFileSearchStore.call(this, fileSearchStoreName, force);
return [
{
json: response,
pairedItem: {
item: i,
},
},
];
}
@@ -0,0 +1,54 @@
import type { INodeProperties } from 'n8n-workflow';
import * as createStore from './createStore.operation';
import * as deleteStore from './deleteStore.operation';
import * as listStores from './listStores.operation';
import * as uploadToStore from './uploadToStore.operation';
export { createStore, deleteStore, listStores, uploadToStore };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Create File Search Store',
value: 'createStore',
action: 'Create a File Search store',
description: 'Create a new File Search store for RAG (Retrieval Augmented Generation)',
},
{
name: 'Delete File Search Store',
value: 'deleteStore',
action: 'Delete a File Search store',
description: 'Delete a File Search store',
},
{
name: 'List File Search Stores',
value: 'listStores',
action: 'List all File Search stores',
description: 'List all File Search stores owned by the user',
},
{
name: 'Upload to File Search Store',
value: 'uploadToStore',
action: 'Upload a file to a File Search store',
description:
'Upload a file to a File Search store for RAG (Retrieval Augmented Generation)',
},
],
default: 'createStore',
displayOptions: {
show: {
resource: ['fileSearch'],
},
},
},
...createStore.description,
...deleteStore.description,
...listStores.description,
...uploadToStore.description,
];
@@ -0,0 +1,50 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { listFileSearchStores } from '../../helpers/utils';
export const properties: INodeProperties[] = [
{
displayName: 'Page Size',
name: 'pageSize',
type: 'number',
description: 'Maximum number of File Search stores to return per page (max 20)',
default: 10,
typeOptions: {
minValue: 1,
maxValue: 20,
},
},
{
displayName: 'Page Token',
name: 'pageToken',
// eslint-disable-next-line -- pageToken is a pagination token, not a password
type: 'string',
description: 'Token from a previous page to retrieve the next page of results',
default: '',
},
];
const displayOptions = {
show: {
operation: ['listStores'],
resource: ['fileSearch'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const pageSize = this.getNodeParameter('pageSize', i) as number | undefined;
const pageToken = this.getNodeParameter('pageToken', i, '') as string | undefined;
const response = await listFileSearchStores.call(this, pageSize, pageToken);
return [
{
json: response,
pairedItem: {
item: i,
},
},
];
}
@@ -0,0 +1,107 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { uploadToFileSearchStore } from '../../helpers/utils';
export const properties: INodeProperties[] = [
{
displayName: 'File Search Store Name',
name: 'fileSearchStoreName',
type: 'string',
placeholder: 'e.g. fileSearchStores/abc123',
description:
'The full name of the File Search store to upload to (format: fileSearchStores/...)',
default: '',
required: true,
},
{
displayName: 'File Display Name',
name: 'displayName',
type: 'string',
placeholder: 'e.g. My Document',
description: 'A human-readable name for the file (will be visible in citations)',
default: '',
required: true,
},
{
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: ['uploadToStore'],
resource: ['fileSearch'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const fileSearchStoreName = this.getNodeParameter('fileSearchStoreName', i, '') as string;
const displayName = this.getNodeParameter('displayName', i, '') as string;
const inputType = this.getNodeParameter('inputType', i, 'url') as string;
let fileUrl: string | undefined;
if (inputType === 'url') {
fileUrl = this.getNodeParameter('fileUrl', i, '') as string;
}
const response = await uploadToFileSearchStore.call(
this,
i,
fileSearchStoreName,
displayName,
fileUrl,
'application/octet-stream',
);
return [
{
json: response ?? {},
pairedItem: {
item: i,
},
},
];
}
@@ -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<INodeExecutionData[]> {
return await baseAnalyze.call(this, i, 'imageUrls', 'image/png');
}
@@ -0,0 +1,248 @@
import { mock, mockDeep } from 'jest-mock-extended';
import type { IBinaryData, ICredentialDataDecryptedObject, IExecuteFunctions } from 'n8n-workflow';
import { execute } from './edit.operation';
const getMockedExecuteFunctions = ({
prompt = 'add bananas',
outputProperty = 'edited',
images = { values: [{ binaryPropertyName: 'data' }] },
outputBuffer = Buffer.from('edited image'),
mimeType = 'image/png',
invalidApiResponse = false,
}: {
prompt?: string;
outputProperty?: string;
images?: { values: Array<{ binaryPropertyName: string }> };
outputBuffer?: Buffer<ArrayBuffer>;
mimeType?: string;
invalidApiResponse?: boolean;
} = {}): IExecuteFunctions => {
const executeFunctions = mockDeep<IExecuteFunctions>();
executeFunctions.getCredentials
.calledWith('googlePalmApi')
.mockResolvedValue(mock<ICredentialDataDecryptedObject>());
executeFunctions.getNodeParameter.calledWith('prompt', 0).mockReturnValue(prompt);
executeFunctions.getNodeParameter
.calledWith('options.binaryPropertyOutput', 0)
.mockReturnValue(outputProperty);
executeFunctions.getNodeParameter.calledWith('images', 0).mockReturnValue(images);
executeFunctions.helpers.assertBinaryData.mockReturnValue(mock<IBinaryData>({ mimeType }));
executeFunctions.helpers.getBinaryDataBuffer.mockImplementation(async (_index, propertyName) =>
Buffer.from(`${propertyName} data`),
);
executeFunctions.helpers.prepareBinaryData.mockImplementation(async (buffer, filename, mime) => ({
data: (buffer as Buffer).toString('base64'),
fileName: filename ?? 'image.png',
fileSize: (buffer as Buffer).length.toString(),
mimeType: mime ?? mimeType,
}));
executeFunctions.helpers.httpRequest
.calledWith(
expect.objectContaining({
url: expect.stringContaining('/upload/v1beta/files'),
}),
)
.mockResolvedValue({
headers: { 'x-goog-upload-url': 'https://mock-upload-url.com' },
});
executeFunctions.helpers.httpRequestWithAuthentication
.calledWith(
'googlePalmApi',
expect.objectContaining({
headers: expect.objectContaining({ 'X-Goog-Upload-Protocol': expect.any(String) }),
}),
)
.mockResolvedValue({
headers: { 'x-goog-upload-url': 'https://mock-upload-url.com' },
});
executeFunctions.helpers.httpRequestWithAuthentication
.calledWith(
'googlePalmApi',
expect.objectContaining({
url: expect.stringContaining('generateContent'),
}),
)
.mockResolvedValue(
invalidApiResponse
? { invalid: 'response' }
: {
candidates: [
{
content: {
parts: [{ inlineData: { data: outputBuffer.toString('base64'), mimeType } }],
},
},
],
},
);
executeFunctions.helpers.httpRequest
.calledWith(
expect.objectContaining({
method: 'POST',
url: expect.stringContaining('mock-upload-url'),
}),
)
.mockResolvedValue({
file: { name: 'files/test', uri: 'mockFileUri', mimeType, state: 'ACTIVE' },
});
return executeFunctions;
};
describe('Gemini Node image edit', () => {
it('should edit an image successfully', async () => {
const executeFunctions = getMockedExecuteFunctions();
const result = await execute.call(executeFunctions, 0);
expect(result).toEqual([
{
binary: {
edited: {
data: 'ZWRpdGVkIGltYWdl',
fileName: 'image.png',
fileSize: '12',
mimeType: 'image/png',
},
},
json: { fileName: 'image.png', fileSize: '12', mimeType: 'image/png', data: undefined },
pairedItem: { item: 0 },
},
]);
});
it('should handle custom output property name', async () => {
const executeFunctions = getMockedExecuteFunctions({
prompt: 'edit this image',
outputProperty: 'custom_output',
});
const result = await execute.call(executeFunctions, 0);
expect(result[0].binary).toHaveProperty('custom_output');
expect(result[0].binary).not.toHaveProperty('edited');
});
it('should handle multiple images', async () => {
const multiImageConfig = {
values: [{ binaryPropertyName: 'image1' }, { binaryPropertyName: 'image2' }],
};
const executeFunctions = getMockedExecuteFunctions({
prompt: 'combine images',
outputProperty: 'combined',
images: multiImageConfig,
outputBuffer: Buffer.from('combined image'),
});
const result = await execute.call(executeFunctions, 0);
expect(result[0].binary).toHaveProperty('combined');
});
it('should throw error for invalid images parameter', async () => {
const executeFunctions = getMockedExecuteFunctions(
{ prompt: 'test prompt', outputProperty: 'edited', images: { values: 'invalid' as never } }, // Invalid format
);
await expect(execute.call(executeFunctions, 0)).rejects.toThrow(
'Invalid images parameter format',
);
});
it('should throw error when no image data returned from API', async () => {
const executeFunctions = getMockedExecuteFunctions(
{
prompt: 'test prompt',
outputProperty: 'edited',
images: { values: [{ binaryPropertyName: 'data' }] },
outputBuffer: Buffer.from(''),
}, // Empty buffer to trigger error
);
await expect(execute.call(executeFunctions, 0)).rejects.toThrow(
'No image data returned from Gemini API',
);
});
it('should throw error for invalid API response format', async () => {
const executeFunctions = getMockedExecuteFunctions({
prompt: 'test prompt',
outputProperty: 'edited',
images: { values: [{ binaryPropertyName: 'data' }] },
invalidApiResponse: true,
});
await expect(execute.call(executeFunctions, 0)).rejects.toThrow(
'Invalid response format from Gemini API',
);
});
it('should handle empty prompt', async () => {
const executeFunctions = getMockedExecuteFunctions({ prompt: '' });
const result = await execute.call(executeFunctions, 0);
expect(result).toEqual([
{
binary: {
edited: {
data: 'ZWRpdGVkIGltYWdl',
fileName: 'image.png',
fileSize: '12',
mimeType: 'image/png',
},
},
json: { fileName: 'image.png', fileSize: '12', mimeType: 'image/png', data: undefined },
pairedItem: { item: 0 },
},
]);
});
it('should handle different MIME types', async () => {
const executeFunctions = getMockedExecuteFunctions({
prompt: 'enhance image',
outputProperty: 'enhanced',
images: { values: [{ binaryPropertyName: 'data' }] },
outputBuffer: Buffer.from('enhanced jpeg'),
mimeType: 'image/jpeg',
});
const result = await execute.call(executeFunctions, 0);
expect(result[0]?.binary?.enhanced?.mimeType).toBe('image/jpeg');
expect(result[0]?.json?.mimeType).toBe('image/jpeg');
});
it('should handle empty images array when no valid binary property names', async () => {
const executeFunctions = getMockedExecuteFunctions({
prompt: 'test prompt',
outputProperty: 'edited',
images: { values: [{ binaryPropertyName: '' }] },
outputBuffer: Buffer.from('no image response'),
});
const result = await execute.call(executeFunctions, 0);
expect(result).toEqual([
{
binary: {
edited: {
data: 'bm8gaW1hZ2UgcmVzcG9uc2U=',
fileName: 'image.png',
fileSize: '17',
mimeType: 'image/png',
},
},
json: { fileName: 'image.png', fileSize: '17', mimeType: 'image/png', data: undefined },
pairedItem: { item: 0 },
},
]);
});
});
@@ -0,0 +1,232 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import type { GenerateContentResponse } from '../../helpers/interfaces';
import { uploadFile } from '../../helpers/utils';
import { apiRequest } from '../../transport';
import { modelRLC } from '../descriptions';
interface ImagesParameter {
values?: Array<{ binaryPropertyName?: string }>;
}
function isImagesParameter(param: unknown): param is ImagesParameter {
if (typeof param !== 'object' || param === null) {
return false;
}
const paramObj = param as Record<string, unknown>;
if (!('values' in paramObj)) {
return true; // values is optional
}
if (!Array.isArray(paramObj.values)) {
return false;
}
return paramObj.values.every((item: unknown) => {
if (typeof item !== 'object' || item === null) {
return false;
}
const itemObj = item as Record<string, unknown>;
if (!('binaryPropertyName' in itemObj)) {
return true; // binaryPropertyName is optional
}
return (
typeof itemObj.binaryPropertyName === 'string' || itemObj.binaryPropertyName === undefined
);
});
}
function isGenerateContentResponse(response: unknown): response is GenerateContentResponse {
if (typeof response !== 'object' || response === null) {
return false;
}
const responseObj = response as Record<string, unknown>;
if (!('candidates' in responseObj) || !Array.isArray(responseObj.candidates)) {
return false;
}
return responseObj.candidates.every((candidate: unknown) => {
if (typeof candidate !== 'object' || candidate === null) {
return false;
}
const candidateObj = candidate as Record<string, unknown>;
if (
!('content' in candidateObj) ||
typeof candidateObj.content !== 'object' ||
candidateObj.content === null
) {
return false;
}
const contentObj = candidateObj.content as Record<string, unknown>;
return 'parts' in contentObj && Array.isArray(contentObj.parts);
});
}
const properties: INodeProperties[] = [
modelRLC('imageEditModelSearch'),
{
displayName: 'Prompt',
name: 'prompt',
type: 'string',
placeholder: 'e.g. combine the first image with the second image',
description: 'Instruction describing how to edit the image',
default: '',
typeOptions: {
rows: 2,
},
},
{
displayName: 'Images',
name: 'images',
type: 'fixedCollection',
placeholder: 'Add Image',
typeOptions: {
multipleValues: true,
multipleValueButtonText: 'Add Image',
},
default: { values: [{ binaryPropertyName: 'data' }] },
description: 'Add one or more binary fields to include images with your prompt',
options: [
{
displayName: 'Image',
name: 'values',
values: [
{
displayName: 'Binary Field Name',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
placeholder: 'e.g. data',
description: 'The name of the binary field containing the image data',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Put Output in Field',
name: 'binaryPropertyOutput',
type: 'string',
default: 'edited',
hint: 'The name of the output field to put the binary file data in',
},
],
},
];
const displayOptions = {
show: {
operation: ['edit'],
resource: ['image'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const prompt = this.getNodeParameter('prompt', i, '');
let model = this.getNodeParameter('modelId', i, '', { extractValue: true }) as string;
if (!model) {
model = 'models/gemini-2.5-flash-image-preview';
}
const binaryPropertyOutput = this.getNodeParameter('options.binaryPropertyOutput', i, 'edited');
const outputKey = typeof binaryPropertyOutput === 'string' ? binaryPropertyOutput : 'data';
// Collect image binary field names from collection
const imagesParam = this.getNodeParameter('images', i, {
values: [{ binaryPropertyName: 'data' }],
});
if (!isImagesParameter(imagesParam)) {
throw new Error('Invalid images parameter format');
}
const imagesUi = imagesParam.values ?? [];
const imageFieldNames = imagesUi
.map((v) => v.binaryPropertyName)
.filter((n): n is string => Boolean(n));
// Upload all images and gather fileData parts
const fileParts = [] as Array<{ fileData: { fileUri: string; mimeType: string } }>;
for (const fieldName of imageFieldNames) {
const bin = this.helpers.assertBinaryData(i, fieldName);
const buf = await this.helpers.getBinaryDataBuffer(i, fieldName);
const uploaded = await uploadFile.call(this, buf, bin.mimeType);
fileParts.push({ fileData: { fileUri: uploaded.fileUri, mimeType: uploaded.mimeType } });
}
const generationConfig = {
responseModalities: ['IMAGE'],
};
const body = {
contents: [
{
role: 'user',
parts: [...fileParts, { text: prompt }],
},
],
generationConfig,
};
const response: unknown = await apiRequest.call(
this,
'POST',
`/v1beta/${model}:generateContent`,
{
body,
},
);
if (!isGenerateContentResponse(response)) {
throw new Error('Invalid response format from Gemini API');
}
const promises = response.candidates.map(async (candidate) => {
const imagePart = candidate.content.parts.find((part) => 'inlineData' in part);
// Check if imagePart exists and has inlineData with actual data
if (!imagePart?.inlineData?.data) {
throw new Error('No image data returned from Gemini API');
}
const bufferOut = Buffer.from(imagePart.inlineData.data, 'base64');
const binaryOut = await this.helpers.prepareBinaryData(
bufferOut,
'image.png',
imagePart.inlineData.mimeType,
);
return {
binary: {
[outputKey]: binaryOut,
},
json: {
...binaryOut,
data: undefined,
},
pairedItem: { item: i },
};
});
return await Promise.all(promises);
}
@@ -0,0 +1,157 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
import {
type GenerateContentRequest,
type GenerateContentResponse,
type ImagenResponse,
Modality,
} 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<INodeExecutionData[]> {
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: [Modality.IMAGE, Modality.TEXT],
};
const body: GenerateContentRequest = {
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.',
},
);
}
@@ -0,0 +1,45 @@
import type { INodeProperties } from 'n8n-workflow';
import * as analyze from './analyze.operation';
import * as edit from './edit.operation';
import * as generate from './generate.operation';
export { analyze, generate, edit };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Analyze Image',
value: 'analyze',
action: 'Analyze an 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',
},
{
name: 'Edit Image',
value: 'edit',
action: 'Edit an image',
description: 'Upload one or more images and apply edits based on a prompt',
},
],
default: 'generate',
displayOptions: {
show: {
resource: ['image'],
},
},
},
...analyze.description,
...edit.description,
...generate.description,
];
@@ -0,0 +1,13 @@
import type { AllEntities } from 'n8n-workflow';
type NodeMap = {
text: 'message';
image: 'analyze' | 'generate';
video: 'analyze' | 'generate' | 'download';
audio: 'transcribe' | 'analyze';
document: 'analyze';
file: 'upload';
fileSearch: 'createStore' | 'deleteStore' | 'listStores' | 'uploadToStore';
};
export type GoogleGeminiType = AllEntities<NodeMap>;
@@ -0,0 +1,136 @@
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 fileSearch from './fileSearch';
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<IExecuteFunctions>();
const mockAudio = jest.spyOn(audio.analyze, 'execute');
const mockDocument = jest.spyOn(document.analyze, 'execute');
const mockFile = jest.spyOn(file.upload, 'execute');
const mockFileSearchCreateStore = jest.spyOn(fileSearch.createStore, 'execute');
const mockFileSearchDeleteStore = jest.spyOn(fileSearch.deleteStore, 'execute');
const mockFileSearchListStores = jest.spyOn(fileSearch.listStores, 'execute');
const mockFileSearchUploadToStore = jest.spyOn(fileSearch.uploadToStore, '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'],
[mockFileSearchCreateStore, 'fileSearch', 'createStore'],
[mockFileSearchDeleteStore, 'fileSearch', 'deleteStore'],
[mockFileSearchListStores, 'fileSearch', 'listStores'],
[mockFileSearchUploadToStore, 'fileSearch', 'uploadToStore'],
[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');
});
});
@@ -0,0 +1,72 @@
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 fileSearch from './fileSearch';
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 'fileSearch':
execute = fileSearch[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];
}
@@ -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,
];
@@ -0,0 +1,584 @@
import { getConnectedTools } from '@utils/helpers';
import {
type IDataObject,
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
jsonParse,
updateDisplayOptions,
validateNodeParameters,
} from 'n8n-workflow';
import zodToJsonSchema from 'zod-to-json-schema';
import type {
GenerateContentRequest,
GenerateContentResponse,
Content,
Tool,
GenerateContentGenerationConfig,
BuiltInTools,
} 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: 'Built-in Tools',
name: 'builtInTools',
placeholder: 'Add Built-in Tool',
type: 'collection',
default: {},
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
options: [
{
displayName: 'Google Search',
name: 'googleSearch',
type: 'boolean',
default: true,
description:
'Whether to allow the model to search the web using Google Search to get real-time information',
},
{
displayName: 'Google Maps',
name: 'googleMaps',
type: 'collection',
default: { latitude: '', longitude: '' },
options: [
{
displayName: 'Latitude',
name: 'latitude',
type: 'number',
default: '',
description: 'The latitude coordinate for location-based queries',
typeOptions: {
numberPrecision: 6,
},
},
{
displayName: 'Longitude',
name: 'longitude',
type: 'number',
default: '',
description: 'The longitude coordinate for location-based queries',
typeOptions: {
numberPrecision: 6,
},
},
],
},
{
displayName: 'URL Context',
name: 'urlContext',
type: 'boolean',
default: true,
description: 'Whether to allow the model to read and analyze content from specific URLs',
},
{
displayName: 'File Search',
name: 'fileSearch',
type: 'collection',
default: { fileSearchStoreNames: '[]' },
options: [
{
displayName: 'File Search Store Names',
name: 'fileSearchStoreNames',
description:
'The file search store names to use for the file search. File search stores are managed via Google AI Studio.',
type: 'json',
default: '[]',
required: true,
},
{
displayName: 'Metadata Filter',
name: 'metadataFilter',
type: 'string',
default: '',
description:
'Use metadata filter to search within a subset of documents. Example: author="Robert Graves".',
placeholder: 'e.g. author="John Doe"',
},
],
},
{
displayName: 'Code Execution',
name: 'codeExecution',
type: 'boolean',
default: true,
description:
'Whether to allow the model to execute code it generates to produce a response. Supported only by certain models.',
},
],
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
type: 'collection',
default: {},
options: [
{
displayName: 'Include Merged Response',
name: 'includeMergedResponse',
type: 'boolean',
default: false,
description:
'Whether to include a single output string merging all text parts of the response',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
},
{
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.',
displayOptions: {
show: {
'@version': [{ _cnd: { eq: 1 } }],
},
},
},
{
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: 'Thinking Budget',
name: 'thinkingBudget',
type: 'number',
default: -1,
description:
'Controls reasoning tokens for thinking models. Set to 0 to disable automatic thinking. Set to -1 for dynamic thinking (default).',
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<INodeExecutionData[]> {
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 builtInTools = this.getNodeParameter('builtInTools', i, {}) as BuiltInTools;
validateNodeParameters(
options,
{
includeMergedResponse: { type: 'boolean', required: false },
systemMessage: { type: 'string', required: false },
codeExecution: { type: 'boolean', required: false },
frequencyPenalty: { type: 'number', required: false },
maxOutputTokens: { type: 'number', required: false },
candidateCount: { type: 'number', required: false },
presencePenalty: { type: 'number', required: false },
temperature: { type: 'number', required: false },
topP: { type: 'number', required: false },
topK: { type: 'number', required: false },
thinkingBudget: { type: 'number', required: false },
maxToolsIterations: { type: 'number', required: false },
},
this.getNode(),
);
const generationConfig: GenerateContentGenerationConfig = {
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,
};
// Add thinkingConfig if thinkingBudget is specified
if (options.thinkingBudget !== undefined) {
generationConfig.thinkingConfig = {
thinkingBudget: options.thinkingBudget,
};
}
const nodeInputs = this.getNodeInputs();
const availableTools = nodeInputs.some((i) => i.type === 'ai_tool')
? 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 (this.getNode().typeVersion === 1) {
if (options.codeExecution) {
tools.push({
codeExecution: {},
});
}
}
// Add built-in tools and build toolConfig
let toolConfig: GenerateContentRequest['toolConfig'];
if (this.getNode().typeVersion >= 1.1) {
if (builtInTools) {
if (builtInTools.googleSearch) {
tools.push({
googleSearch: {},
});
}
const googleMapsOptions = builtInTools.googleMaps;
if (googleMapsOptions) {
tools.push({
googleMaps: {},
});
// Build toolConfig with retrievalConfig if latitude/longitude are provided
const latitude = googleMapsOptions.latitude;
const longitude = googleMapsOptions.longitude;
if (
latitude !== undefined &&
latitude !== '' &&
longitude !== undefined &&
longitude !== ''
) {
toolConfig = {
retrievalConfig: {
latLng: {
latitude: Number(latitude),
longitude: Number(longitude),
},
},
};
}
}
if (builtInTools.urlContext) {
tools.push({
urlContext: {},
});
}
const fileSearchOptions = builtInTools.fileSearch;
if (fileSearchOptions) {
const fileSearchStoreNamesRaw = fileSearchOptions.fileSearchStoreNames;
const metadataFilter = fileSearchOptions.metadataFilter;
let fileSearchStoreNames: string[] | undefined;
if (fileSearchStoreNamesRaw) {
const parsed = jsonParse(fileSearchStoreNamesRaw, {
errorMessage: 'Failed to parse file search store names',
});
if (Array.isArray(parsed)) {
fileSearchStoreNames = parsed;
}
}
tools.push({
fileSearch: {
...(fileSearchStoreNames && { fileSearchStoreNames }),
...(metadataFilter && { metadataFilter }),
},
});
}
if (builtInTools.codeExecution) {
tools.push({
codeExecution: {},
});
}
}
}
const contents: Content[] = messages.map((m) => ({
parts: [{ text: m.content }],
role: m.role,
}));
const body: GenerateContentRequest = {
tools,
contents,
generationConfig,
systemInstruction: options.systemMessage
? { parts: [{ text: options.systemMessage }] }
: undefined,
...(toolConfig && { toolConfig }),
};
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++;
}
const candidates = options.includeMergedResponse
? response.candidates.map((candidate) => ({
...candidate,
mergedResponse: candidate.content.parts
.filter((part) => 'text' in part)
.map((part) => (part as { text: string }).text)
.join(''),
}))
: response.candidates;
if (simplify) {
return candidates.map((candidate) => ({
json: candidate,
pairedItem: { item: i },
}));
}
return [
{
json: {
...response,
candidates,
},
pairedItem: { item: i },
},
];
}
@@ -0,0 +1,103 @@
/* 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 fileSearch from './fileSearch';
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, 1.1],
defaultVersion: 1.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 Search',
value: 'fileSearch',
},
{
name: 'Image',
value: 'image',
},
{
name: 'Media File',
value: 'file',
},
{
name: 'Text',
value: 'text',
},
{
name: 'Video',
value: 'video',
},
],
default: 'text',
},
...audio.description,
...document.description,
...file.description,
...fileSearch.description,
...image.description,
...text.description,
...video.description,
],
};
@@ -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<INodeExecutionData[]> {
return await baseAnalyze.call(this, i, 'videoUrls', 'video/mp4');
}
@@ -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<INodeExecutionData[]> {
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 },
},
];
}
@@ -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. Supported only by certain models.',
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<INodeExecutionData[]> {
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,
},
};
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 },
}));
}
}
@@ -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,
];
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,106 @@
import {
validateNodeParameters,
type IExecuteFunctions,
type 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<INodeExecutionData[]> {
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, {});
validateNodeParameters(
options,
{ maxOutputTokens: { type: 'number', required: false } },
this.getNode(),
);
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 },
},
];
}
@@ -0,0 +1,152 @@
import type {
GenerateContentConfig,
GenerationConfig,
GenerateContentParameters,
} from '@google/genai';
import type { IDataObject } from 'n8n-workflow';
export { Modality } from '@google/genai';
/* type created based on: https://ai.google.dev/api/generate-content#generationconfig */
export type GenerateContentGenerationConfig = Pick<
GenerationConfig,
| 'stopSequences'
| 'responseMimeType'
| 'responseSchema'
| 'responseJsonSchema'
| 'responseModalities'
| 'candidateCount'
| 'maxOutputTokens'
| 'temperature'
| 'topP'
| 'topK'
| 'seed'
| 'presencePenalty'
| 'frequencyPenalty'
| 'responseLogprobs'
| 'logprobs'
| 'speechConfig'
| 'thinkingConfig'
| 'mediaResolution'
>;
/* Type created based on: https://ai.google.dev/api/generate-content#method:-models.streamgeneratecontent */
export interface GenerateContentRequest extends IDataObject {
contents: GenerateContentParameters['contents'];
tools?: GenerateContentConfig['tools'];
toolConfig?: GenerateContentConfig['toolConfig'];
systemInstruction?: GenerateContentConfig['systemInstruction'];
safetySettings?: GenerateContentConfig['safetySettings'];
generationConfig?: GenerateContentGenerationConfig;
cachedContent?: string;
}
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;
};
}>;
};
};
}
/**
* File Search operation interface for long-running upload operations
* Based on: https://ai.google.dev/api/file-search/file-search-stores#method:-media.uploadtofilesearchstore
*/
export interface FileSearchOperation {
name: string;
done: boolean;
error?: { message: string };
response?: IDataObject;
}
/**
* User configuration for built-in tools in the node parameters
*/
export interface BuiltInTools {
googleSearch?: boolean;
googleMaps?: {
latitude?: number | string;
longitude?: number | string;
};
urlContext?: boolean;
fileSearch?: {
fileSearchStoreNames?: string;
metadataFilter?: string;
};
codeExecution?: boolean;
}
/**
* Tool structure for the Google Gemini API request
*/
export interface Tool {
functionDeclarations?: Array<{
name: string;
description: string;
parameters: IDataObject;
}>;
googleSearch?: object;
googleMaps?: object;
urlContext?: object;
fileSearch?: {
fileSearchStoreNames?: string[];
metadataFilter?: string;
};
codeExecution?: object;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,327 @@
import axios from 'axios';
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { Readable } from 'node:stream';
import type Stream from 'node:stream';
import type { FileSearchOperation } from './interfaces';
import { apiRequest } from '../transport';
const OPERATION_CHECK_INTERVAL = 1000;
interface File {
name: string;
uri: string;
mimeType: string;
state: string;
error?: { message: string };
}
interface FileStreamData {
stream: Stream;
mimeType: string;
}
interface FileBufferData {
buffer: Buffer;
mimeType: string;
}
interface UploadStreamConfig {
endpoint: string;
mimeType: string;
body?: IDataObject;
}
const CHUNK_SIZE = 256 * 1024;
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, OPERATION_CHECK_INTERVAL));
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 };
}
async function getFileStreamFromUrlOrBinary(
this: IExecuteFunctions,
i: number,
downloadUrl?: string,
fallbackMimeType?: string,
qs?: IDataObject,
): Promise<FileStreamData | FileBufferData> {
if (downloadUrl) {
const downloadResponse = await axios.get(downloadUrl, {
params: qs,
responseType: 'stream',
});
const contentType = downloadResponse.headers['content-type'] as string | undefined;
const mimeType = contentType?.split(';')?.[0] ?? fallbackMimeType ?? 'application/octet-stream';
return {
stream: downloadResponse.data as Stream,
mimeType,
};
}
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i, 'data');
if (!binaryPropertyName) {
throw new NodeOperationError(
this.getNode(),
'Binary property name or download URL is required',
{
description: 'Error uploading file',
},
);
}
const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName);
if (!binaryData.id) {
const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName);
return {
buffer,
mimeType: binaryData.mimeType,
};
}
return {
stream: await this.helpers.getBinaryStream(binaryData.id, CHUNK_SIZE),
mimeType: binaryData.mimeType,
};
}
async function uploadStream(
this: IExecuteFunctions,
stream: Stream,
config: UploadStreamConfig,
): Promise<{ body: IDataObject }> {
const { endpoint, mimeType, body } = config;
const uploadInitResponse = (await apiRequest.call(this, 'POST', endpoint, {
headers: {
'X-Goog-Upload-Protocol': 'resumable',
'X-Goog-Upload-Command': 'start',
'X-Goog-Upload-Header-Content-Type': mimeType,
'Content-Type': 'application/json',
},
body,
option: { returnFullResponse: true },
})) as { headers: IDataObject };
const uploadUrl = uploadInitResponse.headers['x-goog-upload-url'] as string;
if (!uploadUrl) {
throw new NodeOperationError(this.getNode(), 'Failed to get upload URL');
}
return (await this.helpers.httpRequest({
method: 'POST',
url: uploadUrl,
headers: {
'X-Goog-Upload-Offset': '0',
'X-Goog-Upload-Command': 'upload, finalize',
'Content-Type': mimeType,
},
body: stream,
returnFullResponse: true,
})) as { body: IDataObject };
}
export async function transferFile(
this: IExecuteFunctions,
i: number,
downloadUrl?: string,
fallbackMimeType?: string,
qs?: IDataObject,
) {
const fileData = await getFileStreamFromUrlOrBinary.call(
this,
i,
downloadUrl,
fallbackMimeType,
qs,
);
if ('buffer' in fileData) {
return await uploadFile.call(this, fileData.buffer, fileData.mimeType);
}
const { stream, mimeType } = fileData;
const uploadResponse = (await uploadStream.call(this, stream, {
endpoint: '/upload/v1beta/files',
mimeType,
})) as { body: { file: File } };
let file = uploadResponse.body.file;
while (file.state !== 'ACTIVE' && file.state !== 'FAILED') {
await new Promise((resolve) => setTimeout(resolve, OPERATION_CHECK_INTERVAL));
file = (await apiRequest.call(this, 'GET', `/v1beta/${file.name}`)) as File;
}
if (file.state === 'FAILED') {
throw new NodeOperationError(this.getNode(), file.error?.message ?? 'Unknown error', {
description: 'Error uploading file',
});
}
return { fileUri: file.uri, mimeType: file.mimeType };
}
export async function createFileSearchStore(this: IExecuteFunctions, displayName: string) {
return (await apiRequest.call(this, 'POST', '/v1beta/fileSearchStores', {
body: { displayName },
})) as IDataObject;
}
export async function uploadToFileSearchStore(
this: IExecuteFunctions,
i: number,
fileSearchStoreName: string,
displayName: string,
downloadUrl?: string,
fallbackMimeType?: string,
qs?: IDataObject,
) {
const fileData = await getFileStreamFromUrlOrBinary.call(
this,
i,
downloadUrl,
fallbackMimeType,
qs,
);
let stream: Stream;
let mimeType: string;
if ('buffer' in fileData) {
stream = Readable.from(fileData.buffer);
mimeType = fileData.mimeType;
} else {
stream = fileData.stream;
mimeType = fileData.mimeType;
}
const uploadResponse = (await uploadStream.call(this, stream, {
endpoint: `/upload/v1beta/${fileSearchStoreName}:uploadToFileSearchStore`,
mimeType,
body: { displayName, mimeType },
})) as { body: { name: string } };
const operationName = uploadResponse.body.name;
let operation = (await apiRequest.call(
this,
'GET',
`/v1beta/${operationName}`,
)) as FileSearchOperation;
while (!operation.done) {
await new Promise((resolve) => setTimeout(resolve, OPERATION_CHECK_INTERVAL));
operation = (await apiRequest.call(
this,
'GET',
`/v1beta/${operationName}`,
)) as FileSearchOperation;
}
if (operation.error) {
throw new NodeOperationError(this.getNode(), operation.error.message ?? 'Unknown error', {
description: 'Error uploading file to File Search store',
});
}
return operation.response;
}
export async function listFileSearchStores(
this: IExecuteFunctions,
pageSize?: number,
pageToken?: string,
) {
const qs: IDataObject = {};
if (pageSize !== undefined) {
qs.pageSize = pageSize;
}
if (pageToken) {
qs.pageToken = pageToken;
}
return (await apiRequest.call(this, 'GET', '/v1beta/fileSearchStores', { qs })) as IDataObject;
}
export async function deleteFileSearchStore(
this: IExecuteFunctions,
name: string,
force?: boolean,
) {
const qs: IDataObject = {};
if (force !== undefined) {
qs.force = force;
}
return (await apiRequest.call(this, 'DELETE', `/v1beta/${name}`, { qs })) as IDataObject;
}
@@ -0,0 +1 @@
export * as listSearch from './listSearch';
@@ -0,0 +1,221 @@
import { mock } from 'jest-mock-extended';
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import {
audioModelSearch,
imageEditModelSearch,
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',
},
{
name: 'models/gemini-2.5-flash-image',
},
{
name: 'models/gemini-3-pro-image',
},
],
};
describe('GoogleGemini -> listSearch', () => {
const mockExecuteFunctions = mock<ILoadOptionsFunctions>();
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',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'models/gemini-2.5-flash-image (Nano Banana)',
value: 'models/gemini-2.5-flash-image',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'models/gemini-3-pro-image (Nano Banana Pro)',
value: 'models/gemini-3-pro-image',
},
],
});
});
it('should filter out image models', async () => {
apiRequestMock.mockResolvedValue(mockResponse);
const result = await imageGenerationModelSearch.call(mockExecuteFunctions, 'Exp');
expect(result).toEqual({
results: [
{
name: 'models/gemini-2.0-flash-exp-image-generation',
value: 'models/gemini-2.0-flash-exp-image-generation',
},
],
});
});
});
describe('imageEditModelSearch', () => {
it('should return image edit models', async () => {
apiRequestMock.mockResolvedValue(mockResponse);
const result = await imageEditModelSearch.call(mockExecuteFunctions);
expect(result).toEqual({
results: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'models/gemini-2.5-flash-image (Nano Banana)',
value: 'models/gemini-2.5-flash-image',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'models/gemini-3-pro-image (Nano Banana Pro)',
value: 'models/gemini-3-pro-image',
},
],
});
});
it('should filter out image edit models', async () => {
apiRequestMock.mockResolvedValue(mockResponse);
const result = await imageEditModelSearch.call(mockExecuteFunctions, 'banana pro');
expect(result).toEqual({
results: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'models/gemini-3-pro-image (Nano Banana Pro)',
value: 'models/gemini-3-pro-image',
},
],
});
});
});
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',
},
],
});
});
});
});
@@ -0,0 +1,105 @@
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
import { apiRequest } from '../transport';
async function baseModelSearch(
this: ILoadOptionsFunctions,
modelFilter: (model: string) => boolean,
filter?: string,
): Promise<INodeListSearchResult> {
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<INodeListSearchResult> {
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<INodeListSearchResult> {
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<INodeListSearchResult> {
const rawResult = await baseModelSearch.call(this, (model) => model.includes('image'));
let results = rawResult.results.map((r) => {
if (r.name.includes('gemini-2.5-flash-image')) {
return { name: `${r.name} (Nano Banana)`, value: r.value };
}
if (r.name.includes('gemini-3-pro-image')) {
return { name: `${r.name} (Nano Banana Pro)`, value: r.value };
}
return r;
});
if (filter) {
const filterLowerCase = filter.toLowerCase();
results = results.filter((r) => r.name.toLowerCase().includes(filterLowerCase));
}
return {
results,
};
}
export async function imageEditModelSearch(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const result = await imageGenerationModelSearch.call(this, filter);
return {
results: result.results.filter((r) => r.name.toLowerCase().includes('nano banana')),
};
}
export async function videoGenerationModelSearch(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
return await baseModelSearch.call(this, (model) => model.includes('veo'), filter);
}
@@ -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<IExecuteFunctions>();
beforeEach(() => {
jest.clearAllMocks();
});
it('should call httpRequestWithAuthentication with correct parameters', async () => {
executeFunctionsMock.getCredentials.mockResolvedValue({
host: '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,
},
);
});
});
@@ -0,0 +1,50 @@
import type {
IDataObject,
IExecuteFunctions,
IHttpRequestMethods,
ILoadOptionsFunctions,
} from 'n8n-workflow';
type RequestParameters = {
headers?: IDataObject;
body?: IDataObject | string;
qs?: IDataObject;
option?: IDataObject;
};
type GooglePalmApiCredentials = {
host: string;
apiKey: string;
};
export async function apiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
endpoint: string,
parameters?: RequestParameters,
) {
const { body, qs, option, headers } = parameters ?? {};
const credentials = await this.getCredentials<GooglePalmApiCredentials>('googlePalmApi');
let url = `https://generativelanguage.googleapis.com${endpoint}`;
if (credentials.host) {
url = `${credentials.host}${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);
}