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
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:
+1008
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 Anthropic implements INodeType {
|
||||
description = versionDescription;
|
||||
|
||||
methods = {
|
||||
listSearch,
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const modelRLC: INodeProperties = {
|
||||
displayName: 'Model',
|
||||
name: 'modelId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'modelSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. claude-sonnet-4-5-20250929',
|
||||
},
|
||||
],
|
||||
};
|
||||
Vendored
+103
@@ -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,
|
||||
{
|
||||
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 image description',
|
||||
name: 'maxTokens',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
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', 'document');
|
||||
}
|
||||
+29
@@ -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,
|
||||
];
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'File ID',
|
||||
name: 'fileId',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. file_123',
|
||||
description: 'ID of the file to delete',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['deleteFile'],
|
||||
resource: ['file'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const fileId = this.getNodeParameter('fileId', i, '') as string;
|
||||
const response = (await apiRequest.call(this, 'DELETE', `/v1/files/${fileId}`)) as {
|
||||
id: string;
|
||||
};
|
||||
return [
|
||||
{
|
||||
json: response,
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { File } from '../../helpers/interfaces';
|
||||
import { getBaseUrl } from '../../helpers/utils';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'File ID',
|
||||
name: 'fileId',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. file_123',
|
||||
description: 'ID of the file to get metadata for',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['get'],
|
||||
resource: ['file'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const fileId = this.getNodeParameter('fileId', i, '') as string;
|
||||
const baseUrl = await getBaseUrl.call(this);
|
||||
const response = (await apiRequest.call(this, 'GET', `/v1/files/${fileId}`)) as File;
|
||||
return [
|
||||
{
|
||||
json: { ...response, url: `${baseUrl}/v1/files/${response.id}` },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as deleteFile from './delete.operation';
|
||||
import * as get from './get.operation';
|
||||
import * as list from './list.operation';
|
||||
import * as upload from './upload.operation';
|
||||
|
||||
export { deleteFile, get, list, upload };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Upload File',
|
||||
value: 'upload',
|
||||
action: 'Upload a file',
|
||||
description: 'Upload a file to the Anthropic API for later use',
|
||||
},
|
||||
{
|
||||
name: 'Get File Metadata',
|
||||
value: 'get',
|
||||
action: 'Get file metadata',
|
||||
description: 'Get metadata for a file from the Anthropic API',
|
||||
},
|
||||
{
|
||||
name: 'List Files',
|
||||
value: 'list',
|
||||
action: 'List files',
|
||||
description: 'List files from the Anthropic API',
|
||||
},
|
||||
{
|
||||
name: 'Delete File',
|
||||
value: 'deleteFile',
|
||||
action: 'Delete a file',
|
||||
description: 'Delete a file from the Anthropic API',
|
||||
},
|
||||
],
|
||||
default: 'upload',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...deleteFile.description,
|
||||
...get.description,
|
||||
...list.description,
|
||||
...upload.description,
|
||||
];
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { File } from '../../helpers/interfaces';
|
||||
import { getBaseUrl } from '../../helpers/utils';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
interface FileListResponse {
|
||||
data: File[];
|
||||
first_id: string;
|
||||
last_id: string;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 1000,
|
||||
},
|
||||
default: 50,
|
||||
description: 'Max number of results to return',
|
||||
displayOptions: {
|
||||
show: {
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['list'],
|
||||
resource: ['file'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const returnAll = this.getNodeParameter('returnAll', i, false);
|
||||
const limit = this.getNodeParameter('limit', i, 50);
|
||||
const baseUrl = await getBaseUrl.call(this);
|
||||
if (returnAll) {
|
||||
return await getAllFiles.call(this, baseUrl, i);
|
||||
} else {
|
||||
return await getFiles.call(this, baseUrl, i, limit);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllFiles(this: IExecuteFunctions, baseUrl: string, i: number) {
|
||||
let hasMore = true;
|
||||
let lastId: string | undefined = undefined;
|
||||
const files: File[] = [];
|
||||
while (hasMore) {
|
||||
const response = (await apiRequest.call(this, 'GET', '/v1/files', {
|
||||
qs: {
|
||||
limit: 1000,
|
||||
after_id: lastId,
|
||||
},
|
||||
})) as FileListResponse;
|
||||
|
||||
hasMore = response.has_more;
|
||||
lastId = response.last_id;
|
||||
files.push(...response.data);
|
||||
}
|
||||
|
||||
return files.map((file) => ({
|
||||
json: { ...file, url: `${baseUrl}/v1/files/${file.id}` },
|
||||
pairedItem: { item: i },
|
||||
}));
|
||||
}
|
||||
|
||||
async function getFiles(this: IExecuteFunctions, baseUrl: string, i: number, limit: number) {
|
||||
const response = (await apiRequest.call(this, 'GET', '/v1/files', {
|
||||
qs: {
|
||||
limit,
|
||||
},
|
||||
})) as FileListResponse;
|
||||
|
||||
return response.data.map((file) => ({
|
||||
json: { ...file, url: `${baseUrl}/v1/files/${file.id}` },
|
||||
pairedItem: { item: i },
|
||||
}));
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { File } from '../../helpers/interfaces';
|
||||
import { downloadFile, getBaseUrl, uploadFile } from '../../helpers/utils';
|
||||
|
||||
export const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Input Type',
|
||||
name: 'inputType',
|
||||
type: 'options',
|
||||
default: 'url',
|
||||
options: [
|
||||
{
|
||||
name: 'File URL',
|
||||
value: 'url',
|
||||
},
|
||||
{
|
||||
name: 'Binary File',
|
||||
value: 'binary',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'URL',
|
||||
name: 'fileUrl',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. https://example.com/file.pdf',
|
||||
description: 'URL of the file to upload',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
inputType: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
hint: 'The name of the input field containing the binary file data to be processed',
|
||||
description: 'Name of the binary field which contains the file',
|
||||
displayOptions: {
|
||||
show: {
|
||||
inputType: ['binary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'File Name',
|
||||
name: 'fileName',
|
||||
type: 'string',
|
||||
description: 'The file name to use for the uploaded file',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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;
|
||||
const fileName = this.getNodeParameter('options.fileName', i, 'file') as string;
|
||||
const baseUrl = await getBaseUrl.call(this);
|
||||
|
||||
let response: File;
|
||||
if (inputType === 'url') {
|
||||
const fileUrl = this.getNodeParameter('fileUrl', i, '') as string;
|
||||
const { fileContent, mimeType } = await downloadFile.call(this, fileUrl);
|
||||
response = await uploadFile.call(this, fileContent, mimeType, fileName);
|
||||
} else {
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i, 'data');
|
||||
const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName);
|
||||
const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName);
|
||||
response = await uploadFile.call(this, buffer, binaryData.mimeType, fileName);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: { ...response, url: `${baseUrl}/v1/files/${response.id}` },
|
||||
pairedItem: {
|
||||
item: i,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
+102
@@ -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,
|
||||
{
|
||||
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), 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 image description',
|
||||
name: 'maxTokens',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
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');
|
||||
}
|
||||
@@ -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 Image',
|
||||
value: 'analyze',
|
||||
action: 'Analyze image',
|
||||
description: 'Take in images and answer questions about them',
|
||||
},
|
||||
],
|
||||
default: 'analyze',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['image'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...analyze.description,
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AllEntities } from 'n8n-workflow';
|
||||
|
||||
type NodeMap = {
|
||||
text: 'message';
|
||||
image: 'analyze';
|
||||
document: 'analyze';
|
||||
file: 'upload' | 'deleteFile' | 'get' | 'list';
|
||||
prompt: 'generate' | 'improve' | 'templatize';
|
||||
};
|
||||
|
||||
export type AnthropicType = AllEntities<NodeMap>;
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { PromptResponse } from '../../helpers/interfaces';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Task',
|
||||
name: 'task',
|
||||
type: 'string',
|
||||
description: "Description of the prompt's purpose",
|
||||
placeholder: 'e.g. A chef for a meal prep planning service',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Simplify Output',
|
||||
name: 'simplify',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to return a simplified version of the response instead of the raw data',
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['generate'],
|
||||
resource: ['prompt'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const task = this.getNodeParameter('task', i, '') as string;
|
||||
const simplify = this.getNodeParameter('simplify', i, true) as boolean;
|
||||
|
||||
const body = {
|
||||
task,
|
||||
};
|
||||
const response = (await apiRequest.call(this, 'POST', '/v1/experimental/generate_prompt', {
|
||||
body,
|
||||
enableAnthropicBetas: { promptTools: true },
|
||||
})) as PromptResponse;
|
||||
|
||||
if (simplify) {
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
messages: response.messages,
|
||||
system: response.system,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: { ...response },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { Message, PromptResponse } from '../../helpers/interfaces';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Messages',
|
||||
name: 'messages',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
sortable: true,
|
||||
multipleValues: true,
|
||||
},
|
||||
description: 'Messages that constitute the prompt to be improved',
|
||||
placeholder: 'Add Message',
|
||||
default: { values: [{ content: '', role: 'user' }] },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'content',
|
||||
type: 'string',
|
||||
description: 'The content of the message to be sent',
|
||||
default: '',
|
||||
placeholder: 'e.g. Concise instructions for a meal prep service',
|
||||
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: 'Assistant',
|
||||
value: 'assistant',
|
||||
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: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'System Message',
|
||||
name: 'system',
|
||||
type: 'string',
|
||||
description: 'The existing system prompt to incorporate, if any',
|
||||
default: '',
|
||||
placeholder: 'e.g. You are a professional meal prep chef',
|
||||
},
|
||||
{
|
||||
displayName: 'Feedback',
|
||||
name: 'feedback',
|
||||
type: 'string',
|
||||
description: 'Feedback for improving the prompt',
|
||||
default: '',
|
||||
placeholder: 'e.g. Make it more detailed and include cooking times',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['improve'],
|
||||
resource: ['prompt'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const messages = this.getNodeParameter('messages.values', i, []) as Message[];
|
||||
const simplify = this.getNodeParameter('simplify', i, true) as boolean;
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const body = {
|
||||
messages,
|
||||
system: options.system,
|
||||
feedback: options.feedback,
|
||||
};
|
||||
const response = (await apiRequest.call(this, 'POST', '/v1/experimental/improve_prompt', {
|
||||
body,
|
||||
enableAnthropicBetas: { promptTools: true },
|
||||
})) as PromptResponse;
|
||||
|
||||
if (simplify) {
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
messages: response.messages,
|
||||
system: response.system,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: { ...response },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as generate from './generate.operation';
|
||||
import * as improve from './improve.operation';
|
||||
import * as templatize from './templatize.operation';
|
||||
|
||||
export { generate, improve, templatize };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Generate Prompt',
|
||||
value: 'generate',
|
||||
action: 'Generate a prompt',
|
||||
description: 'Generate a prompt for a model',
|
||||
},
|
||||
{
|
||||
name: 'Improve Prompt',
|
||||
value: 'improve',
|
||||
action: 'Improve a prompt',
|
||||
description: 'Improve a prompt for a model',
|
||||
},
|
||||
{
|
||||
name: 'Templatize Prompt',
|
||||
value: 'templatize',
|
||||
action: 'Templatize a prompt',
|
||||
description: 'Templatize a prompt for a model',
|
||||
},
|
||||
],
|
||||
default: 'generate',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['prompt'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'The <a href="https://docs.anthropic.com/en/api/prompt-tools-generate">prompt tools APIs</a> are in a closed research preview. Your organization must request access to use them.',
|
||||
name: 'experimentalNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['prompt'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...generate.description,
|
||||
...improve.description,
|
||||
...templatize.description,
|
||||
];
|
||||
Vendored
+127
@@ -0,0 +1,127 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { Message, TemplatizeResponse } from '../../helpers/interfaces';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Messages',
|
||||
name: 'messages',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
sortable: true,
|
||||
multipleValues: true,
|
||||
},
|
||||
description: 'Messages that constitute the prompt to be templatized',
|
||||
placeholder: 'Add Message',
|
||||
default: { values: [{ content: '', role: 'user' }] },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'content',
|
||||
type: 'string',
|
||||
description: 'The content of the message to be sent',
|
||||
default: '',
|
||||
placeholder: 'e.g. Translate hello to German',
|
||||
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: 'Assistant',
|
||||
value: 'assistant',
|
||||
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: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'System Message',
|
||||
name: 'system',
|
||||
type: 'string',
|
||||
description: 'The existing system prompt to templatize',
|
||||
default: '',
|
||||
placeholder: 'e.g. You are a professional English to German translator',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['templatize'],
|
||||
resource: ['prompt'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const messages = this.getNodeParameter('messages.values', i, []) as Message[];
|
||||
const simplify = this.getNodeParameter('simplify', i, true) as boolean;
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
const body = {
|
||||
messages,
|
||||
system: options.system,
|
||||
};
|
||||
const response = (await apiRequest.call(this, 'POST', '/v1/experimental/templatize_prompt', {
|
||||
body,
|
||||
enableAnthropicBetas: { promptTools: true },
|
||||
})) as TemplatizeResponse;
|
||||
|
||||
if (simplify) {
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
messages: response.messages,
|
||||
system: response.system,
|
||||
variable_values: response.variable_values,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: { ...response },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import * as document from './document';
|
||||
import * as file from './file';
|
||||
import * as image from './image';
|
||||
import * as prompt from './prompt';
|
||||
import { router } from './router';
|
||||
import * as text from './text';
|
||||
|
||||
describe('Anthropic router', () => {
|
||||
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
const mockDocument = jest.spyOn(document.analyze, 'execute');
|
||||
const mockFile = jest.spyOn(file.upload, 'execute');
|
||||
const mockImage = jest.spyOn(image.analyze, 'execute');
|
||||
const mockPrompt = jest.spyOn(prompt.generate, 'execute');
|
||||
const mockText = jest.spyOn(text.message, 'execute');
|
||||
const operationMocks = [
|
||||
[mockDocument, 'document', 'analyze'],
|
||||
[mockFile, 'file', 'upload'],
|
||||
[mockImage, 'image', 'analyze'],
|
||||
[mockText, 'text', 'message'],
|
||||
[mockPrompt, 'prompt', 'generate'],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
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' ? 'document' : 'analyze',
|
||||
);
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([
|
||||
{
|
||||
json: {
|
||||
text: 'item 1',
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
text: 'item 2',
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
text: 'item 3',
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockDocument.mockResolvedValueOnce([{ json: { response: 'foo' } }]);
|
||||
mockDocument.mockResolvedValueOnce([{ json: { response: 'bar' } }]);
|
||||
mockDocument.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' ? 'document' : 'analyze',
|
||||
);
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }, { json: {} }]);
|
||||
mockDocument.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' ? 'document' : 'analyze',
|
||||
);
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockDocument.mockRejectedValue(new Error('Some error'));
|
||||
|
||||
await expect(router.call(mockExecuteFunctions)).rejects.toThrow('Some error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NodeOperationError, type IExecuteFunctions, type INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import * as document from './document';
|
||||
import * as file from './file';
|
||||
import * as image from './image';
|
||||
import type { AnthropicType } from './node.type';
|
||||
import * as prompt from './prompt';
|
||||
import * as text from './text';
|
||||
|
||||
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 anthropicTypeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as AnthropicType;
|
||||
|
||||
let execute;
|
||||
switch (anthropicTypeData.resource) {
|
||||
case 'document':
|
||||
execute = document[anthropicTypeData.operation].execute;
|
||||
break;
|
||||
case 'file':
|
||||
execute = file[anthropicTypeData.operation].execute;
|
||||
break;
|
||||
case 'image':
|
||||
execute = image[anthropicTypeData.operation].execute;
|
||||
break;
|
||||
case 'prompt':
|
||||
execute = prompt[anthropicTypeData.operation].execute;
|
||||
break;
|
||||
case 'text':
|
||||
execute = text[anthropicTypeData.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 Anthropic model',
|
||||
},
|
||||
],
|
||||
default: 'message',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['text'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...message.description,
|
||||
];
|
||||
+606
@@ -0,0 +1,606 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
import zodToJsonSchema from 'zod-to-json-schema';
|
||||
|
||||
import { getConnectedTools } from '@utils/helpers';
|
||||
|
||||
import type {
|
||||
Content,
|
||||
File,
|
||||
Message,
|
||||
MessagesResponse,
|
||||
Tool as AnthropicTool,
|
||||
} from '../../helpers/interfaces';
|
||||
import {
|
||||
downloadFile,
|
||||
getBaseUrl,
|
||||
getMimeType,
|
||||
splitByComma,
|
||||
uploadFile,
|
||||
} from '../../helpers/utils';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { modelRLC } from '../descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
modelRLC,
|
||||
{
|
||||
displayName: 'Messages',
|
||||
name: 'messages',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
sortable: true,
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Message',
|
||||
default: { values: [{ content: '', role: 'user' }] },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'content',
|
||||
type: 'string',
|
||||
description: 'The content of the message to be sent',
|
||||
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: 'Assistant',
|
||||
value: 'assistant',
|
||||
description: 'Tell the model to adopt a specific tone or personality',
|
||||
},
|
||||
],
|
||||
default: 'user',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Add Attachments',
|
||||
name: 'addAttachments',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to add attachments to the message',
|
||||
},
|
||||
{
|
||||
displayName: 'Attachments Input Type',
|
||||
name: 'attachmentsInputType',
|
||||
type: 'options',
|
||||
default: 'url',
|
||||
description: 'The type of input to use for the attachments',
|
||||
options: [
|
||||
{
|
||||
name: 'URL(s)',
|
||||
value: 'url',
|
||||
},
|
||||
{
|
||||
name: 'Binary File(s)',
|
||||
value: 'binary',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
addAttachments: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Attachment URL(s)',
|
||||
name: 'attachmentsUrls',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. https://example.com/image.png',
|
||||
description: 'URL(s) of the file(s) to attach, multiple URLs can be added separated by comma',
|
||||
displayOptions: {
|
||||
show: {
|
||||
addAttachments: [true],
|
||||
attachmentsInputType: ['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Attachment Input Data Field Name(s)',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
description:
|
||||
'Name of the binary field(s) which contains the file(s) to attach, multiple field names can be added separated by comma',
|
||||
displayOptions: {
|
||||
show: {
|
||||
addAttachments: [true],
|
||||
attachmentsInputType: ['binary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: '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',
|
||||
},
|
||||
{
|
||||
displayName: 'System Message',
|
||||
name: 'system',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. You are a helpful assistant',
|
||||
},
|
||||
{
|
||||
displayName: 'Code Execution',
|
||||
name: 'codeExecution',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to enable code execution. Not supported by all models.',
|
||||
},
|
||||
{
|
||||
displayName: 'Web Search',
|
||||
name: 'webSearch',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to enable web search',
|
||||
},
|
||||
{
|
||||
displayName: 'Web Search Max Uses',
|
||||
name: 'maxUses',
|
||||
type: 'number',
|
||||
default: 5,
|
||||
description: 'The maximum number of web search uses per request',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Web Search Allowed Domains',
|
||||
name: 'allowedDomains',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Comma-separated list of domains to search. Only domains in this list will be searched. Conflicts with "Web Search Blocked Domains".',
|
||||
placeholder: 'e.g. google.com, wikipedia.org',
|
||||
},
|
||||
{
|
||||
displayName: 'Web Search Blocked Domains',
|
||||
name: 'blockedDomains',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Comma-separated list of domains to block from search. Conflicts with "Web Search Allowed Domains".',
|
||||
placeholder: 'e.g. google.com, wikipedia.org',
|
||||
},
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokens',
|
||||
default: 1024,
|
||||
description: 'The maximum number of tokens to generate in the completion',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 1,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Top P)',
|
||||
name: 'topP',
|
||||
default: 0.7,
|
||||
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: 5,
|
||||
description: 'The maximum number of tokens to consider when sampling',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
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);
|
||||
|
||||
interface MessageOptions {
|
||||
includeMergedResponse?: boolean;
|
||||
codeExecution?: boolean;
|
||||
webSearch?: boolean;
|
||||
allowedDomains?: string;
|
||||
blockedDomains?: string;
|
||||
maxUses?: number;
|
||||
maxTokens?: number;
|
||||
system?: string;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
topK?: number;
|
||||
}
|
||||
|
||||
function getFileTypeOrThrow(this: IExecuteFunctions, mimeType?: string): 'image' | 'document' {
|
||||
if (mimeType?.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (mimeType === 'application/pdf') {
|
||||
return 'document';
|
||||
}
|
||||
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Unsupported file type: ${mimeType}. Only images and PDFs are supported.`,
|
||||
);
|
||||
}
|
||||
|
||||
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 Message[];
|
||||
const addAttachments = this.getNodeParameter('addAttachments', i, false) as boolean;
|
||||
const simplify = this.getNodeParameter('simplify', i, true) as boolean;
|
||||
const options = this.getNodeParameter('options', i, {}) as MessageOptions;
|
||||
|
||||
const { tools, connectedTools } = await getTools.call(this, options);
|
||||
|
||||
if (addAttachments) {
|
||||
if (options.codeExecution) {
|
||||
await addCodeAttachmentsToMessages.call(this, i, messages);
|
||||
} else {
|
||||
await addRegularAttachmentsToMessages.call(this, i, messages);
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
model,
|
||||
messages,
|
||||
tools,
|
||||
max_tokens: options.maxTokens ?? 1024,
|
||||
system: options.system,
|
||||
temperature: options.temperature,
|
||||
top_p: options.topP,
|
||||
top_k: options.topK,
|
||||
};
|
||||
|
||||
let response = (await apiRequest.call(this, 'POST', '/v1/messages', {
|
||||
body,
|
||||
enableAnthropicBetas: { codeExecution: options.codeExecution },
|
||||
})) as MessagesResponse;
|
||||
|
||||
const maxToolsIterations = this.getNodeParameter('options.maxToolsIterations', i, 15) as number;
|
||||
const abortSignal = this.getExecutionCancelSignal();
|
||||
let currentIteration = 0;
|
||||
let pauseTurns = 0;
|
||||
while (true) {
|
||||
if (abortSignal?.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (response.stop_reason === 'tool_use') {
|
||||
if (maxToolsIterations > 0 && currentIteration >= maxToolsIterations) {
|
||||
break;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
});
|
||||
await handleToolUse.call(this, response, messages, connectedTools);
|
||||
currentIteration++;
|
||||
} else if (response.stop_reason === 'pause_turn') {
|
||||
// if the model has paused (can happen for the web search or code execution tool), we just retry 3 times
|
||||
if (pauseTurns >= 3) {
|
||||
break;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
});
|
||||
pauseTurns++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
response = (await apiRequest.call(this, 'POST', '/v1/messages', {
|
||||
body,
|
||||
enableAnthropicBetas: { codeExecution: options.codeExecution },
|
||||
})) as MessagesResponse;
|
||||
}
|
||||
|
||||
const mergedResponse = options.includeMergedResponse
|
||||
? response.content
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => c.text)
|
||||
.join('')
|
||||
: undefined;
|
||||
|
||||
if (simplify) {
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
content: response.content,
|
||||
merged_response: mergedResponse,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: { ...response, merged_response: mergedResponse },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function getTools(this: IExecuteFunctions, options: MessageOptions) {
|
||||
let connectedTools: Tool[] = [];
|
||||
const nodeInputs = this.getNodeInputs();
|
||||
// the node can be used as a tool, in this case there won't be any connected tools
|
||||
if (nodeInputs.some((i) => i.type === 'ai_tool')) {
|
||||
connectedTools = await getConnectedTools(this, true);
|
||||
}
|
||||
|
||||
const tools: AnthropicTool[] = connectedTools.map((t) => ({
|
||||
type: 'custom',
|
||||
name: t.name,
|
||||
input_schema: zodToJsonSchema(t.schema),
|
||||
description: t.description,
|
||||
}));
|
||||
|
||||
if (options.codeExecution) {
|
||||
tools.push({
|
||||
type: 'code_execution_20250522',
|
||||
name: 'code_execution',
|
||||
});
|
||||
}
|
||||
|
||||
if (options.webSearch) {
|
||||
const allowedDomains = options.allowedDomains
|
||||
? splitByComma(options.allowedDomains)
|
||||
: undefined;
|
||||
const blockedDomains = options.blockedDomains
|
||||
? splitByComma(options.blockedDomains)
|
||||
: undefined;
|
||||
tools.push({
|
||||
type: 'web_search_20250305',
|
||||
name: 'web_search',
|
||||
max_uses: options.maxUses,
|
||||
allowed_domains: allowedDomains,
|
||||
blocked_domains: blockedDomains,
|
||||
});
|
||||
}
|
||||
|
||||
return { tools, connectedTools };
|
||||
}
|
||||
|
||||
async function addCodeAttachmentsToMessages(
|
||||
this: IExecuteFunctions,
|
||||
i: number,
|
||||
messages: Message[],
|
||||
) {
|
||||
const inputType = this.getNodeParameter('attachmentsInputType', i, 'url') as string;
|
||||
const baseUrl = await getBaseUrl.call(this);
|
||||
const fileUrlPrefix = `${baseUrl}/v1/files/`;
|
||||
|
||||
let content: Content[];
|
||||
if (inputType === 'url') {
|
||||
const urls = this.getNodeParameter('attachmentsUrls', i, '') as string;
|
||||
const promises = splitByComma(urls).map(async (url) => {
|
||||
if (url.startsWith(fileUrlPrefix)) {
|
||||
return url.replace(fileUrlPrefix, '');
|
||||
} else {
|
||||
const { fileContent, mimeType } = await downloadFile.call(this, url);
|
||||
const response = await uploadFile.call(this, fileContent, mimeType);
|
||||
return response.id;
|
||||
}
|
||||
});
|
||||
|
||||
const fileIds = await Promise.all(promises);
|
||||
content = fileIds.map((fileId) => ({
|
||||
type: 'container_upload',
|
||||
file_id: fileId,
|
||||
}));
|
||||
} else {
|
||||
const binaryPropertyNames = this.getNodeParameter('binaryPropertyName', i, 'data');
|
||||
const promises = splitByComma(binaryPropertyNames).map(async (binaryPropertyName) => {
|
||||
const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName);
|
||||
const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName);
|
||||
const response = await uploadFile.call(this, buffer, binaryData.mimeType);
|
||||
return response.id;
|
||||
});
|
||||
|
||||
const fileIds = await Promise.all(promises);
|
||||
content = fileIds.map((fileId) => ({
|
||||
type: 'container_upload',
|
||||
file_id: fileId,
|
||||
}));
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
async function addRegularAttachmentsToMessages(
|
||||
this: IExecuteFunctions,
|
||||
i: number,
|
||||
messages: Message[],
|
||||
) {
|
||||
const inputType = this.getNodeParameter('attachmentsInputType', i, 'url') as string;
|
||||
const baseUrl = await getBaseUrl.call(this);
|
||||
const fileUrlPrefix = `${baseUrl}/v1/files/`;
|
||||
|
||||
let content: Content[];
|
||||
if (inputType === 'url') {
|
||||
const urls = this.getNodeParameter('attachmentsUrls', i, '') as string;
|
||||
const promises = splitByComma(urls).map(async (url) => {
|
||||
if (url.startsWith(fileUrlPrefix)) {
|
||||
const response = (await apiRequest.call(this, 'GET', '', {
|
||||
option: { url },
|
||||
})) as File;
|
||||
const type = getFileTypeOrThrow.call(this, response.mime_type);
|
||||
return {
|
||||
type,
|
||||
source: {
|
||||
type: 'file',
|
||||
file_id: url.replace(fileUrlPrefix, ''),
|
||||
},
|
||||
} as Content;
|
||||
} else {
|
||||
const response = (await this.helpers.httpRequest.call(this, {
|
||||
url,
|
||||
method: 'HEAD',
|
||||
returnFullResponse: true,
|
||||
})) as { headers: IDataObject };
|
||||
const mimeType = getMimeType(response.headers['content-type'] as string);
|
||||
const type = getFileTypeOrThrow.call(this, mimeType);
|
||||
return {
|
||||
type,
|
||||
source: {
|
||||
type: 'url',
|
||||
url,
|
||||
},
|
||||
} as Content;
|
||||
}
|
||||
});
|
||||
|
||||
content = await Promise.all(promises);
|
||||
} else {
|
||||
const binaryPropertyNames = this.getNodeParameter('binaryPropertyName', i, 'data');
|
||||
const promises = splitByComma(binaryPropertyNames).map(async (binaryPropertyName) => {
|
||||
const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName);
|
||||
const type = getFileTypeOrThrow.call(this, binaryData.mimeType);
|
||||
const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName);
|
||||
const fileBase64 = buffer.toString('base64');
|
||||
return {
|
||||
type,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: binaryData.mimeType,
|
||||
data: fileBase64,
|
||||
},
|
||||
} as Content;
|
||||
});
|
||||
|
||||
content = await Promise.all(promises);
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleToolUse(
|
||||
this: IExecuteFunctions,
|
||||
response: MessagesResponse,
|
||||
messages: Message[],
|
||||
connectedTools: Tool[],
|
||||
) {
|
||||
const toolCalls = response.content.filter((c) => c.type === 'tool_use');
|
||||
if (!toolCalls.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolResults = {
|
||||
role: 'user' as const,
|
||||
content: [] as Content[],
|
||||
};
|
||||
for (const toolCall of toolCalls) {
|
||||
let toolResponse;
|
||||
for (const connectedTool of connectedTools) {
|
||||
if (connectedTool.name === toolCall.name) {
|
||||
toolResponse = (await connectedTool.invoke(toolCall.input)) as IDataObject;
|
||||
}
|
||||
}
|
||||
|
||||
toolResults.content.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolCall.id,
|
||||
content:
|
||||
typeof toolResponse === 'object' ? JSON.stringify(toolResponse) : (toolResponse ?? ''),
|
||||
});
|
||||
}
|
||||
|
||||
messages.push(toolResults);
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import * as document from './document';
|
||||
import * as file from './file';
|
||||
import * as image from './image';
|
||||
import * as prompt from './prompt';
|
||||
import * as text from './text';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Anthropic',
|
||||
name: 'anthropic',
|
||||
icon: 'file:anthropic.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
|
||||
description: 'Interact with Anthropic AI models',
|
||||
defaults: {
|
||||
name: 'Anthropic',
|
||||
},
|
||||
usableAsTool: true,
|
||||
codex: {
|
||||
alias: ['LangChain', 'document', 'image', 'assistant', 'claude'],
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Agents', 'Miscellaneous', 'Root Nodes'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.anthropic/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
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: 'anthropicApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Document',
|
||||
value: 'document',
|
||||
},
|
||||
{
|
||||
name: 'File',
|
||||
value: 'file',
|
||||
},
|
||||
{
|
||||
name: 'Image',
|
||||
value: 'image',
|
||||
},
|
||||
{
|
||||
name: 'Prompt',
|
||||
value: 'prompt',
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
},
|
||||
],
|
||||
default: 'text',
|
||||
},
|
||||
...document.description,
|
||||
...file.description,
|
||||
...image.description,
|
||||
...prompt.description,
|
||||
...text.description,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="46" height="32" fill="none"><path fill="#7D7D87" d="M32.73 0h-6.945L38.45 32h6.945zM12.665 0 0 32h7.082l2.59-6.72h13.25l2.59 6.72h7.082L19.929 0zm-.702 19.337 4.334-11.246 4.334 11.246z"/></svg>
|
||||
|
After Width: | Height: | Size: 242 B |
@@ -0,0 +1,97 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import type { Content, MessagesResponse } from './interfaces';
|
||||
import { getBaseUrl, splitByComma } from './utils';
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
export async function baseAnalyze(
|
||||
this: IExecuteFunctions,
|
||||
i: number,
|
||||
urlsPropertyName: string,
|
||||
type: 'image' | 'document',
|
||||
): 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, {});
|
||||
const baseUrl = await getBaseUrl.call(this);
|
||||
const fileUrlPrefix = `${baseUrl}/v1/files/`;
|
||||
|
||||
let content: Content[];
|
||||
if (inputType === 'url') {
|
||||
const urls = this.getNodeParameter(urlsPropertyName, i, '') as string;
|
||||
content = splitByComma(urls).map((url) => {
|
||||
if (url.startsWith(fileUrlPrefix)) {
|
||||
return {
|
||||
type,
|
||||
source: {
|
||||
type: 'file',
|
||||
file_id: url.replace(fileUrlPrefix, ''),
|
||||
},
|
||||
} as Content;
|
||||
} else {
|
||||
return {
|
||||
type,
|
||||
source: {
|
||||
type: 'url',
|
||||
url,
|
||||
},
|
||||
} as Content;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const binaryPropertyNames = this.getNodeParameter('binaryPropertyName', i, 'data');
|
||||
const promises = splitByComma(binaryPropertyNames).map(async (binaryPropertyName) => {
|
||||
const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName);
|
||||
const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName);
|
||||
const fileBase64 = buffer.toString('base64');
|
||||
return {
|
||||
type,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: binaryData.mimeType,
|
||||
data: fileBase64,
|
||||
},
|
||||
} as Content;
|
||||
});
|
||||
|
||||
content = await Promise.all(promises);
|
||||
}
|
||||
|
||||
content.push({
|
||||
type: 'text',
|
||||
text,
|
||||
});
|
||||
|
||||
const body = {
|
||||
model,
|
||||
max_tokens: options.maxTokens ?? 1024,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = (await apiRequest.call(this, 'POST', '/v1/messages', {
|
||||
body,
|
||||
})) as MessagesResponse;
|
||||
|
||||
if (simplify) {
|
||||
return [
|
||||
{
|
||||
json: { content: response.content },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: { ...response },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
import type { JsonSchema7Type } from 'zod-to-json-schema';
|
||||
|
||||
export type FileSource =
|
||||
| {
|
||||
type: 'base64';
|
||||
media_type: string;
|
||||
data: string;
|
||||
}
|
||||
| {
|
||||
type: 'url';
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
type: 'file';
|
||||
file_id: string;
|
||||
};
|
||||
|
||||
export type Content =
|
||||
| {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'image';
|
||||
source: FileSource;
|
||||
}
|
||||
| {
|
||||
type: 'document';
|
||||
source: FileSource;
|
||||
}
|
||||
| {
|
||||
type: 'tool_use';
|
||||
id: string;
|
||||
name: string;
|
||||
input: IDataObject;
|
||||
}
|
||||
| {
|
||||
type: 'tool_result';
|
||||
tool_use_id: string;
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
type: 'container_upload';
|
||||
file_id: string;
|
||||
};
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string | Content[];
|
||||
}
|
||||
|
||||
export interface File {
|
||||
created_at: string;
|
||||
downloadable: boolean;
|
||||
filename: string;
|
||||
id: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
type: 'file';
|
||||
}
|
||||
|
||||
export type Tool =
|
||||
| {
|
||||
type: 'custom';
|
||||
name: string;
|
||||
input_schema: JsonSchema7Type;
|
||||
description: string;
|
||||
}
|
||||
| {
|
||||
type: 'web_search_20250305';
|
||||
name: 'web_search';
|
||||
max_uses?: number;
|
||||
allowed_domains?: string[];
|
||||
blocked_domains?: string[];
|
||||
}
|
||||
| {
|
||||
type: 'code_execution_20250522';
|
||||
name: 'code_execution';
|
||||
};
|
||||
|
||||
export interface MessagesResponse {
|
||||
content: Content[];
|
||||
stop_reason: string | null;
|
||||
}
|
||||
|
||||
export interface PromptResponse {
|
||||
messages: Message[];
|
||||
system: string;
|
||||
}
|
||||
|
||||
export interface TemplatizeResponse extends PromptResponse {
|
||||
variable_values: IDataObject;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { downloadFile, getBaseUrl, getMimeType, splitByComma, uploadFile } from './utils';
|
||||
import * as transport from '../transport';
|
||||
|
||||
describe('Anthropic -> utils', () => {
|
||||
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
const apiRequestMock = jest.spyOn(transport, 'apiRequest');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getMimeType', () => {
|
||||
it('should extract mime type from content type string', () => {
|
||||
const result = getMimeType('application/pdf; q=0.9');
|
||||
expect(result).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should return full string if no semicolon', () => {
|
||||
const result = getMimeType('application/pdf');
|
||||
expect(result).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should return undefined for undefined input', () => {
|
||||
const result = getMimeType(undefined);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = getMimeType('');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadFile', () => {
|
||||
it('should download file', async () => {
|
||||
mockExecuteFunctions.helpers.httpRequest.mockResolvedValue({
|
||||
body: new ArrayBuffer(10),
|
||||
headers: {
|
||||
'content-type': 'application/pdf',
|
||||
},
|
||||
});
|
||||
|
||||
const file = await downloadFile.call(mockExecuteFunctions, 'https://example.com/file.pdf');
|
||||
|
||||
expect(file).toEqual({
|
||||
fileContent: Buffer.from(new ArrayBuffer(10)),
|
||||
mimeType: 'application/pdf',
|
||||
});
|
||||
expect(mockExecuteFunctions.helpers.httpRequest).toHaveBeenCalledWith({
|
||||
method: 'GET',
|
||||
url: 'https://example.com/file.pdf',
|
||||
returnFullResponse: true,
|
||||
encoding: 'arraybuffer',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use fallback mime type if content type header is not present', async () => {
|
||||
mockExecuteFunctions.helpers.httpRequest.mockResolvedValue({
|
||||
body: new ArrayBuffer(10),
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const file = await downloadFile.call(mockExecuteFunctions, 'https://example.com/file.pdf');
|
||||
|
||||
expect(file).toEqual({
|
||||
fileContent: Buffer.from(new ArrayBuffer(10)),
|
||||
mimeType: 'application/octet-stream',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should upload file', async () => {
|
||||
const fileContent = Buffer.from('test file content');
|
||||
const mimeType = 'text/plain';
|
||||
const fileName = 'test.txt';
|
||||
|
||||
apiRequestMock.mockResolvedValue({
|
||||
created_at: '2025-01-01T10:00:00Z',
|
||||
downloadable: true,
|
||||
filename: fileName,
|
||||
id: 'file_123',
|
||||
mime_type: mimeType,
|
||||
size_bytes: fileContent.length,
|
||||
type: 'file',
|
||||
});
|
||||
|
||||
const result = await uploadFile.call(mockExecuteFunctions, fileContent, mimeType, fileName);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('POST', '/v1/files', {
|
||||
headers: expect.objectContaining({
|
||||
'content-type': expect.stringContaining('multipart/form-data'),
|
||||
}),
|
||||
body: expect.any(Object),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
created_at: '2025-01-01T10:00:00Z',
|
||||
downloadable: true,
|
||||
filename: fileName,
|
||||
id: 'file_123',
|
||||
mime_type: mimeType,
|
||||
size_bytes: fileContent.length,
|
||||
type: 'file',
|
||||
});
|
||||
});
|
||||
|
||||
it('should upload file with default filename when not provided', async () => {
|
||||
const fileContent = Buffer.from('test file content');
|
||||
const mimeType = 'application/pdf';
|
||||
|
||||
apiRequestMock.mockResolvedValue({
|
||||
created_at: '2025-01-01T10:00:00Z',
|
||||
downloadable: true,
|
||||
filename: 'file',
|
||||
id: 'file_456',
|
||||
mime_type: mimeType,
|
||||
size_bytes: fileContent.length,
|
||||
type: 'file',
|
||||
});
|
||||
|
||||
const result = await uploadFile.call(mockExecuteFunctions, fileContent, mimeType);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('POST', '/v1/files', {
|
||||
headers: expect.objectContaining({
|
||||
'content-type': expect.stringContaining('multipart/form-data'),
|
||||
}),
|
||||
body: expect.any(Object),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
created_at: '2025-01-01T10:00:00Z',
|
||||
downloadable: true,
|
||||
filename: 'file',
|
||||
id: 'file_456',
|
||||
mime_type: mimeType,
|
||||
size_bytes: fileContent.length,
|
||||
type: 'file',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitByComma', () => {
|
||||
it('should split string by comma and trim', () => {
|
||||
const result = splitByComma('apple, banana, cherry');
|
||||
expect(result).toEqual(['apple', 'banana', 'cherry']);
|
||||
});
|
||||
|
||||
it('should handle string with extra spaces', () => {
|
||||
const result = splitByComma(' apple , banana , cherry ');
|
||||
expect(result).toEqual(['apple', 'banana', 'cherry']);
|
||||
});
|
||||
|
||||
it('should filter out empty strings', () => {
|
||||
const result = splitByComma('apple,, banana, , cherry,');
|
||||
expect(result).toEqual(['apple', 'banana', 'cherry']);
|
||||
});
|
||||
|
||||
it('should handle single item', () => {
|
||||
const result = splitByComma('apple');
|
||||
expect(result).toEqual(['apple']);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = splitByComma('');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle string with only commas and spaces', () => {
|
||||
const result = splitByComma(' , , , ');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBaseUrl', () => {
|
||||
it('should return custom URL from credentials', async () => {
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
url: 'https://custom-anthropic-api.com',
|
||||
});
|
||||
|
||||
const result = await getBaseUrl.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toBe('https://custom-anthropic-api.com');
|
||||
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('anthropicApi');
|
||||
});
|
||||
|
||||
it('should return default URL when no custom URL in credentials', async () => {
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({});
|
||||
|
||||
const result = await getBaseUrl.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toBe('https://api.anthropic.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import FormData from 'form-data';
|
||||
import type { IDataObject, IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../transport';
|
||||
import type { File } from './interfaces';
|
||||
|
||||
export function getMimeType(contentType?: string) {
|
||||
return contentType?.split(';')?.[0];
|
||||
}
|
||||
|
||||
export async function downloadFile(this: IExecuteFunctions, url: string, qs?: IDataObject) {
|
||||
const downloadResponse = (await this.helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url,
|
||||
qs,
|
||||
returnFullResponse: true,
|
||||
encoding: 'arraybuffer',
|
||||
})) as { body: ArrayBuffer; headers: IDataObject };
|
||||
|
||||
const mimeType =
|
||||
getMimeType(downloadResponse.headers?.['content-type'] as string) ?? 'application/octet-stream';
|
||||
const fileContent = Buffer.from(downloadResponse.body);
|
||||
return {
|
||||
fileContent,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
this: IExecuteFunctions,
|
||||
fileContent: Buffer,
|
||||
mimeType: string,
|
||||
fileName?: string,
|
||||
) {
|
||||
const form = new FormData();
|
||||
form.append('file', fileContent, {
|
||||
filename: fileName ?? 'file',
|
||||
contentType: mimeType,
|
||||
});
|
||||
return (await apiRequest.call(this, 'POST', '/v1/files', {
|
||||
headers: form.getHeaders(),
|
||||
body: form,
|
||||
})) as File;
|
||||
}
|
||||
|
||||
export function splitByComma(str: string) {
|
||||
return str
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s);
|
||||
}
|
||||
|
||||
export async function getBaseUrl(this: IExecuteFunctions | ILoadOptionsFunctions) {
|
||||
const credentials = await this.getCredentials('anthropicApi');
|
||||
return (credentials.url ?? 'https://api.anthropic.com') as string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as listSearch from './listSearch';
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
|
||||
import { modelSearch } from './listSearch';
|
||||
import * as transport from '../transport';
|
||||
|
||||
const mockResponse = {
|
||||
data: [
|
||||
{
|
||||
id: 'claude-opus-4-20250514',
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('Anthropic -> listSearch', () => {
|
||||
const mockExecuteFunctions = mock<ILoadOptionsFunctions>();
|
||||
const apiRequestMock = jest.spyOn(transport, 'apiRequest');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('modelSearch', () => {
|
||||
it('should return all models', async () => {
|
||||
apiRequestMock.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await modelSearch.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{
|
||||
name: 'claude-opus-4-20250514',
|
||||
value: 'claude-opus-4-20250514',
|
||||
},
|
||||
{
|
||||
name: 'claude-sonnet-4-20250514',
|
||||
value: 'claude-sonnet-4-20250514',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return filtered models', async () => {
|
||||
apiRequestMock.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await modelSearch.call(mockExecuteFunctions, 'sonnet');
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{
|
||||
name: 'claude-sonnet-4-20250514',
|
||||
value: 'claude-sonnet-4-20250514',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
export async function modelSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const response = (await apiRequest.call(this, 'GET', '/v1/models')) as {
|
||||
data: Array<{ id: string }>;
|
||||
};
|
||||
|
||||
let models = response.data;
|
||||
if (filter) {
|
||||
models = models.filter((model) => model.id.toLowerCase().includes(filter.toLowerCase()));
|
||||
}
|
||||
|
||||
return {
|
||||
results: models.map((model) => ({
|
||||
name: model.id,
|
||||
value: model.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import { apiRequest } from '.';
|
||||
|
||||
describe('Anthropic transport', () => {
|
||||
const executeFunctionsMock = mockDeep<IExecuteFunctions>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call httpRequestWithAuthentication with correct parameters', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
url: 'https://custom-url.com',
|
||||
});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'GET', '/v1/messages', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
},
|
||||
qs: {
|
||||
test: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'anthropicApi',
|
||||
{
|
||||
method: 'GET',
|
||||
url: 'https://custom-url.com/v1/messages',
|
||||
json: true,
|
||||
body: {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
},
|
||||
qs: {
|
||||
test: 123,
|
||||
},
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': 'files-api-2025-04-14',
|
||||
'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', '/v1/messages');
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'anthropicApi',
|
||||
{
|
||||
method: 'GET',
|
||||
url: 'https://api.anthropic.com/v1/messages',
|
||||
json: true,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': 'files-api-2025-04-14',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should override the values with `option`', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'GET', '', {
|
||||
option: {
|
||||
url: 'https://override-url.com',
|
||||
returnFullResponse: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'anthropicApi',
|
||||
{
|
||||
method: 'GET',
|
||||
url: 'https://override-url.com',
|
||||
json: true,
|
||||
returnFullResponse: true,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': 'files-api-2025-04-14',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should include prompt-tools beta when enableAnthropicBetas.promptTools is true', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'POST', '/v1/messages', {
|
||||
enableAnthropicBetas: {
|
||||
promptTools: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'anthropicApi',
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'https://api.anthropic.com/v1/messages',
|
||||
json: true,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': 'files-api-2025-04-14,prompt-tools-2025-04-02',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should include code-execution beta when enableAnthropicBetas.codeExecution is true', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'POST', '/v1/messages', {
|
||||
enableAnthropicBetas: {
|
||||
codeExecution: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'anthropicApi',
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'https://api.anthropic.com/v1/messages',
|
||||
json: true,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': 'files-api-2025-04-14,code-execution-2025-05-22',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should include both beta features when both are enabled', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'POST', '/v1/messages', {
|
||||
enableAnthropicBetas: {
|
||||
promptTools: true,
|
||||
codeExecution: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'anthropicApi',
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'https://api.anthropic.com/v1/messages',
|
||||
json: true,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta':
|
||||
'files-api-2025-04-14,prompt-tools-2025-04-02,code-execution-2025-05-22',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add custom header when header toggle is enabled', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value-123',
|
||||
});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'POST', '/v1/messages');
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'anthropicApi',
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'https://api.anthropic.com/v1/messages',
|
||||
json: true,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': 'files-api-2025-04-14',
|
||||
'X-Custom-Header': 'custom-value-123',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add custom header when header toggle is disabled', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
header: false,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value-123',
|
||||
});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'POST', '/v1/messages');
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'anthropicApi',
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'https://api.anthropic.com/v1/messages',
|
||||
json: true,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': 'files-api-2025-04-14',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add custom header along with other headers', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value-123',
|
||||
});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'POST', '/v1/messages', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'anthropicApi',
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'https://api.anthropic.com/v1/messages',
|
||||
json: true,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': 'files-api-2025-04-14',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'custom-value-123',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle custom header with custom URL', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
url: 'https://custom-url.com',
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value-123',
|
||||
});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'POST', '/v1/messages');
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'anthropicApi',
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'https://custom-url.com/v1/messages',
|
||||
json: true,
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': 'files-api-2025-04-14',
|
||||
'X-Custom-Header': 'custom-value-123',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type FormData from 'form-data';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
ILoadOptionsFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
type RequestParameters = {
|
||||
headers?: IDataObject;
|
||||
body?: IDataObject | string | FormData;
|
||||
qs?: IDataObject;
|
||||
option?: IDataObject;
|
||||
enableAnthropicBetas?: {
|
||||
promptTools?: boolean;
|
||||
codeExecution?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export async function apiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
parameters?: RequestParameters,
|
||||
) {
|
||||
const { body, qs, option, headers } = parameters ?? {};
|
||||
|
||||
const credentials = await this.getCredentials('anthropicApi');
|
||||
const baseUrl = credentials.url ?? 'https://api.anthropic.com';
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
|
||||
const betas = ['files-api-2025-04-14'];
|
||||
if (parameters?.enableAnthropicBetas?.promptTools) {
|
||||
betas.push('prompt-tools-2025-04-02');
|
||||
}
|
||||
|
||||
if (parameters?.enableAnthropicBetas?.codeExecution) {
|
||||
betas.push('code-execution-2025-05-22');
|
||||
}
|
||||
|
||||
const requestHeaders: IDataObject = {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': betas.join(','),
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (
|
||||
credentials.header &&
|
||||
typeof credentials.headerName === 'string' &&
|
||||
credentials.headerName &&
|
||||
typeof credentials.headerValue === 'string'
|
||||
) {
|
||||
requestHeaders[credentials.headerName] = credentials.headerValue;
|
||||
}
|
||||
|
||||
const options = {
|
||||
headers: requestHeaders,
|
||||
method,
|
||||
body,
|
||||
qs,
|
||||
url,
|
||||
json: true,
|
||||
};
|
||||
|
||||
if (option && Object.keys(option).length !== 0) {
|
||||
Object.assign(options, option);
|
||||
}
|
||||
|
||||
return await this.helpers.httpRequestWithAuthentication.call(this, 'anthropicApi', options);
|
||||
}
|
||||
+2007
File diff suppressed because it is too large
Load Diff
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+102
@@ -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');
|
||||
}
|
||||
+37
@@ -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,
|
||||
];
|
||||
Vendored
+185
@@ -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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
+26
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
Vendored
+103
@@ -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');
|
||||
}
|
||||
+29
@@ -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,
|
||||
];
|
||||
+29
@@ -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,
|
||||
];
|
||||
+78
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/fileSearch/createStore.operation.ts
Vendored
+39
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/fileSearch/deleteStore.operation.ts
Vendored
+48
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
+54
@@ -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,
|
||||
];
|
||||
Vendored
+50
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
+107
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
Vendored
+102
@@ -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');
|
||||
}
|
||||
Vendored
+248
@@ -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 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+232
@@ -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);
|
||||
}
|
||||
Vendored
+157
@@ -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.',
|
||||
},
|
||||
);
|
||||
}
|
||||
+45
@@ -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,
|
||||
];
|
||||
+13
@@ -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>;
|
||||
+136
@@ -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];
|
||||
}
|
||||
+29
@@ -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,
|
||||
];
|
||||
+584
@@ -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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
+103
@@ -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,
|
||||
],
|
||||
};
|
||||
Vendored
+102
@@ -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');
|
||||
}
|
||||
Vendored
+64
@@ -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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
Vendored
+212
@@ -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 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
+45
@@ -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 |
+106
@@ -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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
+152
@@ -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;
|
||||
}
|
||||
+1071
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';
|
||||
+221
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+105
@@ -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);
|
||||
}
|
||||
+83
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4303_1230)">
|
||||
<g clip-path="url(#clip1_4303_1230)">
|
||||
<path d="M48.6273 23.9918C48.4433 16.9565 48.3514 13.4388 49.3514 10.6312C50.9152 6.24117 54.3152 2.75106 58.6629 1.07311C61.4434 0 64.9623 0 72 0C79.1102 0 85.2633 12.5109 89.4089 29.9356C91.3095 37.9241 92.2597 41.9184 89.8575 44.9592C87.4553 48 83.095 48 74.3746 48H65C55.6524 48 51.1922 50.0237 49.6213 62.0074L48.6273 23.9918Z" fill="url(#paint0_linear_4303_1230)"/>
|
||||
<path d="M48.6273 23.9918C48.4433 16.9565 48.3514 13.4388 49.3514 10.6312C50.9152 6.24117 54.3152 2.75106 58.6629 1.07311C61.4434 0 64.9623 0 72 0C79.1102 0 85.2633 12.5109 89.4089 29.9356C91.3095 37.9241 92.2597 41.9184 89.8575 44.9592C87.4553 48 83.095 48 74.3746 48H65C55.6524 48 51.1922 50.0237 49.6213 62.0074L48.6273 23.9918Z" fill="url(#paint1_radial_4303_1230)"/>
|
||||
<path d="M56 76C56 64.9543 64.9543 56 76 56C87.0457 56 96 64.9543 96 76C96 87.0457 87.0457 96 76 96C64.9543 96 56 87.0457 56 76Z" fill="url(#paint2_linear_4303_1230)"/>
|
||||
<path d="M56 76C56 64.9543 64.9543 56 76 56C87.0457 56 96 64.9543 96 76C96 87.0457 87.0457 96 76 96C64.9543 96 56 87.0457 56 76Z" fill="url(#paint3_radial_4303_1230)"/>
|
||||
<path d="M48 0C25.3343 0 6.89691 31.7119 1.56146 72.064C0.172668 82.5675 -0.52173 87.8192 3.06341 91.9096C6.64855 96 12.4324 96 24 96C35.1353 96 40.703 96 44.1613 92.7703C47.6197 89.5406 48.0198 83.6982 48.8198 72.0133C51.4763 33.214 60.3542 0 72 0H48Z" fill="url(#paint4_linear_4303_1230)"/>
|
||||
<path d="M48 0C25.3343 0 6.89691 31.7119 1.56146 72.064C0.172668 82.5675 -0.52173 87.8192 3.06341 91.9096C6.64855 96 12.4324 96 24 96C35.1353 96 40.703 96 44.1613 92.7703C47.6197 89.5406 48.0198 83.6982 48.8198 72.0133C51.4763 33.214 60.3542 0 72 0H48Z" fill="url(#paint5_linear_4303_1230)"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4303_1230" x1="62" y1="5.5" x2="87" y2="64.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1B44B1"/>
|
||||
<stop offset="1" stop-color="#2764E7"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint1_radial_4303_1230" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(80.5 71) rotate(-92.0166) scale(71.0441 31.9304)">
|
||||
<stop offset="0.539446" stop-color="#2052CB" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#163697"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="paint2_linear_4303_1230" x1="96.6015" y1="96" x2="96.7128" y2="56.0003" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#367AF2"/>
|
||||
<stop offset="1" stop-color="#BABAFF"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint3_radial_4303_1230" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(71 93.7778) rotate(-74.268) scale(40.9795 35.3954)">
|
||||
<stop offset="0.513697" stop-color="#BABAFF" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#FECBE6"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="paint4_linear_4303_1230" x1="57" y1="0" x2="57" y2="96" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#BABAFF"/>
|
||||
<stop offset="0.513233" stop-color="#4894FE"/>
|
||||
<stop offset="1" stop-color="#2764E7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_4303_1230" x1="51" y1="33.5" x2="51" y2="-2.21398e-06" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#BABAFF" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#FECBE6"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_4303_1230">
|
||||
<rect width="96" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_4303_1230">
|
||||
<rect width="96" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
Vendored
+373
@@ -0,0 +1,373 @@
|
||||
import type { INodeType, IWebhookFunctions, IWebhookResponseData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { MicrosoftAgent365Trigger } from './MicrosoftAgent365Trigger.node';
|
||||
import {
|
||||
createMicrosoftAgentApplication,
|
||||
configureAdapterProcessCallback,
|
||||
type MicrosoftAgent365Credentials,
|
||||
type ActivityCapture,
|
||||
} from './microsoft-utils';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('./microsoft-utils', () => ({
|
||||
createMicrosoftAgentApplication: jest.fn(),
|
||||
configureAdapterProcessCallback: jest.fn(),
|
||||
microsoftMcpServers: [
|
||||
{ name: 'Calendar', value: 'mcp_CalendarTools' },
|
||||
{ name: 'Mail', value: 'mcp_MailTools' },
|
||||
],
|
||||
}));
|
||||
|
||||
jest.mock('../../agents/Agent/V2/utils', () => ({
|
||||
getInputs: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('MicrosoftAgent365Trigger', () => {
|
||||
let microsoftAgent365Trigger: INodeType;
|
||||
let mockWebhookFunctions: IWebhookFunctions;
|
||||
let mockRequest: any;
|
||||
let mockResponse: any;
|
||||
let mockAdapter: any;
|
||||
|
||||
beforeEach(() => {
|
||||
microsoftAgent365Trigger = new MicrosoftAgent365Trigger();
|
||||
|
||||
// Create mock request
|
||||
mockRequest = {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
headers: {},
|
||||
};
|
||||
|
||||
// Create mock response
|
||||
mockResponse = {
|
||||
end: jest.fn(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
// Create mock adapter
|
||||
mockAdapter = {
|
||||
process: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// Create mock webhook functions using jest-mock-extended
|
||||
mockWebhookFunctions = mock<IWebhookFunctions>();
|
||||
mockWebhookFunctions.getRequestObject = jest.fn().mockReturnValue(mockRequest);
|
||||
mockWebhookFunctions.getResponseObject = jest.fn().mockReturnValue(mockResponse);
|
||||
mockWebhookFunctions.getCredentials = jest.fn() as any;
|
||||
mockWebhookFunctions.getNode = jest.fn().mockReturnValue({
|
||||
name: 'Microsoft Agent 365',
|
||||
type: 'microsoftAgent365Trigger',
|
||||
});
|
||||
mockWebhookFunctions.helpers = {
|
||||
returnJsonArray: jest.fn((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => ({ json: item }));
|
||||
}
|
||||
return [{ json: data }];
|
||||
}),
|
||||
} as any;
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Node Description', () => {
|
||||
test('should have correct basic properties', () => {
|
||||
expect(microsoftAgent365Trigger.description.displayName).toBe('Microsoft Agent 365 Trigger');
|
||||
expect(microsoftAgent365Trigger.description.name).toBe('microsoftAgent365Trigger');
|
||||
expect(microsoftAgent365Trigger.description.group).toEqual(['trigger']);
|
||||
});
|
||||
|
||||
test('should have webhook configuration', () => {
|
||||
expect(microsoftAgent365Trigger.description.webhooks).toBeDefined();
|
||||
expect(microsoftAgent365Trigger.description.webhooks).toHaveLength(2);
|
||||
|
||||
// Check POST webhook
|
||||
const postWebhook = microsoftAgent365Trigger.description.webhooks![0];
|
||||
expect(postWebhook.httpMethod).toBe('POST');
|
||||
expect(postWebhook.path).toBe('webhook');
|
||||
expect(postWebhook.responseMode).toBe('onReceived');
|
||||
|
||||
// Check HEAD webhook
|
||||
const headWebhook = microsoftAgent365Trigger.description.webhooks![1];
|
||||
expect(headWebhook.httpMethod).toBe('HEAD');
|
||||
expect(headWebhook.path).toBe('webhook');
|
||||
});
|
||||
|
||||
test('should require microsoftAgent365Api credentials', () => {
|
||||
expect(microsoftAgent365Trigger.description.credentials).toBeDefined();
|
||||
expect(microsoftAgent365Trigger.description.credentials).toHaveLength(1);
|
||||
expect(microsoftAgent365Trigger.description.credentials![0].name).toBe(
|
||||
'microsoftAgent365Api',
|
||||
);
|
||||
expect(microsoftAgent365Trigger.description.credentials![0].required).toBe(true);
|
||||
});
|
||||
|
||||
test('should have system prompt property', () => {
|
||||
const properties = microsoftAgent365Trigger.description.properties;
|
||||
const systemPromptProp = properties.find((p) => p.name === 'systemPrompt');
|
||||
|
||||
expect(systemPromptProp).toBeDefined();
|
||||
expect(systemPromptProp?.type).toBe('string');
|
||||
expect(systemPromptProp?.displayName).toBe('System Prompt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook method', () => {
|
||||
describe('HEAD request handling', () => {
|
||||
test('should handle HEAD request and return immediately', async () => {
|
||||
mockRequest.method = 'HEAD';
|
||||
|
||||
const result = await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
|
||||
expect(mockResponse.end).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
expect(mockWebhookFunctions.getCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST request handling', () => {
|
||||
let mockCredentials: MicrosoftAgent365Credentials;
|
||||
let mockAgent: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
mockAgent = {
|
||||
adapter: mockAdapter,
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue(mockCredentials);
|
||||
(createMicrosoftAgentApplication as jest.Mock).mockReturnValue(mockAgent);
|
||||
});
|
||||
|
||||
test('should process POST request successfully', async () => {
|
||||
const mockCallback = jest.fn();
|
||||
(configureAdapterProcessCallback as jest.Mock).mockReturnValue(mockCallback);
|
||||
|
||||
const result = await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
|
||||
// Verify credentials were retrieved
|
||||
expect(mockWebhookFunctions.getCredentials).toHaveBeenCalledWith('microsoftAgent365Api');
|
||||
|
||||
// Verify agent application was created
|
||||
expect(createMicrosoftAgentApplication).toHaveBeenCalledWith(mockCredentials);
|
||||
|
||||
// Verify callback was configured
|
||||
expect(configureAdapterProcessCallback).toHaveBeenCalledWith(
|
||||
mockWebhookFunctions,
|
||||
mockAgent,
|
||||
mockCredentials,
|
||||
expect.objectContaining({
|
||||
input: '',
|
||||
output: [],
|
||||
activity: {},
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify request user was set
|
||||
expect(mockRequest.user).toEqual({
|
||||
aud: mockCredentials.clientId,
|
||||
appid: mockCredentials.clientId,
|
||||
azp: mockCredentials.clientId,
|
||||
});
|
||||
|
||||
// Verify adapter process was called
|
||||
expect(mockAdapter.process).toHaveBeenCalledWith(mockRequest, mockResponse, mockCallback);
|
||||
|
||||
// Verify result structure
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
workflowData: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
test('should capture activity data in workflowData', async () => {
|
||||
const mockCallback = jest.fn();
|
||||
(configureAdapterProcessCallback as jest.Mock).mockReturnValue(mockCallback);
|
||||
|
||||
const result = (await microsoftAgent365Trigger.webhook!.call(
|
||||
mockWebhookFunctions,
|
||||
)) as IWebhookResponseData;
|
||||
|
||||
expect(result.workflowData).toBeDefined();
|
||||
expect(Array.isArray(result.workflowData)).toBe(true);
|
||||
expect(result.workflowData).toHaveLength(1);
|
||||
|
||||
// Verify returnJsonArray was called with activity capture
|
||||
expect(mockWebhookFunctions.helpers!.returnJsonArray).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: '',
|
||||
output: [],
|
||||
activity: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should set request user properties correctly', async () => {
|
||||
const mockCallback = jest.fn();
|
||||
(configureAdapterProcessCallback as jest.Mock).mockReturnValue(mockCallback);
|
||||
|
||||
await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
|
||||
expect(mockRequest.user).toBeDefined();
|
||||
expect(mockRequest.user.aud).toBe(mockCredentials.clientId);
|
||||
expect(mockRequest.user.appid).toBe(mockCredentials.clientId);
|
||||
expect(mockRequest.user.azp).toBe(mockCredentials.clientId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
test('should throw NodeOperationError when credentials retrieval fails', async () => {
|
||||
const error = new Error('Credentials not found');
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
await expect(microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle error with response data containing error object', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
data: {
|
||||
error: 'invalid_client',
|
||||
error_description: 'Invalid client credentials',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockRejectedValue(errorResponse);
|
||||
|
||||
await expect(microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions)).rejects.toThrow(
|
||||
'Error: invalid_client',
|
||||
);
|
||||
});
|
||||
|
||||
test('should include error description in NodeOperationError', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
data: {
|
||||
error: 'unauthorized',
|
||||
error_description: 'The provided credentials are invalid',
|
||||
},
|
||||
},
|
||||
message: 'Authentication failed',
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockRejectedValue(errorResponse);
|
||||
|
||||
try {
|
||||
await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
fail('Should have thrown an error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(NodeOperationError);
|
||||
expect((error as NodeOperationError).description).toBe(
|
||||
'The provided credentials are invalid',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should throw NodeOperationError with message when no error object in response', async () => {
|
||||
const error = new Error('Network error');
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
try {
|
||||
await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NodeOperationError);
|
||||
expect((err as NodeOperationError).message).toContain('Network error');
|
||||
}
|
||||
});
|
||||
|
||||
test('should throw NodeOperationError when agent creation fails', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue(mockCredentials);
|
||||
(createMicrosoftAgentApplication as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Failed to create agent application');
|
||||
});
|
||||
|
||||
await expect(microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw NodeOperationError when adapter process fails', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
const mockAgent = {
|
||||
adapter: {
|
||||
process: jest.fn().mockRejectedValue(new Error('Adapter processing failed')),
|
||||
},
|
||||
};
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue(mockCredentials);
|
||||
(createMicrosoftAgentApplication as jest.Mock).mockReturnValue(mockAgent);
|
||||
|
||||
await expect(microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
test('should handle complete webhook flow with activity capture', async () => {
|
||||
const mockCredentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
const mockAgent = {
|
||||
adapter: mockAdapter,
|
||||
};
|
||||
|
||||
let capturedActivityCapture: ActivityCapture | undefined;
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue(mockCredentials);
|
||||
(createMicrosoftAgentApplication as jest.Mock).mockReturnValue(mockAgent);
|
||||
(configureAdapterProcessCallback as jest.Mock).mockImplementation(
|
||||
(_ctx, _agent, _creds, activityCapture) => {
|
||||
capturedActivityCapture = activityCapture;
|
||||
return mockCallback;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await microsoftAgent365Trigger.webhook!.call(mockWebhookFunctions);
|
||||
|
||||
// Verify activity capture was initialized
|
||||
expect(capturedActivityCapture).toBeDefined();
|
||||
expect(capturedActivityCapture?.input).toBe('');
|
||||
expect(capturedActivityCapture?.output).toEqual([]);
|
||||
expect(capturedActivityCapture?.activity).toEqual({});
|
||||
|
||||
// Verify the full flow completed
|
||||
expect(createMicrosoftAgentApplication).toHaveBeenCalled();
|
||||
expect(configureAdapterProcessCallback).toHaveBeenCalled();
|
||||
expect(mockAdapter.process).toHaveBeenCalled();
|
||||
expect(result.noWebhookResponse).toBe(true);
|
||||
expect(result.workflowData).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
IWebhookFunctions,
|
||||
IWebhookResponseData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getInputs } from '../../agents/Agent/V2/utils';
|
||||
|
||||
import {
|
||||
type ActivityCapture,
|
||||
configureAdapterProcessCallback,
|
||||
createMicrosoftAgentApplication,
|
||||
type MicrosoftAgent365Credentials,
|
||||
microsoftMcpServers,
|
||||
} from './microsoft-utils';
|
||||
|
||||
export class MicrosoftAgent365Trigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Microsoft Agent 365 Trigger',
|
||||
name: 'microsoftAgent365Trigger',
|
||||
icon: 'file:Agent365.svg',
|
||||
group: ['trigger'],
|
||||
description: 'Trigger for Microsoft Agent 365',
|
||||
codex: {
|
||||
categories: ['Core Nodes'],
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.microsoftAgent365Trigger/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
version: [1],
|
||||
defaults: {
|
||||
name: 'Microsoft Agent 365',
|
||||
},
|
||||
inputs: `={{
|
||||
((hasOutputParser, needsFallback) => {
|
||||
${getInputs.toString()};
|
||||
return getInputs(false, hasOutputParser, needsFallback);
|
||||
})($parameter.hasOutputParser === undefined || $parameter.hasOutputParser === true, $parameter.needsFallback !== undefined && $parameter.needsFallback === true)
|
||||
}}`,
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
triggerPanel: false,
|
||||
webhooks: [
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'POST',
|
||||
responseMode: 'onReceived',
|
||||
path: 'webhook',
|
||||
ndvHideMethod: true,
|
||||
},
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'HEAD',
|
||||
responseMode: 'onReceived',
|
||||
path: 'webhook',
|
||||
ndvHideUrl: true,
|
||||
ndvHideMethod: true,
|
||||
},
|
||||
],
|
||||
credentials: [
|
||||
{
|
||||
name: 'microsoftAgent365Api',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName:
|
||||
'This is an early preview for building Agents with Microsoft Agent 365 and n8n. You need to be part of the <a href="https://adoption.microsoft.com/copilot/frontier-program/" target="_blank">Frontier preview program</a> to get early access to Microsoft Agent 365. <a href="https://github.com/microsoft/Agent365-Samples/tree/main/nodejs/n8n/sample-agent" target="_blank">Learn more</a>',
|
||||
name: 'previewNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'System Prompt',
|
||||
name: 'systemPrompt',
|
||||
type: 'string',
|
||||
placeholder:
|
||||
'e.g. You are a friendly assistant that helps people find a weather forecast for a given time and place.',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
hasOutputParser: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Enable Fallback Model',
|
||||
name: 'needsFallback',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.1 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Connect an additional language model on the canvas to use it as a fallback if the main model fails',
|
||||
name: 'fallbackNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
needsFallback: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Enable Microsoft MCP Tools',
|
||||
name: 'useMcpTools',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to allow the agent to use Microsoft MCP tools like Calendar, Email, and OneDrive to assist in completing tasks. Requires appropriate permissions in your Microsoft account.',
|
||||
},
|
||||
{
|
||||
displayName: 'Tools to Include',
|
||||
name: 'include',
|
||||
type: 'options',
|
||||
default: 'all',
|
||||
displayOptions: {
|
||||
show: {
|
||||
useMcpTools: [true],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'All',
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
name: 'Selected',
|
||||
value: 'selected',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Tools to Include',
|
||||
name: 'includeTools',
|
||||
type: 'multiOptions',
|
||||
default: [],
|
||||
noDataExpression: true,
|
||||
options: microsoftMcpServers,
|
||||
displayOptions: {
|
||||
show: {
|
||||
useMcpTools: [true],
|
||||
include: ['selected'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Require Specific Output Format',
|
||||
name: 'hasOutputParser',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add Option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Max Iterations',
|
||||
name: 'maxIterations',
|
||||
type: 'number',
|
||||
default: 10,
|
||||
description: 'The maximum number of iterations the agent will run before stopping',
|
||||
},
|
||||
{
|
||||
displayName: 'Welcome Message',
|
||||
name: 'welcomeMessage',
|
||||
type: 'string',
|
||||
placeholder: "e.g. Hello! I'm here to help you!",
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const req = this.getRequestObject();
|
||||
const res = this.getResponseObject();
|
||||
|
||||
const method = req.method;
|
||||
if (method === 'HEAD') {
|
||||
res.end();
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = (await this.getCredentials(
|
||||
'microsoftAgent365Api',
|
||||
)) as MicrosoftAgent365Credentials;
|
||||
|
||||
const agent = createMicrosoftAgentApplication(credentials);
|
||||
|
||||
const activityCapture: ActivityCapture = {
|
||||
input: '',
|
||||
output: [],
|
||||
activity: {},
|
||||
};
|
||||
|
||||
const callback = configureAdapterProcessCallback(this, agent, credentials, activityCapture);
|
||||
|
||||
(req as any).user = {
|
||||
aud: credentials.clientId,
|
||||
appid: credentials.clientId,
|
||||
azp: credentials.clientId,
|
||||
};
|
||||
|
||||
await agent.adapter.process(req, res, callback);
|
||||
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
workflowData: [this.helpers.returnJsonArray({ ...activityCapture })],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorData = error.response?.data;
|
||||
if (typeof errorData === 'object' && 'error' in errorData) {
|
||||
const message = 'Error: ' + String(errorData.error);
|
||||
const description = (errorData.error_description as string) ?? error.message;
|
||||
|
||||
throw new NodeOperationError(this.getNode(), message, { description });
|
||||
}
|
||||
|
||||
throw new NodeOperationError(this.getNode(), error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { invokeAgent } from '../langchain-utils';
|
||||
|
||||
import {
|
||||
getChatModel,
|
||||
getOptionalMemory,
|
||||
getTools,
|
||||
preparePrompt,
|
||||
} from '../../../agents/Agent/agents/ToolsAgent/common';
|
||||
import { createAgentExecutor } from '../../../agents/Agent/agents/ToolsAgent/V2/execute';
|
||||
import { getOptionalOutputParser } from '../../../../utils/output_parsers/N8nOutputParser';
|
||||
|
||||
jest.mock('../../../agents/Agent/agents/ToolsAgent/common', () => ({
|
||||
getChatModel: jest.fn(),
|
||||
getOptionalMemory: jest.fn(),
|
||||
getTools: jest.fn(),
|
||||
preparePrompt: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../agents/Agent/agents/ToolsAgent/V2/execute', () => ({
|
||||
createAgentExecutor: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../utils/output_parsers/N8nOutputParser', () => ({
|
||||
getOptionalOutputParser: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('langchain-utils', () => {
|
||||
describe('invokeAgent', () => {
|
||||
let nodeContext: IWebhookFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
nodeContext = mock<IWebhookFunctions>({
|
||||
getNodeParameter: jest.fn(),
|
||||
getNode: jest.fn().mockReturnValue({ name: 'Test Node' }),
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw error if no model is connected', async () => {
|
||||
(getChatModel as jest.Mock).mockResolvedValue(null);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(null);
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockReturnValue(false);
|
||||
|
||||
await expect(invokeAgent(nodeContext, 'test input')).rejects.toThrow(
|
||||
'Please connect a model to the Chat Model input',
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw NodeOperationError if fallback is needed but fallback model is not connected', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
(getChatModel as jest.Mock).mockResolvedValueOnce(mockModel).mockResolvedValueOnce(null);
|
||||
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(null);
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return true;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(invokeAgent(nodeContext, 'test input')).rejects.toThrow(
|
||||
'Please connect a model to the Fallback Model input or disable the fallback option',
|
||||
);
|
||||
});
|
||||
|
||||
test('should invoke agent with microsoftMcpTools when provided', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const microsoftMcpTools = [{ name: 'mcp-tool1' }, { name: 'mcp-tool2' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(
|
||||
nodeContext,
|
||||
'test input',
|
||||
undefined,
|
||||
{},
|
||||
microsoftMcpTools as any,
|
||||
);
|
||||
|
||||
expect(result).toBe('test response');
|
||||
|
||||
expect(getTools).toHaveBeenCalled();
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
[...mockTools, ...microsoftMcpTools],
|
||||
mockPrompt,
|
||||
{ maxIterations: 10 },
|
||||
null,
|
||||
mockMemory,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('should invoke agent without microsoftMcpTools when not provided', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
expect(result).toBe('test response');
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
mockTools,
|
||||
mockPrompt,
|
||||
{ maxIterations: 10 },
|
||||
null,
|
||||
mockMemory,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle custom systemMessage parameter', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const customSystemMessage = 'Custom system message';
|
||||
const result = await invokeAgent(nodeContext, 'test input', customSystemMessage);
|
||||
|
||||
expect(result).toBe('test response');
|
||||
expect(mockExecutor.invoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: 'test input',
|
||||
system_message: customSystemMessage,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle fallback model when needsFallback is true', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockFallbackModel = { name: 'fallback-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock)
|
||||
.mockResolvedValueOnce(mockModel)
|
||||
.mockResolvedValueOnce(mockFallbackModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return true;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
expect(result).toBe('test response');
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
mockTools,
|
||||
mockPrompt,
|
||||
{ maxIterations: 10 },
|
||||
null,
|
||||
mockMemory,
|
||||
mockFallbackModel,
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle custom maxIterations option', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return { maxIterations: 20 };
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
expect(result).toBe('test response');
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
mockTools,
|
||||
mockPrompt,
|
||||
{ maxIterations: 20 },
|
||||
null,
|
||||
mockMemory,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw NodeOperationError when executor returns rejected status', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockError = new Error('Execution failed');
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ status: 'rejected', reason: mockError }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(invokeAgent(nodeContext, 'test input')).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
test('should parse JSON output when memory and outputParser are present', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockOutputParser = { name: 'outputParser' };
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ output: '{"output": {"result": "parsed response"}}' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(mockOutputParser);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
expect(result).toEqual({ result: 'parsed response' });
|
||||
});
|
||||
|
||||
test('should return raw output when memory is present but outputParser is not', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'raw output response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
expect(result).toBe('raw output response');
|
||||
});
|
||||
|
||||
test('should concatenate microsoftMcpTools with regular tools', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }, { name: 'tool2' }];
|
||||
const microsoftMcpTools = [{ name: 'mcp-tool1' }, { name: 'mcp-tool2' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
await invokeAgent(nodeContext, 'test input', undefined, {}, microsoftMcpTools as any);
|
||||
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
[...mockTools, ...microsoftMcpTools],
|
||||
mockPrompt,
|
||||
{ maxIterations: 10 },
|
||||
null,
|
||||
mockMemory,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('should use empty array for microsoftMcpTools when not provided', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
await invokeAgent(nodeContext, 'test input');
|
||||
|
||||
// Verify that only regular tools were used (no microsoftMcpTools)
|
||||
expect(createAgentExecutor).toHaveBeenCalledWith(
|
||||
mockModel,
|
||||
mockTools,
|
||||
mockPrompt,
|
||||
{ maxIterations: 10 },
|
||||
null,
|
||||
mockMemory,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('should pass custom invokeOptions to executor', async () => {
|
||||
const mockModel = { name: 'primary-model' };
|
||||
const mockMemory = { name: 'memory' };
|
||||
const mockTools = [{ name: 'tool1' }];
|
||||
const mockPrompt = { name: 'prompt' };
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'test response' }),
|
||||
};
|
||||
|
||||
(getChatModel as jest.Mock).mockResolvedValue(mockModel);
|
||||
(getOptionalMemory as jest.Mock).mockResolvedValue(mockMemory);
|
||||
(getTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
(getOptionalOutputParser as jest.Mock).mockResolvedValue(null);
|
||||
(preparePrompt as jest.Mock).mockReturnValue(mockPrompt);
|
||||
(createAgentExecutor as jest.Mock).mockReturnValue(mockExecutor);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'needsFallback') return false;
|
||||
if (param === 'options') return {};
|
||||
return false;
|
||||
});
|
||||
|
||||
const customInvokeOptions = { timeout: 5000, tags: ['test'] };
|
||||
await invokeAgent(nodeContext, 'test input', undefined, customInvokeOptions);
|
||||
|
||||
expect(mockExecutor.invoke).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: 'test input',
|
||||
}),
|
||||
customInvokeOptions,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+987
@@ -0,0 +1,987 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
createMicrosoftAgentApplication,
|
||||
configureAdapterProcessCallback,
|
||||
getMicrosoftMcpTools,
|
||||
configureActivityCallback,
|
||||
microsoftMcpServers,
|
||||
extractActivityInfo,
|
||||
type MicrosoftAgent365Credentials,
|
||||
type ActivityCapture,
|
||||
type ActivityInfo,
|
||||
} from '../microsoft-utils';
|
||||
|
||||
jest.mock('@microsoft/agents-hosting', () => ({
|
||||
MemoryStorage: jest.fn().mockImplementation(() => ({})),
|
||||
AgentApplication: jest.fn().mockImplementation(function (this: any, config: any) {
|
||||
this.adapter = config.adapter;
|
||||
this.storage = config.storage;
|
||||
this.authorization = config.authorization;
|
||||
this.onConversationUpdate = jest.fn();
|
||||
this.onActivity = jest.fn();
|
||||
this.run = jest.fn();
|
||||
return this;
|
||||
}),
|
||||
CloudAdapter: jest.fn().mockImplementation((config: any) => ({ config })),
|
||||
}));
|
||||
|
||||
jest.mock('@microsoft/agents-a365-observability', () => ({
|
||||
ExecutionType: {
|
||||
HumanToAgent: 'HumanToAgent',
|
||||
},
|
||||
InvokeAgentScope: {
|
||||
start: jest.fn().mockReturnValue({
|
||||
withActiveSpanAsync: jest.fn().mockImplementation((fn: any) => fn()),
|
||||
recordInputMessages: jest.fn(),
|
||||
recordOutputMessages: jest.fn(),
|
||||
dispose: jest.fn(),
|
||||
}),
|
||||
},
|
||||
BaggageBuilder: jest.fn().mockImplementation(() => ({
|
||||
tenantId: jest.fn().mockReturnThis(),
|
||||
agentId: jest.fn().mockReturnThis(),
|
||||
correlationId: jest.fn().mockReturnThis(),
|
||||
agentName: jest.fn().mockReturnThis(),
|
||||
conversationId: jest.fn().mockReturnThis(),
|
||||
build: jest.fn().mockReturnValue({
|
||||
run: jest.fn().mockImplementation((fn: any) => fn()),
|
||||
}),
|
||||
})),
|
||||
ObservabilityManager: {
|
||||
configure: jest.fn().mockReturnValue({
|
||||
start: jest.fn(),
|
||||
shutdown: jest.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@microsoft/agents-a365-runtime', () => ({
|
||||
getMcpPlatformAuthenticationScope: jest.fn().mockReturnValue('mcp-scope'),
|
||||
getObservabilityAuthenticationScope: jest.fn().mockReturnValue('observability-scope'),
|
||||
Utility: {
|
||||
ResolveAgentIdentity: jest.fn().mockReturnValue('agent-identity'),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@microsoft/agents-a365-tooling', () => ({
|
||||
McpToolServerConfigurationService: jest.fn().mockImplementation(() => ({
|
||||
listToolServers: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
Utility: {
|
||||
ValidateAuthToken: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../langchain-utils', () => ({
|
||||
invokeAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../mcp/shared/utils', () => ({
|
||||
connectMcpClient: jest.fn(),
|
||||
getAllTools: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../mcp/McpClientTool/utils', () => ({
|
||||
createCallTool: jest.fn(),
|
||||
mcpToolToDynamicTool: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: jest.fn(() => 'test-uuid'),
|
||||
}));
|
||||
|
||||
import { MemoryStorage, AgentApplication, CloudAdapter } from '@microsoft/agents-hosting';
|
||||
import { invokeAgent } from '../langchain-utils';
|
||||
import { connectMcpClient, getAllTools } from '../../../mcp/shared/utils';
|
||||
import { createCallTool, mcpToolToDynamicTool } from '../../../mcp/McpClientTool/utils';
|
||||
|
||||
describe('microsoft-utils', () => {
|
||||
describe('createMicrosoftAgentApplication', () => {
|
||||
const mockCredentials: MicrosoftAgent365Credentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
test('should create CloudAdapter with correct auth configuration', () => {
|
||||
createMicrosoftAgentApplication(mockCredentials);
|
||||
|
||||
expect(CloudAdapter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientId: mockCredentials.clientId,
|
||||
clientSecret: mockCredentials.clientSecret,
|
||||
tenantId: mockCredentials.tenantId,
|
||||
authority: 'https://login.microsoftonline.com',
|
||||
issuers: expect.arrayContaining([
|
||||
'https://api.botframework.com',
|
||||
`https://sts.windows.net/${mockCredentials.tenantId}/`,
|
||||
`https://login.microsoftonline.com/${mockCredentials.tenantId}/v2.0`,
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should create MemoryStorage', () => {
|
||||
createMicrosoftAgentApplication(mockCredentials);
|
||||
|
||||
expect(MemoryStorage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should create AgentApplication with correct configuration', () => {
|
||||
const result = createMicrosoftAgentApplication(mockCredentials);
|
||||
|
||||
expect(AgentApplication).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
adapter: expect.any(Object),
|
||||
storage: expect.any(Object),
|
||||
authorization: {
|
||||
agentic: {
|
||||
type: 'agentic',
|
||||
scopes: ['https://graph.microsoft.com/.default'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(AgentApplication);
|
||||
});
|
||||
|
||||
test('should return AgentApplication instance', () => {
|
||||
const result = createMicrosoftAgentApplication(mockCredentials);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('adapter');
|
||||
expect(result).toHaveProperty('storage');
|
||||
expect(result).toHaveProperty('authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureAdapterProcessCallback', () => {
|
||||
let nodeContext: IWebhookFunctions;
|
||||
let agent: any;
|
||||
let credentials: MicrosoftAgent365Credentials;
|
||||
let activityCapture: ActivityCapture;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
nodeContext = mock<IWebhookFunctions>({
|
||||
getNodeParameter: jest.fn(),
|
||||
getNode: jest.fn().mockReturnValue({ name: 'Test Node' }),
|
||||
});
|
||||
|
||||
agent = {
|
||||
authorization: {
|
||||
exchangeToken: jest.fn().mockResolvedValue({ token: 'mock-token' }),
|
||||
},
|
||||
onConversationUpdate: jest.fn(),
|
||||
onActivity: jest.fn(),
|
||||
run: jest.fn(),
|
||||
};
|
||||
|
||||
credentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
activityCapture = {
|
||||
input: '',
|
||||
output: [],
|
||||
activity: {},
|
||||
};
|
||||
});
|
||||
|
||||
test('should configure agent with welcome message', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'conversationUpdate',
|
||||
text: 'Hello',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome to the agent!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(agent.onConversationUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should set up activity callback that invokes agent', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input message',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn().mockResolvedValue({}),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test agent response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(agent.onActivity).toHaveBeenCalled();
|
||||
expect(activityCapture.input).toBe('Test input message');
|
||||
});
|
||||
|
||||
test('should handle agent.run errors and throw NodeOperationError', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockError = new Error('Agent run failed');
|
||||
agent.run = jest.fn().mockRejectedValue(mockError);
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await expect(callback(mockTurnContext as any)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
test('should call agent.run with turnContext', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(agent.run).toHaveBeenCalledWith(mockTurnContext);
|
||||
});
|
||||
|
||||
test('should exchange tokens for observability and MCP', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(agent.authorization.exchangeToken).toHaveBeenCalledWith(
|
||||
mockTurnContext,
|
||||
'observability-scope',
|
||||
'agentic',
|
||||
);
|
||||
|
||||
expect(agent.authorization.exchangeToken).toHaveBeenCalledWith(
|
||||
mockTurnContext,
|
||||
'agentic',
|
||||
expect.objectContaining({
|
||||
scopes: ['mcp-scope'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle MCP token exchange failure gracefully', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
agent.authorization.exchangeToken = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ token: 'observability-token' })
|
||||
.mockRejectedValueOnce(new Error('Token exchange failed'));
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(agent.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should capture activity input and output', async () => {
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'User input message',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn().mockImplementation(async (_activityOrText: string) => {
|
||||
return {};
|
||||
}),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Agent response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
expect(activityCapture.input).toBe('User input message');
|
||||
});
|
||||
|
||||
test('should handle observability when enabled', async () => {
|
||||
const originalEnv = process.env;
|
||||
process.env.ENABLE_OBSERVABILITY = 'true';
|
||||
process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true';
|
||||
|
||||
const mockTurnContext = {
|
||||
activity: {
|
||||
type: 'message',
|
||||
text: 'Test input',
|
||||
recipient: { agenticAppId: 'agent-id', name: 'Agent', tenantId: 'tenant-id' },
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn(),
|
||||
turnState: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test response');
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'options.welcomeMessage') return 'Welcome!';
|
||||
if (param === 'systemPrompt') return 'Test agent';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const callback = configureAdapterProcessCallback(
|
||||
nodeContext,
|
||||
agent,
|
||||
credentials,
|
||||
activityCapture,
|
||||
);
|
||||
|
||||
await callback(mockTurnContext as any);
|
||||
|
||||
process.env = originalEnv;
|
||||
|
||||
expect(agent.run).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMicrosoftMcpTools', () => {
|
||||
let mockTurnContext: any;
|
||||
let mockConfigService: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockTurnContext = {
|
||||
activity: {
|
||||
recipient: { tenantId: 'test-tenant-id' },
|
||||
channelData: { tenant: { id: 'test-tenant-id' } },
|
||||
},
|
||||
};
|
||||
|
||||
// Reset the mock implementation for each test
|
||||
const { McpToolServerConfigurationService } = jest.requireMock(
|
||||
'@microsoft/agents-a365-tooling',
|
||||
);
|
||||
mockConfigService = {
|
||||
listToolServers: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
(McpToolServerConfigurationService as jest.Mock).mockImplementation(() => mockConfigService);
|
||||
});
|
||||
|
||||
test('should return undefined when no servers are configured', async () => {
|
||||
mockConfigService.listToolServers.mockResolvedValue([]);
|
||||
|
||||
const result = await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should filter servers when selectedTools is provided', async () => {
|
||||
const mockServers = [
|
||||
{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' },
|
||||
{ mcpServerName: 'mcp_MailTools', url: 'http://mail-server' },
|
||||
{ mcpServerName: 'mcp_TeamsServer', url: 'http://teams-server' },
|
||||
];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
result: mockClient,
|
||||
});
|
||||
|
||||
const mockTool = { name: 'test-tool', description: 'Test tool' };
|
||||
(getAllTools as jest.Mock).mockResolvedValue([mockTool]);
|
||||
|
||||
const mockCallTool = jest.fn();
|
||||
(createCallTool as jest.Mock).mockReturnValue(mockCallTool);
|
||||
|
||||
const mockDynamicTool = { name: 'test-tool' };
|
||||
(mcpToolToDynamicTool as jest.Mock).mockReturnValue(mockDynamicTool);
|
||||
|
||||
const selectedTools = ['mcp_CalendarTools', 'mcp_TeamsServer'];
|
||||
|
||||
const result = await getMicrosoftMcpTools(mockTurnContext, 'test-token', selectedTools);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(connectMcpClient).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should connect to MCP servers with correct headers', async () => {
|
||||
const mockServers = [{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' }];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
result: mockClient,
|
||||
});
|
||||
|
||||
(getAllTools as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
expect(connectMcpClient).toHaveBeenCalledWith({
|
||||
serverTransport: 'httpStreamable',
|
||||
endpointUrl: 'http://calendar-server',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-token',
|
||||
'x-ms-tenant-id': 'test-tenant-id',
|
||||
},
|
||||
name: 'Microsoft-Agent-365',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle connection errors gracefully', async () => {
|
||||
const mockServers = [
|
||||
{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' },
|
||||
{ mcpServerName: 'mcp_MailTools', url: 'http://mail-server' },
|
||||
];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
(connectMcpClient as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'Connection failed',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { close: jest.fn() },
|
||||
});
|
||||
|
||||
(getAllTools as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
try {
|
||||
await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to connect to MCP server mcp_CalendarTools:',
|
||||
'Connection failed',
|
||||
);
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('should create dynamic tools from MCP tools', async () => {
|
||||
const mockServers = [{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' }];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
result: mockClient,
|
||||
});
|
||||
|
||||
const mockTools = [
|
||||
{ name: 'create_event', description: 'Create calendar event' },
|
||||
{ name: 'list_events', description: 'List calendar events' },
|
||||
];
|
||||
(getAllTools as jest.Mock).mockResolvedValue(mockTools);
|
||||
|
||||
const mockCallTool = jest.fn();
|
||||
(createCallTool as jest.Mock).mockReturnValue(mockCallTool);
|
||||
|
||||
const mockDynamicTool1 = { name: 'create_event' };
|
||||
const mockDynamicTool2 = { name: 'list_events' };
|
||||
(mcpToolToDynamicTool as jest.Mock)
|
||||
.mockReturnValueOnce(mockDynamicTool1)
|
||||
.mockReturnValueOnce(mockDynamicTool2);
|
||||
|
||||
const result = await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.tools).toHaveLength(2);
|
||||
expect(createCallTool).toHaveBeenCalledTimes(2);
|
||||
expect(mcpToolToDynamicTool).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should return undefined when no tools are available', async () => {
|
||||
const mockServers = [{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' }];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
result: mockClient,
|
||||
});
|
||||
|
||||
(getAllTools as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should provide close method that closes all clients', async () => {
|
||||
const mockServers = [
|
||||
{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' },
|
||||
{ mcpServerName: 'mcp_MailTools', url: 'http://mail-server' },
|
||||
];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient1 = { close: jest.fn() };
|
||||
const mockClient2 = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock)
|
||||
.mockResolvedValueOnce({ ok: true, result: mockClient1 })
|
||||
.mockResolvedValueOnce({ ok: true, result: mockClient2 });
|
||||
|
||||
const mockTool = { name: 'test-tool', description: 'Test tool' };
|
||||
(getAllTools as jest.Mock).mockResolvedValue([mockTool]);
|
||||
|
||||
const mockCallTool = jest.fn();
|
||||
(createCallTool as jest.Mock).mockReturnValue(mockCallTool);
|
||||
|
||||
const mockDynamicTool = { name: 'test-tool' };
|
||||
(mcpToolToDynamicTool as jest.Mock).mockReturnValue(mockDynamicTool);
|
||||
|
||||
const result = await getMicrosoftMcpTools(mockTurnContext, 'test-token', undefined);
|
||||
|
||||
await result?.client.close();
|
||||
|
||||
expect(mockClient1.close).toHaveBeenCalled();
|
||||
expect(mockClient2.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should use tenant id from channelData if recipient tenantId is not available', async () => {
|
||||
const contextWithChannelData = {
|
||||
activity: {
|
||||
recipient: {},
|
||||
channelData: { tenant: { id: 'channel-tenant-id' } },
|
||||
},
|
||||
};
|
||||
|
||||
const mockServers = [{ mcpServerName: 'mcp_CalendarTools', url: 'http://calendar-server' }];
|
||||
|
||||
mockConfigService.listToolServers.mockResolvedValue(mockServers);
|
||||
|
||||
const mockClient = { close: jest.fn() };
|
||||
(connectMcpClient as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
result: mockClient,
|
||||
});
|
||||
|
||||
(getAllTools as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
await getMicrosoftMcpTools(contextWithChannelData as any, 'test-token', undefined);
|
||||
|
||||
expect(connectMcpClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'x-ms-tenant-id': 'channel-tenant-id',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureActivityCallback', () => {
|
||||
let nodeContext: IWebhookFunctions;
|
||||
let credentials: MicrosoftAgent365Credentials;
|
||||
let mcpTokenRef: { token: string | undefined };
|
||||
let mockTurnContext: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
nodeContext = mock<IWebhookFunctions>({
|
||||
getNodeParameter: jest.fn(),
|
||||
getNode: jest.fn().mockReturnValue({ name: 'Test Node' }),
|
||||
});
|
||||
|
||||
credentials = {
|
||||
clientId: 'test-client-id',
|
||||
tenantId: 'test-tenant-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
};
|
||||
|
||||
mcpTokenRef = { token: 'test-mcp-token' };
|
||||
|
||||
mockTurnContext = {
|
||||
activity: {
|
||||
text: 'Test message',
|
||||
recipient: {
|
||||
agenticAppId: 'agent-id',
|
||||
name: 'Test Agent',
|
||||
tenantId: 'tenant-id',
|
||||
},
|
||||
conversation: { id: 'conversation-id' },
|
||||
},
|
||||
sendActivity: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
|
||||
test('should invoke agent with input text and system prompt', async () => {
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'systemPrompt') return 'You are a helpful assistant';
|
||||
if (param === 'useMcpTools') return false;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Agent response');
|
||||
|
||||
const callback = configureActivityCallback(nodeContext, credentials, mcpTokenRef);
|
||||
await callback(mockTurnContext);
|
||||
|
||||
expect(invokeAgent).toHaveBeenCalledWith(
|
||||
nodeContext,
|
||||
'Test message',
|
||||
'You are a helpful assistant',
|
||||
{ configurable: { thread_id: 'conversation-id' } },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle empty input text', async () => {
|
||||
const contextWithEmptyText = {
|
||||
...mockTurnContext,
|
||||
activity: {
|
||||
...mockTurnContext.activity,
|
||||
text: '',
|
||||
},
|
||||
};
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'systemPrompt') return 'Test prompt';
|
||||
if (param === 'useMcpTools') return false;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Response');
|
||||
|
||||
const callback = configureActivityCallback(nodeContext, credentials, mcpTokenRef);
|
||||
await callback(contextWithEmptyText);
|
||||
|
||||
expect(invokeAgent).toHaveBeenCalledWith(
|
||||
nodeContext,
|
||||
'',
|
||||
'Test prompt',
|
||||
{ configurable: { thread_id: 'conversation-id' } },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('should not use MCP tools when token is not available', async () => {
|
||||
const noTokenRef = { token: undefined };
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'systemPrompt') return 'Test prompt';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Response');
|
||||
|
||||
const callback = configureActivityCallback(nodeContext, credentials, noTokenRef);
|
||||
await callback(mockTurnContext);
|
||||
|
||||
expect(invokeAgent).toHaveBeenCalledWith(
|
||||
nodeContext,
|
||||
'Test message',
|
||||
'Test prompt',
|
||||
{ configurable: { thread_id: 'conversation-id' } },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('should send agent response to turn context', async () => {
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'systemPrompt') return 'Test prompt';
|
||||
if (param === 'useMcpTools') return false;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Test agent response');
|
||||
|
||||
const callback = configureActivityCallback(nodeContext, credentials, mcpTokenRef);
|
||||
await callback(mockTurnContext);
|
||||
|
||||
expect(mockTurnContext.sendActivity).toHaveBeenCalledWith('Test agent response');
|
||||
});
|
||||
|
||||
test('should use default values when recipient data is missing', async () => {
|
||||
const contextWithoutRecipient = {
|
||||
activity: {
|
||||
text: 'Test message',
|
||||
conversation: { id: 'conversation-id' },
|
||||
recipient: {},
|
||||
},
|
||||
sendActivity: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
(nodeContext.getNodeParameter as jest.Mock).mockImplementation((param: string) => {
|
||||
if (param === 'systemPrompt') return 'Test prompt';
|
||||
if (param === 'useMcpTools') return false;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
(invokeAgent as jest.Mock).mockResolvedValue('Response');
|
||||
|
||||
const callback = configureActivityCallback(nodeContext, credentials, mcpTokenRef);
|
||||
await callback(contextWithoutRecipient as any);
|
||||
|
||||
expect(invokeAgent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('microsoftMcpServers', () => {
|
||||
test('should export correct server options', () => {
|
||||
expect(microsoftMcpServers).toEqual([
|
||||
{ name: 'Calendar', value: 'mcp_CalendarTools' },
|
||||
{ name: 'Mail', value: 'mcp_MailTools' },
|
||||
{ name: 'Me', value: 'mcp_MeServer' },
|
||||
{ name: 'OneDrive & SharePoint', value: 'mcp_ODSPRemoteServer' },
|
||||
{ name: 'SharePoint Lists', value: 'mcp_SharePointListsTools' },
|
||||
{ name: 'Teams', value: 'mcp_TeamsServer' },
|
||||
{ name: 'Teams Canary', value: 'mcp_TeamsCanaryServer' },
|
||||
{ name: 'Word', value: 'mcp_WordServer' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('should have correct number of server options', () => {
|
||||
expect(microsoftMcpServers).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractActivityInfo', () => {
|
||||
test('should extract all fields from a complete activity', () => {
|
||||
const activity = {
|
||||
id: 'activity-123',
|
||||
type: 'message',
|
||||
channelId: 'msteams',
|
||||
conversation: { id: 'conv-456' },
|
||||
from: { id: 'user-789', name: 'John Doe' },
|
||||
recipient: { id: 'bot-abc', name: 'Test Bot' },
|
||||
timestamp: new Date('2024-01-15T10:30:00Z'),
|
||||
locale: 'en-US',
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'activity-123',
|
||||
type: 'message',
|
||||
channelId: 'msteams',
|
||||
conversationId: 'conv-456',
|
||||
from: { id: 'user-789', name: 'John Doe' },
|
||||
recipient: { id: 'bot-abc', name: 'Test Bot' },
|
||||
timestamp: '2024-01-15T10:30:00.000Z',
|
||||
locale: 'en-US',
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle activity with missing optional fields', () => {
|
||||
const activity = {
|
||||
type: 'message',
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: undefined,
|
||||
type: 'message',
|
||||
channelId: undefined,
|
||||
conversationId: undefined,
|
||||
from: undefined,
|
||||
recipient: undefined,
|
||||
timestamp: undefined,
|
||||
locale: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle activity with string timestamp', () => {
|
||||
const activity = {
|
||||
type: 'message',
|
||||
timestamp: '2024-01-15T10:30:00Z',
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result.timestamp).toBe('2024-01-15T10:30:00Z');
|
||||
});
|
||||
|
||||
test('should handle activity with partial from/recipient data', () => {
|
||||
const activity = {
|
||||
type: 'message',
|
||||
from: { id: 'user-123' },
|
||||
recipient: { name: 'Bot Name' },
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result.from).toEqual({ id: 'user-123', name: undefined });
|
||||
expect(result.recipient).toEqual({ id: undefined, name: 'Bot Name' });
|
||||
});
|
||||
|
||||
test('should handle activity with null conversation', () => {
|
||||
const activity = {
|
||||
type: 'message',
|
||||
conversation: null,
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result.conversationId).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should extract conversationId from conversation object', () => {
|
||||
const activity = {
|
||||
type: 'conversationUpdate',
|
||||
conversation: { id: 'conversation-id-123', name: 'Test Conversation' },
|
||||
};
|
||||
|
||||
const result: ActivityInfo = extractActivityInfo(activity as any);
|
||||
|
||||
expect(result.conversationId).toBe('conversation-id-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { NodeOperationError, type IWebhookFunctions, assert, jsonParse } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
getChatModel,
|
||||
getOptionalMemory,
|
||||
getTools,
|
||||
preparePrompt,
|
||||
} from '../../agents/Agent/agents/ToolsAgent/common';
|
||||
|
||||
import { SYSTEM_MESSAGE } from '../../agents/Agent/agents/ConversationalAgent/prompt';
|
||||
import { createAgentExecutor } from '../../agents/Agent/agents/ToolsAgent/V2/execute';
|
||||
import type { RunnableConfig } from '@langchain/core/runnables';
|
||||
|
||||
import { getOptionalOutputParser } from '../../../utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import type { ChatPromptTemplate, BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
|
||||
|
||||
import { type N8nOutputParser } from '../../../utils/output_parsers/N8nOutputParser';
|
||||
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import type { ToolInputSchemaBase } from '@langchain/core/dist/tools/types';
|
||||
|
||||
export async function invokeAgent(
|
||||
nodeContext: IWebhookFunctions,
|
||||
input: string,
|
||||
systemMessage?: string,
|
||||
invokeOptions: RunnableConfig = {},
|
||||
microsoftMcpTools: Array<DynamicStructuredTool<ToolInputSchemaBase, any, any, any>> = [],
|
||||
): Promise<string> {
|
||||
const needsFallback = nodeContext.getNodeParameter('needsFallback', false) as boolean;
|
||||
const memory = await getOptionalMemory(nodeContext);
|
||||
const model = await getChatModel(nodeContext, 0);
|
||||
|
||||
assert(model, 'Please connect a model to the Chat Model input');
|
||||
|
||||
const fallbackModel = needsFallback ? await getChatModel(nodeContext, 1) : null;
|
||||
|
||||
if (needsFallback && !fallbackModel) {
|
||||
throw new NodeOperationError(
|
||||
nodeContext.getNode(),
|
||||
'Please connect a model to the Fallback Model input or disable the fallback option',
|
||||
);
|
||||
}
|
||||
|
||||
const outputParser = await getOptionalOutputParser(nodeContext, 0);
|
||||
let tools = await getTools(nodeContext, outputParser);
|
||||
|
||||
if (microsoftMcpTools?.length) {
|
||||
tools = tools.concat(microsoftMcpTools);
|
||||
}
|
||||
|
||||
const options = nodeContext.getNodeParameter('options', {}) as {
|
||||
systemMessage?: string;
|
||||
maxIterations?: number;
|
||||
};
|
||||
|
||||
if (systemMessage) {
|
||||
options.systemMessage = systemMessage;
|
||||
}
|
||||
|
||||
if (options.maxIterations === undefined) {
|
||||
options.maxIterations = 10;
|
||||
}
|
||||
|
||||
const messages = await prepareMessages({
|
||||
systemMessage: options.systemMessage,
|
||||
outputParser,
|
||||
});
|
||||
const prompt: ChatPromptTemplate = preparePrompt(messages);
|
||||
|
||||
const executor = createAgentExecutor(
|
||||
model,
|
||||
tools,
|
||||
prompt,
|
||||
options,
|
||||
outputParser,
|
||||
memory,
|
||||
fallbackModel,
|
||||
);
|
||||
|
||||
const system_message = options.systemMessage ?? SYSTEM_MESSAGE;
|
||||
|
||||
const invokeParams = {
|
||||
input,
|
||||
system_message,
|
||||
formatting_instructions:
|
||||
'IMPORTANT: For your response to user, you MUST use the `format_final_json_response` tool with your complete answer formatted according to the required schema. Do not attempt to format the JSON manually - always use this tool. Your response will be rejected if it is not properly formatted through this tool. Only use this tool once you are ready to provide your final answer.',
|
||||
};
|
||||
|
||||
const result = await executor.invoke(invokeParams, invokeOptions);
|
||||
|
||||
if (result.status === 'rejected') {
|
||||
const error = result.reason as Error;
|
||||
|
||||
throw new NodeOperationError(nodeContext.getNode(), error);
|
||||
}
|
||||
const response = result;
|
||||
|
||||
if (memory && outputParser) {
|
||||
const parsedOutput = jsonParse<{ output: Record<string, unknown> }>(response.output as string);
|
||||
response.output = parsedOutput?.output ?? parsedOutput;
|
||||
}
|
||||
|
||||
return response.output;
|
||||
}
|
||||
|
||||
async function prepareMessages(options: {
|
||||
systemMessage?: string;
|
||||
outputParser?: N8nOutputParser;
|
||||
}): Promise<BaseMessagePromptTemplateLike[]> {
|
||||
const messages: BaseMessagePromptTemplateLike[] = [];
|
||||
|
||||
if (options.systemMessage) {
|
||||
messages.push([
|
||||
'system',
|
||||
`{system_message}${options.outputParser ? '\n\n{formatting_instructions}' : ''}`,
|
||||
]);
|
||||
} else if (options.outputParser) {
|
||||
messages.push(['system', '{formatting_instructions}']);
|
||||
}
|
||||
|
||||
messages.push([
|
||||
'system',
|
||||
`{system_message}${options.outputParser ? '\n\n{formatting_instructions}' : ''}`,
|
||||
]);
|
||||
|
||||
messages.push(['placeholder', '{chat_history}'], ['human', '{input}']);
|
||||
|
||||
messages.push(['placeholder', '{agent_scratchpad}']);
|
||||
return messages;
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import type {
|
||||
AuthConfiguration,
|
||||
DefaultConversationState,
|
||||
DefaultUserState,
|
||||
TurnContext,
|
||||
TurnState,
|
||||
} from '@microsoft/agents-hosting';
|
||||
|
||||
import { MemoryStorage, AgentApplication, CloudAdapter } from '@microsoft/agents-hosting';
|
||||
import {
|
||||
NodeOperationError,
|
||||
type IWebhookFunctions,
|
||||
type INodePropertyOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
ExecutionType,
|
||||
type InvokeAgentDetails,
|
||||
InvokeAgentScope,
|
||||
type TenantDetails,
|
||||
BaggageBuilder,
|
||||
ObservabilityManager,
|
||||
type Builder,
|
||||
} from '@microsoft/agents-a365-observability';
|
||||
import {
|
||||
getMcpPlatformAuthenticationScope,
|
||||
getObservabilityAuthenticationScope,
|
||||
Utility as RuntimeUtility,
|
||||
} from '@microsoft/agents-a365-runtime';
|
||||
import { type Activity, ActivityTypes } from '@microsoft/agents-activity';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { invokeAgent } from './langchain-utils';
|
||||
|
||||
import { McpToolServerConfigurationService, Utility } from '@microsoft/agents-a365-tooling';
|
||||
|
||||
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { connectMcpClient, getAllTools } from '../../mcp/shared/utils';
|
||||
import { createCallTool, mcpToolToDynamicTool } from '../../mcp/McpClientTool/utils';
|
||||
|
||||
export type MicrosoftAgent365Credentials = {
|
||||
clientId: string;
|
||||
tenantId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
export type ActivityInfo = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
channelId?: string;
|
||||
conversationId?: string;
|
||||
from?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
recipient?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
timestamp?: string;
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
export type ActivityCapture = {
|
||||
input: string;
|
||||
output: string[];
|
||||
activity: ActivityInfo;
|
||||
};
|
||||
|
||||
export function extractActivityInfo(activity: Activity): ActivityInfo {
|
||||
return {
|
||||
id: activity.id,
|
||||
type: activity.type,
|
||||
channelId: activity.channelId,
|
||||
conversationId: activity.conversation?.id,
|
||||
from: activity.from
|
||||
? {
|
||||
id: activity.from.id,
|
||||
name: activity.from.name,
|
||||
}
|
||||
: undefined,
|
||||
recipient: activity.recipient
|
||||
? {
|
||||
id: activity.recipient.id,
|
||||
name: activity.recipient.name,
|
||||
}
|
||||
: undefined,
|
||||
timestamp:
|
||||
activity.timestamp instanceof Date ? activity.timestamp.toISOString() : activity.timestamp,
|
||||
locale: activity.locale,
|
||||
};
|
||||
}
|
||||
|
||||
export const microsoftMcpServers: INodePropertyOptions[] = [
|
||||
{ name: 'Calendar', value: 'mcp_CalendarTools' },
|
||||
{ name: 'Mail', value: 'mcp_MailTools' },
|
||||
{ name: 'Me', value: 'mcp_MeServer' },
|
||||
{ name: 'OneDrive & SharePoint', value: 'mcp_ODSPRemoteServer' },
|
||||
{ name: 'SharePoint Lists', value: 'mcp_SharePointListsTools' },
|
||||
{ name: 'Teams', value: 'mcp_TeamsServer' },
|
||||
{ name: 'Teams Canary', value: 'mcp_TeamsCanaryServer' },
|
||||
{ name: 'Word', value: 'mcp_WordServer' },
|
||||
];
|
||||
|
||||
const MS_TENANT_ID_HEADER = 'x-ms-tenant-id';
|
||||
|
||||
function isMicrosoftObservabilityEnabled(): boolean {
|
||||
return (
|
||||
process.env.ENABLE_OBSERVABILITY === 'true' &&
|
||||
process.env.ENABLE_A365_OBSERVABILITY_EXPORTER === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
export function createMicrosoftAgentApplication(credentials: MicrosoftAgent365Credentials) {
|
||||
const authConfig: AuthConfiguration = createAuthConfig(credentials);
|
||||
|
||||
const adapter = new CloudAdapter(authConfig);
|
||||
const storage = new MemoryStorage();
|
||||
|
||||
const agent: AgentApplication<TurnState> = new AgentApplication<TurnState>({
|
||||
adapter,
|
||||
storage,
|
||||
authorization: {
|
||||
agentic: {
|
||||
type: 'agentic',
|
||||
scopes: ['https://graph.microsoft.com/.default'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
export async function getMicrosoftMcpTools(
|
||||
turnContext: TurnContext,
|
||||
mcpAuthToken: string,
|
||||
selectedTools: string[] | undefined,
|
||||
) {
|
||||
const configService: McpToolServerConfigurationService = new McpToolServerConfigurationService();
|
||||
|
||||
Utility.ValidateAuthToken(mcpAuthToken);
|
||||
|
||||
const agenticAppId = RuntimeUtility.ResolveAgentIdentity(turnContext, mcpAuthToken);
|
||||
let servers = await configService.listToolServers(agenticAppId, mcpAuthToken);
|
||||
|
||||
if (servers.length === 0) return undefined;
|
||||
|
||||
if (selectedTools?.length) {
|
||||
servers = servers.filter((server) => selectedTools.includes(server.mcpServerName));
|
||||
}
|
||||
|
||||
const tenantId =
|
||||
turnContext.activity.recipient?.tenantId || turnContext.activity?.channelData?.tenant?.id;
|
||||
|
||||
const tools: DynamicStructuredTool[] = [];
|
||||
const clients: Client[] = [];
|
||||
const timeout = 60000;
|
||||
|
||||
for (const server of servers) {
|
||||
const headers: Record<string, string> = {};
|
||||
if (mcpAuthToken) {
|
||||
headers['Authorization'] = `Bearer ${mcpAuthToken}`;
|
||||
}
|
||||
if (tenantId) {
|
||||
headers[MS_TENANT_ID_HEADER] = tenantId;
|
||||
}
|
||||
|
||||
const clientResult = await connectMcpClient({
|
||||
serverTransport: 'httpStreamable', // Microsoft servers use HTTP
|
||||
endpointUrl: server.url,
|
||||
headers,
|
||||
name: 'Microsoft-Agent-365',
|
||||
version: 1,
|
||||
});
|
||||
|
||||
if (!clientResult.ok) {
|
||||
console.error(`Failed to connect to MCP server ${server.mcpServerName}:`, clientResult.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
const client = clientResult.result;
|
||||
clients.push(client);
|
||||
|
||||
const mcpTools = await getAllTools(client);
|
||||
|
||||
for (const tool of mcpTools) {
|
||||
const callToolFunc = createCallTool(tool.name, client, timeout, (errorMessage) => {
|
||||
console.error(`Tool "${tool.name}" execution error:`, errorMessage);
|
||||
});
|
||||
|
||||
const dynamicTool = mcpToolToDynamicTool(tool, callToolFunc);
|
||||
tools.push(dynamicTool);
|
||||
}
|
||||
}
|
||||
|
||||
if (tools.length === 0) return undefined;
|
||||
|
||||
return {
|
||||
tools,
|
||||
client: {
|
||||
async close() {
|
||||
await Promise.all(clients.map(async (c) => await c.close()));
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const configureActivityCallback = (
|
||||
nodeContext: IWebhookFunctions,
|
||||
credentials: MicrosoftAgent365Credentials,
|
||||
mcpTokenRef: { token: string | undefined },
|
||||
) => {
|
||||
const systemPrompt = nodeContext.getNodeParameter('systemPrompt') as string;
|
||||
const { clientId, tenantId } = credentials;
|
||||
|
||||
return async (turnContext: TurnContext) => {
|
||||
const agentId = turnContext.activity.recipient?.agenticAppId ?? clientId;
|
||||
const agentName = turnContext.activity.recipient?.name ?? 'Microsoft Agent 365';
|
||||
const tenantDetails: TenantDetails = {
|
||||
tenantId: turnContext.activity.recipient?.tenantId ?? tenantId ?? '',
|
||||
};
|
||||
const conversationId = turnContext.activity.conversation?.id;
|
||||
const inputText = turnContext.activity.text || '';
|
||||
|
||||
const baggageScope = new BaggageBuilder()
|
||||
.tenantId(tenantDetails.tenantId)
|
||||
.agentId(agentId)
|
||||
.correlationId(uuid())
|
||||
.agentName(agentName)
|
||||
.conversationId(conversationId)
|
||||
.build();
|
||||
|
||||
await baggageScope.run(async () => {
|
||||
const invokeAgentDetails: InvokeAgentDetails = {
|
||||
agentId,
|
||||
agentName,
|
||||
conversationId,
|
||||
request: {
|
||||
content: inputText || 'Unknown text',
|
||||
executionType: ExecutionType.HumanToAgent,
|
||||
sessionId: conversationId,
|
||||
},
|
||||
};
|
||||
|
||||
const invokeAgentScope = InvokeAgentScope.start(invokeAgentDetails, tenantDetails);
|
||||
|
||||
await invokeAgentScope.withActiveSpanAsync(async () => {
|
||||
invokeAgentScope.recordInputMessages([inputText || 'Unknown text']);
|
||||
|
||||
let microsoftMcpTools = undefined;
|
||||
let mcpClient = undefined;
|
||||
if (mcpTokenRef.token) {
|
||||
try {
|
||||
const useMcpTools = nodeContext.getNodeParameter('useMcpTools', false) as boolean;
|
||||
|
||||
if (useMcpTools) {
|
||||
let selectedTools: string[] | undefined = undefined;
|
||||
const include = nodeContext.getNodeParameter('include', 'all') as 'all' | 'selected';
|
||||
|
||||
if (include === 'selected') {
|
||||
const selected = nodeContext.getNodeParameter('includeTools', []) as string[];
|
||||
selectedTools = microsoftMcpServers
|
||||
.filter((server) => selected.includes(server.value as string))
|
||||
.map((server) => server.value as string);
|
||||
}
|
||||
|
||||
const result = await getMicrosoftMcpTools(
|
||||
turnContext,
|
||||
mcpTokenRef.token,
|
||||
selectedTools,
|
||||
);
|
||||
|
||||
mcpClient = result?.client;
|
||||
microsoftMcpTools = result?.tools;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error retrieving MCP tools');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await invokeAgent(
|
||||
nodeContext,
|
||||
inputText,
|
||||
systemPrompt,
|
||||
{
|
||||
configurable: { thread_id: turnContext.activity.conversation!.id },
|
||||
},
|
||||
microsoftMcpTools,
|
||||
);
|
||||
|
||||
invokeAgentScope.recordOutputMessages([`n8n Agent Response: ${response}`]);
|
||||
|
||||
await turnContext.sendActivity(response);
|
||||
} finally {
|
||||
if (mcpClient) await mcpClient.close();
|
||||
invokeAgentScope.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function configureAdapterProcessCallback(
|
||||
nodeContext: IWebhookFunctions,
|
||||
agent: AgentApplication<TurnState<DefaultConversationState, DefaultUserState>>,
|
||||
credentials: MicrosoftAgent365Credentials,
|
||||
activityCapture: ActivityCapture,
|
||||
) {
|
||||
return async (turnContext: TurnContext) => {
|
||||
const { token: aauToken } = await agent.authorization.exchangeToken(
|
||||
turnContext,
|
||||
getObservabilityAuthenticationScope(),
|
||||
'agentic',
|
||||
);
|
||||
|
||||
let observability: ReturnType<typeof ObservabilityManager.configure> | undefined;
|
||||
|
||||
if (isMicrosoftObservabilityEnabled()) {
|
||||
observability = ObservabilityManager.configure((builder: Builder) =>
|
||||
builder
|
||||
.withService('TypeScript Sample Agent', '1.0.0')
|
||||
.withTokenResolver((_agentId: string, _tenantId: string) => aauToken || ''),
|
||||
);
|
||||
|
||||
observability.start();
|
||||
}
|
||||
|
||||
const mcpTokenRef = { token: undefined as string | undefined };
|
||||
|
||||
try {
|
||||
turnContext.turnState.set('AgenticAuthorization/agentic', undefined);
|
||||
const tokenResult = await agent.authorization.exchangeToken(turnContext, 'agentic', {
|
||||
scopes: [getMcpPlatformAuthenticationScope()],
|
||||
});
|
||||
mcpTokenRef.token = tokenResult.token;
|
||||
} catch (error) {
|
||||
console.error('Error getting MCP token');
|
||||
}
|
||||
|
||||
try {
|
||||
const originalSendActivity = turnContext.sendActivity.bind(turnContext);
|
||||
activityCapture.input = turnContext.activity.text || '';
|
||||
activityCapture.activity = extractActivityInfo(turnContext.activity);
|
||||
|
||||
const sendActivityWrapper = async (activityOrText: string | Activity) => {
|
||||
if (typeof activityOrText === 'string') {
|
||||
activityCapture.output.push(activityOrText);
|
||||
} else if (activityOrText.text) {
|
||||
activityCapture.output.push(activityOrText.text);
|
||||
}
|
||||
return await originalSendActivity(activityOrText);
|
||||
};
|
||||
|
||||
turnContext.sendActivity = sendActivityWrapper;
|
||||
|
||||
const welcomeMessage = nodeContext.getNodeParameter(
|
||||
'options.welcomeMessage',
|
||||
"Hello! I'm here to help you!",
|
||||
) as string;
|
||||
|
||||
agent.onConversationUpdate('membersAdded', async (context) => {
|
||||
await context.sendActivity(welcomeMessage);
|
||||
});
|
||||
|
||||
const onActivity = configureActivityCallback(nodeContext, credentials, mcpTokenRef);
|
||||
agent.onActivity(ActivityTypes.Message, onActivity, ['agentic']);
|
||||
|
||||
await agent.run(turnContext);
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(nodeContext.getNode(), error);
|
||||
} finally {
|
||||
if (observability) {
|
||||
await observability.shutdown();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const createAuthConfig = (credentials: MicrosoftAgent365Credentials) => {
|
||||
const { clientId, tenantId, clientSecret } = credentials;
|
||||
const connections: Map<string, AuthConfiguration> = new Map();
|
||||
connections.set('serviceConnection', {
|
||||
clientId,
|
||||
clientSecret,
|
||||
tenantId,
|
||||
authority: 'https://login.microsoftonline.com',
|
||||
issuers: [
|
||||
'https://api.botframework.com',
|
||||
`https://sts.windows.net/${tenantId}/`,
|
||||
`https://login.microsoftonline.com/${tenantId}/v2.0`,
|
||||
],
|
||||
});
|
||||
|
||||
const config = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
tenantId,
|
||||
authority: 'https://login.microsoftonline.com',
|
||||
issuers: [
|
||||
'https://api.botframework.com',
|
||||
`https://sts.windows.net/${tenantId}/`,
|
||||
`https://login.microsoftonline.com/${tenantId}/v2.0`,
|
||||
],
|
||||
connections,
|
||||
connectionsMap: [
|
||||
{
|
||||
connection: 'serviceConnection',
|
||||
serviceUrl: '*',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,680 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import * as helpers from '@utils/helpers';
|
||||
|
||||
import * as image from './actions/image';
|
||||
import * as text from './actions/text';
|
||||
import * as transport from './transport';
|
||||
import type { OllamaChatResponse, OllamaMessage } from './helpers/interfaces';
|
||||
|
||||
describe('Ollama Node', () => {
|
||||
const executeFunctionsMock = mockDeep<IExecuteFunctions>();
|
||||
const apiRequestMock = jest.spyOn(transport, 'apiRequest');
|
||||
const getConnectedToolsMock = jest.spyOn(helpers, 'getConnectedTools');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Text -> Message', () => {
|
||||
it('should call the API with correct parameters for basic message', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
switch (parameter) {
|
||||
case 'modelId':
|
||||
return 'llama3.2:latest';
|
||||
case 'messages.values':
|
||||
return [{ role: 'user', content: 'Hello, world!' }];
|
||||
case 'simplify':
|
||||
return true;
|
||||
case 'options':
|
||||
return {
|
||||
system: 'You are a helpful assistant.',
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
top_k: 40,
|
||||
num_predict: 1024,
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
||||
getConnectedToolsMock.mockResolvedValue([]);
|
||||
apiRequestMock.mockResolvedValue({
|
||||
model: 'llama3.2:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: { role: 'assistant', content: 'Hello! How can I help you today?' },
|
||||
done: true,
|
||||
} as OllamaChatResponse);
|
||||
|
||||
const result = await text.message.execute.call(executeFunctionsMock, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { content: 'Hello! How can I help you today?' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', {
|
||||
body: {
|
||||
model: 'llama3.2:latest',
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a helpful assistant.' },
|
||||
{ role: 'user', content: 'Hello, world!' },
|
||||
],
|
||||
stream: false,
|
||||
tools: [],
|
||||
options: {
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
top_k: 40,
|
||||
num_predict: 1024,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return full response when simplify is false', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
switch (parameter) {
|
||||
case 'modelId':
|
||||
return 'llama3.2:latest';
|
||||
case 'messages.values':
|
||||
return [{ role: 'user', content: 'Test message' }];
|
||||
case 'simplify':
|
||||
return false;
|
||||
case 'options':
|
||||
return {};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
||||
getConnectedToolsMock.mockResolvedValue([]);
|
||||
const mockResponse = {
|
||||
model: 'llama3.2:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: { role: 'assistant', content: 'Test response' },
|
||||
done: true,
|
||||
total_duration: 5000000,
|
||||
load_duration: 1000000,
|
||||
eval_count: 10,
|
||||
eval_duration: 2000000,
|
||||
} as OllamaChatResponse;
|
||||
apiRequestMock.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await text.message.execute.call(executeFunctionsMock, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle tool calls correctly', async () => {
|
||||
const mockTool = {
|
||||
name: 'calculator',
|
||||
description: 'Performs calculations',
|
||||
schema: z.object({
|
||||
expression: z.string().describe('Mathematical expression to evaluate'),
|
||||
}),
|
||||
invoke: jest.fn().mockResolvedValue({ result: 42 }),
|
||||
};
|
||||
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
switch (parameter) {
|
||||
case 'modelId':
|
||||
return 'llama3.2:latest';
|
||||
case 'messages.values':
|
||||
return [{ role: 'user', content: 'What is 6 * 7?' }];
|
||||
case 'simplify':
|
||||
return true;
|
||||
case 'options':
|
||||
return {};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }, { type: 'ai_tool' }]);
|
||||
// @ts-expect-error: Mocking a tool, we do not implement the full interface
|
||||
getConnectedToolsMock.mockResolvedValue([mockTool]);
|
||||
|
||||
apiRequestMock.mockResolvedValueOnce({
|
||||
model: 'llama3.2:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: 'calculator',
|
||||
arguments: { expression: '6 * 7' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
done: true,
|
||||
} as OllamaChatResponse);
|
||||
|
||||
apiRequestMock.mockResolvedValueOnce({
|
||||
model: 'llama3.2:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: { role: 'assistant', content: 'The result is 42.' },
|
||||
done: true,
|
||||
} as OllamaChatResponse);
|
||||
|
||||
const result = await text.message.execute.call(executeFunctionsMock, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { content: 'The result is 42.' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith({ expression: '6 * 7' });
|
||||
expect(apiRequestMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle tool execution errors gracefully', async () => {
|
||||
const mockTool = {
|
||||
name: 'failing_tool',
|
||||
description: 'A tool that fails',
|
||||
schema: z.object({}),
|
||||
invoke: jest.fn().mockRejectedValue(new Error('Tool execution failed')),
|
||||
};
|
||||
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
switch (parameter) {
|
||||
case 'modelId':
|
||||
return 'llama3.2:latest';
|
||||
case 'messages.values':
|
||||
return [{ role: 'user', content: 'Use the failing tool' }];
|
||||
case 'simplify':
|
||||
return true;
|
||||
case 'options':
|
||||
return {};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }, { type: 'ai_tool' }]);
|
||||
// @ts-expect-error: Mocking a tool, we do not implement the full interface
|
||||
getConnectedToolsMock.mockResolvedValue([mockTool]);
|
||||
|
||||
apiRequestMock.mockResolvedValueOnce({
|
||||
model: 'llama3.2:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: 'failing_tool',
|
||||
arguments: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
done: true,
|
||||
} as OllamaChatResponse);
|
||||
|
||||
apiRequestMock.mockResolvedValueOnce({
|
||||
model: 'llama3.2:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: { role: 'assistant', content: 'I encountered an error with the tool.' },
|
||||
done: true,
|
||||
} as OllamaChatResponse);
|
||||
|
||||
const result = await text.message.execute.call(executeFunctionsMock, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { content: 'I encountered an error with the tool.' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
|
||||
const secondCallBody = apiRequestMock.mock.calls[1][2]?.body as any;
|
||||
const toolMessage = secondCallBody.messages.find((msg: OllamaMessage) => msg.role === 'tool');
|
||||
expect(toolMessage.content).toBe('Error executing tool: Tool execution failed');
|
||||
});
|
||||
|
||||
it('should process stop sequences correctly', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
switch (parameter) {
|
||||
case 'modelId':
|
||||
return 'llama3.2:latest';
|
||||
case 'messages.values':
|
||||
return [{ role: 'user', content: 'Generate text' }];
|
||||
case 'simplify':
|
||||
return true;
|
||||
case 'options':
|
||||
return {
|
||||
stop: '###,END,STOP',
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
||||
getConnectedToolsMock.mockResolvedValue([]);
|
||||
apiRequestMock.mockResolvedValue({
|
||||
model: 'llama3.2:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: { role: 'assistant', content: 'Generated text' },
|
||||
done: true,
|
||||
} as OllamaChatResponse);
|
||||
|
||||
await text.message.execute.call(executeFunctionsMock, 0);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', {
|
||||
body: {
|
||||
model: 'llama3.2:latest',
|
||||
messages: [{ role: 'user', content: 'Generate text' }],
|
||||
stream: false,
|
||||
tools: [],
|
||||
options: {
|
||||
stop: ['###', 'END', 'STOP'],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle various model-specific options', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
switch (parameter) {
|
||||
case 'modelId':
|
||||
return 'llama3.2:latest';
|
||||
case 'messages.values':
|
||||
return [{ role: 'user', content: 'Test with options' }];
|
||||
case 'simplify':
|
||||
return true;
|
||||
case 'options':
|
||||
return {
|
||||
temperature: 0.5,
|
||||
top_p: 0.8,
|
||||
top_k: 30,
|
||||
num_predict: 512,
|
||||
frequency_penalty: 0.1,
|
||||
presence_penalty: 0.2,
|
||||
repeat_penalty: 1.2,
|
||||
num_ctx: 2048,
|
||||
repeat_last_n: 32,
|
||||
min_p: 0.1,
|
||||
seed: 123,
|
||||
low_vram: true,
|
||||
main_gpu: 1,
|
||||
num_batch: 256,
|
||||
num_gpu: 2,
|
||||
num_thread: 8,
|
||||
penalize_newline: false,
|
||||
use_mlock: true,
|
||||
use_mmap: false,
|
||||
vocab_only: false,
|
||||
keep_alive: '10m',
|
||||
format: 'json',
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]);
|
||||
getConnectedToolsMock.mockResolvedValue([]);
|
||||
apiRequestMock.mockResolvedValue({
|
||||
model: 'llama3.2:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: { role: 'assistant', content: '{"response": "test"}' },
|
||||
done: true,
|
||||
} as OllamaChatResponse);
|
||||
|
||||
await text.message.execute.call(executeFunctionsMock, 0);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', {
|
||||
body: {
|
||||
model: 'llama3.2:latest',
|
||||
messages: [{ role: 'user', content: 'Test with options' }],
|
||||
stream: false,
|
||||
tools: [],
|
||||
options: {
|
||||
temperature: 0.5,
|
||||
top_p: 0.8,
|
||||
top_k: 30,
|
||||
num_predict: 512,
|
||||
frequency_penalty: 0.1,
|
||||
presence_penalty: 0.2,
|
||||
repeat_penalty: 1.2,
|
||||
num_ctx: 2048,
|
||||
repeat_last_n: 32,
|
||||
min_p: 0.1,
|
||||
seed: 123,
|
||||
low_vram: true,
|
||||
main_gpu: 1,
|
||||
num_batch: 256,
|
||||
num_gpu: 2,
|
||||
num_thread: 8,
|
||||
penalize_newline: false,
|
||||
use_mlock: true,
|
||||
use_mmap: false,
|
||||
vocab_only: false,
|
||||
keep_alive: '10m',
|
||||
format: 'json',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Image -> Analyze', () => {
|
||||
it('should analyze image from binary data', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
switch (parameter) {
|
||||
case 'modelId':
|
||||
return 'llava:latest';
|
||||
case 'inputType':
|
||||
return 'binary';
|
||||
case 'binaryPropertyName':
|
||||
return 'data';
|
||||
case 'text':
|
||||
return "What's in this image?";
|
||||
case 'simplify':
|
||||
return true;
|
||||
case 'options':
|
||||
return {
|
||||
temperature: 0.3,
|
||||
num_predict: 512,
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(
|
||||
Buffer.from('test image data'),
|
||||
);
|
||||
apiRequestMock.mockResolvedValue({
|
||||
model: 'llava:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'This image shows a beautiful mountain landscape with snow-capped peaks.',
|
||||
},
|
||||
done: true,
|
||||
} as OllamaChatResponse);
|
||||
|
||||
const result = await image.analyze.execute.call(executeFunctionsMock, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
content: 'This image shows a beautiful mountain landscape with snow-capped peaks.',
|
||||
},
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', {
|
||||
body: {
|
||||
model: 'llava:latest',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: "What's in this image?",
|
||||
images: ['dGVzdCBpbWFnZSBkYXRh'], // base64 encoded 'test image data'
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.3,
|
||||
num_predict: 512,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should analyze image from URL', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
switch (parameter) {
|
||||
case 'modelId':
|
||||
return 'llava:latest';
|
||||
case 'inputType':
|
||||
return 'url';
|
||||
case 'imageUrls':
|
||||
return 'https://example.com/test-image.jpg';
|
||||
case 'text':
|
||||
return 'Describe this image';
|
||||
case 'simplify':
|
||||
return true;
|
||||
case 'options':
|
||||
return {};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
executeFunctionsMock.helpers.httpRequest.mockResolvedValue(
|
||||
Buffer.from('downloaded image data'),
|
||||
);
|
||||
apiRequestMock.mockResolvedValue({
|
||||
model: 'llava:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'This image contains a sunset over the ocean.',
|
||||
},
|
||||
done: true,
|
||||
} as OllamaChatResponse);
|
||||
|
||||
const result = await image.analyze.execute.call(executeFunctionsMock, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { content: 'This image contains a sunset over the ocean.' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(executeFunctionsMock.helpers.httpRequest).toHaveBeenCalledWith({
|
||||
method: 'GET',
|
||||
url: 'https://example.com/test-image.jpg',
|
||||
encoding: 'arraybuffer',
|
||||
});
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', {
|
||||
body: {
|
||||
model: 'llava:latest',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Describe this image',
|
||||
images: ['ZG93bmxvYWRlZCBpbWFnZSBkYXRh'], // base64 encoded 'downloaded image data'
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
options: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple images from URLs', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
switch (parameter) {
|
||||
case 'modelId':
|
||||
return 'llava:latest';
|
||||
case 'inputType':
|
||||
return 'url';
|
||||
case 'imageUrls':
|
||||
return 'https://example.com/image1.jpg, https://example.com/image2.png';
|
||||
case 'text':
|
||||
return 'Compare these images';
|
||||
case 'simplify':
|
||||
return true;
|
||||
case 'options':
|
||||
return {};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
executeFunctionsMock.helpers.httpRequest.mockResolvedValueOnce(
|
||||
Buffer.from('first image data'),
|
||||
);
|
||||
executeFunctionsMock.helpers.httpRequest.mockResolvedValueOnce(
|
||||
Buffer.from('second image data'),
|
||||
);
|
||||
apiRequestMock.mockResolvedValue({
|
||||
model: 'llava:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'Both images show different landscapes.',
|
||||
},
|
||||
done: true,
|
||||
} as OllamaChatResponse);
|
||||
|
||||
const result = await image.analyze.execute.call(executeFunctionsMock, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { content: 'Both images show different landscapes.' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(executeFunctionsMock.helpers.httpRequest).toHaveBeenCalledTimes(2);
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', {
|
||||
body: {
|
||||
model: 'llava:latest',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Compare these images',
|
||||
images: [
|
||||
'Zmlyc3QgaW1hZ2UgZGF0YQ==', // base64 encoded 'first image data'
|
||||
'c2Vjb25kIGltYWdlIGRhdGE=', // base64 encoded 'second image data'
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
options: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple binary images', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
switch (parameter) {
|
||||
case 'modelId':
|
||||
return 'llava:latest';
|
||||
case 'inputType':
|
||||
return 'binary';
|
||||
case 'binaryPropertyName':
|
||||
return 'image1,image2';
|
||||
case 'text':
|
||||
return 'Analyze these images';
|
||||
case 'simplify':
|
||||
return false;
|
||||
case 'options':
|
||||
return {};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValueOnce(
|
||||
Buffer.from('first binary image'),
|
||||
);
|
||||
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValueOnce(
|
||||
Buffer.from('second binary image'),
|
||||
);
|
||||
const mockResponse = {
|
||||
model: 'llava:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'Analysis complete for both images.',
|
||||
},
|
||||
done: true,
|
||||
eval_count: 25,
|
||||
eval_duration: 3000000,
|
||||
} as OllamaChatResponse;
|
||||
apiRequestMock.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await image.analyze.execute.call(executeFunctionsMock, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: mockResponse,
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
expect(executeFunctionsMock.helpers.getBinaryDataBuffer).toHaveBeenCalledTimes(2);
|
||||
expect(executeFunctionsMock.helpers.getBinaryDataBuffer).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
0,
|
||||
'image1',
|
||||
);
|
||||
expect(executeFunctionsMock.helpers.getBinaryDataBuffer).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
0,
|
||||
'image2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should process stop sequences for image analysis', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
switch (parameter) {
|
||||
case 'modelId':
|
||||
return 'llava:latest';
|
||||
case 'inputType':
|
||||
return 'binary';
|
||||
case 'binaryPropertyName':
|
||||
return 'data';
|
||||
case 'text':
|
||||
return 'Describe briefly';
|
||||
case 'simplify':
|
||||
return true;
|
||||
case 'options':
|
||||
return {
|
||||
stop: 'END,DONE',
|
||||
temperature: 0.1,
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(Buffer.from('test image'));
|
||||
apiRequestMock.mockResolvedValue({
|
||||
model: 'llava:latest',
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'A simple image.',
|
||||
},
|
||||
done: true,
|
||||
} as OllamaChatResponse);
|
||||
|
||||
await image.analyze.execute.call(executeFunctionsMock, 0);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('POST', '/api/chat', {
|
||||
body: {
|
||||
model: 'llava:latest',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Describe briefly',
|
||||
images: ['dGVzdCBpbWFnZQ=='], // base64 encoded 'test image'
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
options: {
|
||||
stop: ['END', 'DONE'],
|
||||
temperature: 0.1,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 Ollama implements INodeType {
|
||||
description = versionDescription;
|
||||
|
||||
methods = {
|
||||
listSearch,
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const modelRLC: INodeProperties = {
|
||||
displayName: 'Model',
|
||||
name: 'modelId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'modelSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. llava, llama3.2-vision',
|
||||
},
|
||||
],
|
||||
};
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { OllamaChatResponse, OllamaMessage } from '../../helpers';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { modelRLC } from '../descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
modelRLC,
|
||||
{
|
||||
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: 'binary',
|
||||
options: [
|
||||
{
|
||||
name: 'Binary File(s)',
|
||||
value: 'binary',
|
||||
},
|
||||
{
|
||||
name: 'Image URL(s)',
|
||||
value: '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: '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: '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: 'System Message',
|
||||
name: 'system',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. You are a helpful assistant.',
|
||||
description: 'System message to set the context for the conversation',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Temperature',
|
||||
name: 'temperature',
|
||||
type: 'number',
|
||||
default: 0.8,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 2,
|
||||
numberPrecision: 2,
|
||||
},
|
||||
description: 'Controls randomness in responses. Lower values make output more focused.',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Top P)',
|
||||
name: 'top_p',
|
||||
default: 0.7,
|
||||
description: 'The maximum cumulative probability of tokens to consider when sampling',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Top K',
|
||||
name: 'top_k',
|
||||
type: 'number',
|
||||
default: 40,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
description: 'Controls diversity by limiting the number of top tokens to consider',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Tokens',
|
||||
name: 'num_predict',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description: 'Maximum number of tokens to generate in the completion',
|
||||
},
|
||||
{
|
||||
displayName: 'Frequency Penalty',
|
||||
name: 'frequency_penalty',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 2,
|
||||
},
|
||||
description:
|
||||
'Adjusts the penalty for tokens that have already appeared in the generated text. Higher values discourage repetition.',
|
||||
},
|
||||
{
|
||||
displayName: 'Presence Penalty',
|
||||
name: 'presence_penalty',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
typeOptions: {
|
||||
numberPrecision: 2,
|
||||
},
|
||||
description:
|
||||
'Adjusts the penalty for tokens based on their presence in the generated text so far. Positive values penalize tokens that have already appeared, encouraging diversity.',
|
||||
},
|
||||
{
|
||||
displayName: 'Repetition Penalty',
|
||||
name: 'repeat_penalty',
|
||||
type: 'number',
|
||||
default: 1.1,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 2,
|
||||
},
|
||||
description:
|
||||
'Sets how strongly to penalize repetitions. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient.',
|
||||
},
|
||||
{
|
||||
displayName: 'Context Length',
|
||||
name: 'num_ctx',
|
||||
type: 'number',
|
||||
default: 4096,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description: 'Sets the size of the context window used to generate the next token',
|
||||
},
|
||||
{
|
||||
displayName: 'Repeat Last N',
|
||||
name: 'repeat_last_n',
|
||||
type: 'number',
|
||||
default: 64,
|
||||
typeOptions: {
|
||||
minValue: -1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Sets how far back for the model to look back to prevent repetition. (0 = disabled, -1 = num_ctx).',
|
||||
},
|
||||
{
|
||||
displayName: 'Min P',
|
||||
name: 'min_p',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
numberPrecision: 3,
|
||||
},
|
||||
description:
|
||||
'Alternative to the top_p, and aims to ensure a balance of quality and variety. The parameter p represents the minimum probability for a token to be considered, relative to the probability of the most likely token.',
|
||||
},
|
||||
{
|
||||
displayName: 'Seed',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt.',
|
||||
},
|
||||
{
|
||||
displayName: 'Stop Sequences',
|
||||
name: 'stop',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Sets the stop sequences to use. When this pattern is encountered the LLM will stop generating text and return. Separate multiple patterns with commas',
|
||||
},
|
||||
{
|
||||
displayName: 'Keep Alive',
|
||||
name: 'keep_alive',
|
||||
type: 'string',
|
||||
default: '5m',
|
||||
description:
|
||||
'Specifies the duration to keep the loaded model in memory after use. Format: 1h30m (1 hour 30 minutes).',
|
||||
},
|
||||
{
|
||||
displayName: 'Low VRAM Mode',
|
||||
name: 'low_vram',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to activate low VRAM mode, which reduces memory usage at the cost of slower generation speed. Useful for GPUs with limited memory.',
|
||||
},
|
||||
{
|
||||
displayName: 'Main GPU ID',
|
||||
name: 'main_gpu',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Specifies the ID of the GPU to use for the main computation. Only change this if you have multiple GPUs.',
|
||||
},
|
||||
{
|
||||
displayName: 'Context Batch Size',
|
||||
name: 'num_batch',
|
||||
type: 'number',
|
||||
default: 512,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Sets the batch size for prompt processing. Larger batch sizes may improve generation speed but increase memory usage.',
|
||||
},
|
||||
{
|
||||
displayName: 'Number of GPUs',
|
||||
name: 'num_gpu',
|
||||
type: 'number',
|
||||
default: -1,
|
||||
typeOptions: {
|
||||
minValue: -1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Specifies the number of GPUs to use for parallel processing. Set to -1 for auto-detection.',
|
||||
},
|
||||
{
|
||||
displayName: 'Number of CPU Threads',
|
||||
name: 'num_thread',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Specifies the number of CPU threads to use for processing. Set to 0 for auto-detection.',
|
||||
},
|
||||
{
|
||||
displayName: 'Penalize Newlines',
|
||||
name: 'penalize_newline',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether the model will be less likely to generate newline characters, encouraging longer continuous sequences of text',
|
||||
},
|
||||
{
|
||||
displayName: 'Use Memory Locking',
|
||||
name: 'use_mlock',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to lock the model in memory to prevent swapping. This can improve performance but requires sufficient available memory.',
|
||||
},
|
||||
{
|
||||
displayName: 'Use Memory Mapping',
|
||||
name: 'use_mmap',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to use memory mapping for loading the model. This can reduce memory usage but may impact performance.',
|
||||
},
|
||||
{
|
||||
displayName: 'Load Vocabulary Only',
|
||||
name: 'vocab_only',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to only load the model vocabulary without the weights. Useful for quickly testing tokenization.',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Format',
|
||||
name: 'format',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Default', value: '' },
|
||||
{ name: 'JSON', value: 'json' },
|
||||
],
|
||||
default: '',
|
||||
description: 'Specifies the format of the API response',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface MessageOptions {
|
||||
system?: string;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
num_predict?: number;
|
||||
frequency_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
repeat_penalty?: number;
|
||||
num_ctx?: number;
|
||||
repeat_last_n?: number;
|
||||
min_p?: number;
|
||||
seed?: number;
|
||||
stop?: string | string[];
|
||||
low_vram?: boolean;
|
||||
main_gpu?: number;
|
||||
num_batch?: number;
|
||||
num_gpu?: number;
|
||||
num_thread?: number;
|
||||
penalize_newline?: boolean;
|
||||
use_mlock?: boolean;
|
||||
use_mmap?: boolean;
|
||||
vocab_only?: boolean;
|
||||
format?: string;
|
||||
keep_alive?: string;
|
||||
}
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['analyze'],
|
||||
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 inputType = this.getNodeParameter('inputType', i, 'binary') 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, {}) as MessageOptions;
|
||||
|
||||
let images: string[];
|
||||
|
||||
if (inputType === 'url') {
|
||||
const urls = this.getNodeParameter('imageUrls', i, '') as string;
|
||||
const urlList = urls
|
||||
.split(',')
|
||||
.map((url) => url.trim())
|
||||
.filter((url) => url);
|
||||
|
||||
// For URL inputs, we need to download and convert to base64
|
||||
const imagePromises = urlList.map(async (url) => {
|
||||
const response = (await this.helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url,
|
||||
encoding: 'arraybuffer',
|
||||
})) as Buffer;
|
||||
return response.toString('base64');
|
||||
});
|
||||
|
||||
images = await Promise.all(imagePromises);
|
||||
} else {
|
||||
const binaryPropertyNames = this.getNodeParameter('binaryPropertyName', i, 'data');
|
||||
const propertyNames = binaryPropertyNames
|
||||
.split(',')
|
||||
.map((name: string) => name.trim())
|
||||
.filter((name: string) => name);
|
||||
|
||||
const imagePromises = propertyNames.map(async (binaryPropertyName: string) => {
|
||||
const buffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName);
|
||||
return buffer.toString('base64');
|
||||
});
|
||||
|
||||
images = await Promise.all(imagePromises);
|
||||
}
|
||||
|
||||
const messages: OllamaMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: text,
|
||||
images,
|
||||
},
|
||||
];
|
||||
|
||||
const processedOptions = { ...options };
|
||||
if (processedOptions.stop && typeof processedOptions.stop === 'string') {
|
||||
processedOptions.stop = processedOptions.stop
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const body = {
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
options: processedOptions,
|
||||
};
|
||||
|
||||
const response: OllamaChatResponse = await apiRequest.call(this, 'POST', '/api/chat', {
|
||||
body,
|
||||
});
|
||||
|
||||
if (simplify) {
|
||||
return [
|
||||
{
|
||||
json: { content: response.message.content },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: { ...response },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 Image',
|
||||
value: 'analyze',
|
||||
action: 'Analyze image',
|
||||
description: 'Take in images and answer questions about them',
|
||||
},
|
||||
],
|
||||
default: 'analyze',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['image'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...analyze.description,
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { AllEntities } from 'n8n-workflow';
|
||||
|
||||
type NodeMap = {
|
||||
text: 'message';
|
||||
image: 'analyze';
|
||||
};
|
||||
|
||||
export type OllamaType = AllEntities<NodeMap>;
|
||||
@@ -0,0 +1,226 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import { NodeOperationError, type IExecuteFunctions, type INode } from 'n8n-workflow';
|
||||
|
||||
import * as image from './image';
|
||||
import * as text from './text';
|
||||
import { router } from './router';
|
||||
|
||||
jest.mock('./image');
|
||||
jest.mock('./text');
|
||||
|
||||
describe('Ollama Router', () => {
|
||||
const executeFunctionsMock = mockDeep<IExecuteFunctions>();
|
||||
const mockImageExecute = jest.fn();
|
||||
const mockTextExecute = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
(image as any).analyze = { execute: mockImageExecute };
|
||||
(text as any).message = { execute: mockTextExecute };
|
||||
|
||||
executeFunctionsMock.getInputData.mockReturnValue([
|
||||
{ json: { input: 'test1' } },
|
||||
{ json: { input: 'test2' } },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('router', () => {
|
||||
it('should route to text.message operation', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
if (parameter === 'resource') return 'text';
|
||||
if (parameter === 'operation') return 'message';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockTextExecute.mockResolvedValueOnce([
|
||||
{ json: { result: 'response1' }, pairedItem: { item: 0 } },
|
||||
]);
|
||||
mockTextExecute.mockResolvedValueOnce([
|
||||
{ json: { result: 'response2' }, pairedItem: { item: 1 } },
|
||||
]);
|
||||
|
||||
const result = await router.call(executeFunctionsMock);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ json: { result: 'response1' }, pairedItem: { item: 0 } },
|
||||
{ json: { result: 'response2' }, pairedItem: { item: 1 } },
|
||||
],
|
||||
]);
|
||||
expect(mockTextExecute).toHaveBeenCalledTimes(2);
|
||||
expect(mockTextExecute).toHaveBeenNthCalledWith(1, 0);
|
||||
expect(mockTextExecute).toHaveBeenNthCalledWith(2, 1);
|
||||
});
|
||||
|
||||
it('should route to image.analyze operation', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
if (parameter === 'resource') return 'image';
|
||||
if (parameter === 'operation') return 'analyze';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockImageExecute.mockResolvedValueOnce([
|
||||
{ json: { result: 'image analysis 1' }, pairedItem: { item: 0 } },
|
||||
]);
|
||||
mockImageExecute.mockResolvedValueOnce([
|
||||
{ json: { result: 'image analysis 2' }, pairedItem: { item: 1 } },
|
||||
]);
|
||||
|
||||
const result = await router.call(executeFunctionsMock);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ json: { result: 'image analysis 1' }, pairedItem: { item: 0 } },
|
||||
{ json: { result: 'image analysis 2' }, pairedItem: { item: 1 } },
|
||||
],
|
||||
]);
|
||||
expect(mockImageExecute).toHaveBeenCalledTimes(2);
|
||||
expect(mockImageExecute).toHaveBeenNthCalledWith(1, 0);
|
||||
expect(mockImageExecute).toHaveBeenNthCalledWith(2, 1);
|
||||
});
|
||||
|
||||
it('should throw error for unsupported resource', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
if (parameter === 'resource') return 'unsupported';
|
||||
if (parameter === 'operation') return 'test';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockNode = { name: 'Ollama', type: 'n8n-nodes-langchain.ollama' } as INode;
|
||||
executeFunctionsMock.getNode.mockReturnValue(mockNode);
|
||||
|
||||
await expect(router.call(executeFunctionsMock)).rejects.toThrow(NodeOperationError);
|
||||
await expect(router.call(executeFunctionsMock)).rejects.toThrow(
|
||||
'The resource "unsupported" is not supported!',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle execution errors with continueOnFail enabled', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
if (parameter === 'resource') return 'text';
|
||||
if (parameter === 'operation') return 'message';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
executeFunctionsMock.continueOnFail.mockReturnValue(true);
|
||||
mockTextExecute.mockResolvedValueOnce([
|
||||
{ json: { result: 'success' }, pairedItem: { item: 0 } },
|
||||
]);
|
||||
mockTextExecute.mockRejectedValueOnce(new Error('API Error'));
|
||||
|
||||
const result = await router.call(executeFunctionsMock);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ json: { result: 'success' }, pairedItem: { item: 0 } },
|
||||
{ json: { error: 'API Error' }, pairedItem: { item: 1 } },
|
||||
],
|
||||
]);
|
||||
expect(mockTextExecute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when continueOnFail is disabled', async () => {
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
if (parameter === 'resource') return 'text';
|
||||
if (parameter === 'operation') return 'message';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
executeFunctionsMock.continueOnFail.mockReturnValue(false);
|
||||
const mockNode = { name: 'Ollama', type: 'n8n-nodes-langchain.ollama' } as INode;
|
||||
executeFunctionsMock.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const originalError = new Error('API Connection Failed');
|
||||
mockTextExecute.mockRejectedValueOnce(originalError);
|
||||
|
||||
await expect(router.call(executeFunctionsMock)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should process multiple items and accumulate results', async () => {
|
||||
executeFunctionsMock.getInputData.mockReturnValue([
|
||||
{ json: { input: 'test1' } },
|
||||
{ json: { input: 'test2' } },
|
||||
{ json: { input: 'test3' } },
|
||||
]);
|
||||
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
if (parameter === 'resource') return 'text';
|
||||
if (parameter === 'operation') return 'message';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockTextExecute.mockResolvedValueOnce([
|
||||
{ json: { result: 'response1' }, pairedItem: { item: 0 } },
|
||||
]);
|
||||
mockTextExecute.mockResolvedValueOnce([
|
||||
{ json: { result: 'response2a' }, pairedItem: { item: 1 } },
|
||||
{ json: { result: 'response2b' }, pairedItem: { item: 1 } },
|
||||
]);
|
||||
mockTextExecute.mockResolvedValueOnce([
|
||||
{ json: { result: 'response3' }, pairedItem: { item: 2 } },
|
||||
]);
|
||||
|
||||
const result = await router.call(executeFunctionsMock);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ json: { result: 'response1' }, pairedItem: { item: 0 } },
|
||||
{ json: { result: 'response2a' }, pairedItem: { item: 1 } },
|
||||
{ json: { result: 'response2b' }, pairedItem: { item: 1 } },
|
||||
{ json: { result: 'response3' }, pairedItem: { item: 2 } },
|
||||
],
|
||||
]);
|
||||
expect(mockTextExecute).toHaveBeenCalledTimes(3);
|
||||
expect(mockTextExecute).toHaveBeenNthCalledWith(1, 0);
|
||||
expect(mockTextExecute).toHaveBeenNthCalledWith(2, 1);
|
||||
expect(mockTextExecute).toHaveBeenNthCalledWith(3, 2);
|
||||
});
|
||||
|
||||
it('should handle empty input data', async () => {
|
||||
executeFunctionsMock.getInputData.mockReturnValue([]);
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
if (parameter === 'resource') return 'text';
|
||||
if (parameter === 'operation') return 'message';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await router.call(executeFunctionsMock);
|
||||
|
||||
expect(result).toEqual([[]]);
|
||||
expect(mockTextExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle mixed success and failure with continueOnFail', async () => {
|
||||
executeFunctionsMock.getInputData.mockReturnValue([
|
||||
{ json: { input: 'test1' } },
|
||||
{ json: { input: 'test2' } },
|
||||
{ json: { input: 'test3' } },
|
||||
]);
|
||||
|
||||
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
|
||||
if (parameter === 'resource') return 'text';
|
||||
if (parameter === 'operation') return 'message';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
executeFunctionsMock.continueOnFail.mockReturnValue(true);
|
||||
mockTextExecute.mockResolvedValueOnce([
|
||||
{ json: { result: 'success1' }, pairedItem: { item: 0 } },
|
||||
]);
|
||||
mockTextExecute.mockRejectedValueOnce(new Error('Error in item 2'));
|
||||
mockTextExecute.mockResolvedValueOnce([
|
||||
{ json: { result: 'success3' }, pairedItem: { item: 2 } },
|
||||
]);
|
||||
|
||||
const result = await router.call(executeFunctionsMock);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ json: { result: 'success1' }, pairedItem: { item: 0 } },
|
||||
{ json: { error: 'Error in item 2' }, pairedItem: { item: 1 } },
|
||||
{ json: { result: 'success3' }, pairedItem: { item: 2 } },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NodeOperationError, type IExecuteFunctions, type INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import * as image from './image';
|
||||
import type { OllamaType } from './node.type';
|
||||
import * as text from './text';
|
||||
|
||||
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 ollamaTypeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as OllamaType;
|
||||
|
||||
let execute;
|
||||
switch (ollamaTypeData.resource) {
|
||||
case 'image':
|
||||
execute = image[ollamaTypeData.operation].execute;
|
||||
break;
|
||||
case 'text':
|
||||
execute = text[ollamaTypeData.operation].execute;
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(this.getNode(), `The resource "${resource}" is not supported!`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const responseData = await execute.call(this, i);
|
||||
returnData.push.apply(returnData, 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: 'Send a message to Ollama model',
|
||||
},
|
||||
],
|
||||
default: 'message',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['text'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...message.description,
|
||||
];
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { getConnectedTools } from '@utils/helpers';
|
||||
|
||||
import type { OllamaChatResponse, OllamaMessage, OllamaTool } from '../../helpers';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { modelRLC } from '../descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
modelRLC,
|
||||
{
|
||||
displayName: 'Messages',
|
||||
name: 'messages',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
sortable: true,
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Message',
|
||||
default: { values: [{ content: '', role: 'user' }] },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Content',
|
||||
name: 'content',
|
||||
type: 'string',
|
||||
description: 'The content of the message to be sent',
|
||||
default: '',
|
||||
placeholder: 'e.g. Hello, how can you help me?',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Role',
|
||||
name: 'role',
|
||||
type: 'options',
|
||||
description: 'The role of this message in the conversation',
|
||||
options: [
|
||||
{
|
||||
name: 'User',
|
||||
value: 'user',
|
||||
description: 'Message from the user',
|
||||
},
|
||||
{
|
||||
name: 'Assistant',
|
||||
value: 'assistant',
|
||||
description: 'Response from the assistant (for conversation history)',
|
||||
},
|
||||
],
|
||||
default: 'user',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: 'System Message',
|
||||
name: 'system',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. You are a helpful assistant.',
|
||||
description: 'System message to set the context for the conversation',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Temperature',
|
||||
name: 'temperature',
|
||||
type: 'number',
|
||||
default: 0.8,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 2,
|
||||
numberPrecision: 2,
|
||||
},
|
||||
description: 'Controls randomness in responses. Lower values make output more focused.',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Randomness (Top P)',
|
||||
name: 'top_p',
|
||||
default: 0.7,
|
||||
description: 'The maximum cumulative probability of tokens to consider when sampling',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
numberPrecision: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Top K',
|
||||
name: 'top_k',
|
||||
type: 'number',
|
||||
default: 40,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
description: 'Controls diversity by limiting the number of top tokens to consider',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Tokens',
|
||||
name: 'num_predict',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description: 'Maximum number of tokens to generate in the completion',
|
||||
},
|
||||
{
|
||||
displayName: 'Frequency Penalty',
|
||||
name: 'frequency_penalty',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 2,
|
||||
},
|
||||
description:
|
||||
'Adjusts the penalty for tokens that have already appeared in the generated text. Higher values discourage repetition.',
|
||||
},
|
||||
{
|
||||
displayName: 'Presence Penalty',
|
||||
name: 'presence_penalty',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
typeOptions: {
|
||||
numberPrecision: 2,
|
||||
},
|
||||
description:
|
||||
'Adjusts the penalty for tokens based on their presence in the generated text so far. Positive values penalize tokens that have already appeared, encouraging diversity.',
|
||||
},
|
||||
{
|
||||
displayName: 'Repetition Penalty',
|
||||
name: 'repeat_penalty',
|
||||
type: 'number',
|
||||
default: 1.1,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 2,
|
||||
},
|
||||
description:
|
||||
'Sets how strongly to penalize repetitions. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient.',
|
||||
},
|
||||
{
|
||||
displayName: 'Context Length',
|
||||
name: 'num_ctx',
|
||||
type: 'number',
|
||||
default: 4096,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description: 'Sets the size of the context window used to generate the next token',
|
||||
},
|
||||
{
|
||||
displayName: 'Repeat Last N',
|
||||
name: 'repeat_last_n',
|
||||
type: 'number',
|
||||
default: 64,
|
||||
typeOptions: {
|
||||
minValue: -1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Sets how far back for the model to look back to prevent repetition. (0 = disabled, -1 = num_ctx).',
|
||||
},
|
||||
{
|
||||
displayName: 'Min P',
|
||||
name: 'min_p',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
numberPrecision: 3,
|
||||
},
|
||||
description:
|
||||
'Alternative to the top_p, and aims to ensure a balance of quality and variety. The parameter p represents the minimum probability for a token to be considered, relative to the probability of the most likely token.',
|
||||
},
|
||||
{
|
||||
displayName: 'Seed',
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt.',
|
||||
},
|
||||
{
|
||||
displayName: 'Stop Sequences',
|
||||
name: 'stop',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Sets the stop sequences to use. When this pattern is encountered the LLM will stop generating text and return. Separate multiple patterns with commas',
|
||||
},
|
||||
{
|
||||
displayName: 'Keep Alive',
|
||||
name: 'keep_alive',
|
||||
type: 'string',
|
||||
default: '5m',
|
||||
description:
|
||||
'Specifies the duration to keep the loaded model in memory after use. Format: 1h30m (1 hour 30 minutes).',
|
||||
},
|
||||
{
|
||||
displayName: 'Low VRAM Mode',
|
||||
name: 'low_vram',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to activate low VRAM mode, which reduces memory usage at the cost of slower generation speed. Useful for GPUs with limited memory.',
|
||||
},
|
||||
{
|
||||
displayName: 'Main GPU ID',
|
||||
name: 'main_gpu',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Specifies the ID of the GPU to use for the main computation. Only change this if you have multiple GPUs.',
|
||||
},
|
||||
{
|
||||
displayName: 'Context Batch Size',
|
||||
name: 'num_batch',
|
||||
type: 'number',
|
||||
default: 512,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Sets the batch size for prompt processing. Larger batch sizes may improve generation speed but increase memory usage.',
|
||||
},
|
||||
{
|
||||
displayName: 'Number of GPUs',
|
||||
name: 'num_gpu',
|
||||
type: 'number',
|
||||
default: -1,
|
||||
typeOptions: {
|
||||
minValue: -1,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Specifies the number of GPUs to use for parallel processing. Set to -1 for auto-detection.',
|
||||
},
|
||||
{
|
||||
displayName: 'Number of CPU Threads',
|
||||
name: 'num_thread',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 0,
|
||||
},
|
||||
description:
|
||||
'Specifies the number of CPU threads to use for processing. Set to 0 for auto-detection.',
|
||||
},
|
||||
{
|
||||
displayName: 'Penalize Newlines',
|
||||
name: 'penalize_newline',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether the model will be less likely to generate newline characters, encouraging longer continuous sequences of text',
|
||||
},
|
||||
{
|
||||
displayName: 'Use Memory Locking',
|
||||
name: 'use_mlock',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to lock the model in memory to prevent swapping. This can improve performance but requires sufficient available memory.',
|
||||
},
|
||||
{
|
||||
displayName: 'Use Memory Mapping',
|
||||
name: 'use_mmap',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to use memory mapping for loading the model. This can reduce memory usage but may impact performance.',
|
||||
},
|
||||
{
|
||||
displayName: 'Load Vocabulary Only',
|
||||
name: 'vocab_only',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to only load the model vocabulary without the weights. Useful for quickly testing tokenization.',
|
||||
},
|
||||
{
|
||||
displayName: 'Output Format',
|
||||
name: 'format',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Default', value: '' },
|
||||
{ name: 'JSON', value: 'json' },
|
||||
],
|
||||
default: '',
|
||||
description: 'Specifies the format of the API response',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface MessageOptions {
|
||||
system?: string;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
num_predict?: number;
|
||||
frequency_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
repeat_penalty?: number;
|
||||
num_ctx?: number;
|
||||
repeat_last_n?: number;
|
||||
min_p?: number;
|
||||
seed?: number;
|
||||
stop?: string | string[];
|
||||
low_vram?: boolean;
|
||||
main_gpu?: number;
|
||||
num_batch?: number;
|
||||
num_gpu?: number;
|
||||
num_thread?: number;
|
||||
penalize_newline?: boolean;
|
||||
use_mlock?: boolean;
|
||||
use_mmap?: boolean;
|
||||
vocab_only?: boolean;
|
||||
format?: string;
|
||||
keep_alive?: string;
|
||||
}
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['message'],
|
||||
resource: ['text'],
|
||||
},
|
||||
};
|
||||
|
||||
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 messages = this.getNodeParameter('messages.values', i, []) as OllamaMessage[];
|
||||
const simplify = this.getNodeParameter('simplify', i, true) as boolean;
|
||||
const options = this.getNodeParameter('options', i, {}) as MessageOptions;
|
||||
const { tools, connectedTools } = await getTools.call(this);
|
||||
|
||||
if (options.system) {
|
||||
messages.unshift({
|
||||
role: 'system',
|
||||
content: options.system,
|
||||
});
|
||||
}
|
||||
|
||||
delete options.system;
|
||||
|
||||
const processedOptions = { ...options };
|
||||
if (processedOptions.stop && typeof processedOptions.stop === 'string') {
|
||||
processedOptions.stop = processedOptions.stop
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const body = {
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
tools,
|
||||
options: processedOptions,
|
||||
};
|
||||
|
||||
let response: OllamaChatResponse = await apiRequest.call(this, 'POST', '/api/chat', {
|
||||
body,
|
||||
});
|
||||
|
||||
if (tools.length > 0 && response.message.tool_calls && response.message.tool_calls.length > 0) {
|
||||
const toolCalls = response.message.tool_calls;
|
||||
|
||||
messages.push(response.message);
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
let toolResponse = '';
|
||||
let toolFound = false;
|
||||
|
||||
for (const tool of connectedTools) {
|
||||
if (tool.name === toolCall.function.name) {
|
||||
toolFound = true;
|
||||
try {
|
||||
const result: unknown = await tool.invoke(toolCall.function.arguments);
|
||||
toolResponse =
|
||||
typeof result === 'object' && result !== null
|
||||
? JSON.stringify(result)
|
||||
: String(result);
|
||||
} catch (error) {
|
||||
toolResponse = `Error executing tool: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool response even if tool wasn't found to prevent silent failure
|
||||
if (!toolFound) {
|
||||
toolResponse = `Error: Tool '${toolCall.function.name}' not found`;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: toolResponse,
|
||||
tool_name: toolCall.function.name,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedBody = {
|
||||
...body,
|
||||
messages,
|
||||
};
|
||||
|
||||
response = await apiRequest.call(this, 'POST', '/api/chat', {
|
||||
body: updatedBody,
|
||||
});
|
||||
}
|
||||
|
||||
if (simplify) {
|
||||
return [
|
||||
{
|
||||
json: { content: response.message.content },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: { ...response },
|
||||
pairedItem: { item: i },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function getTools(this: IExecuteFunctions) {
|
||||
let connectedTools: Tool[] = [];
|
||||
const nodeInputs = this.getNodeInputs();
|
||||
|
||||
if (nodeInputs.some((input) => input.type === 'ai_tool')) {
|
||||
connectedTools = await getConnectedTools(this, true);
|
||||
}
|
||||
|
||||
const tools: OllamaTool[] = connectedTools.map((tool) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: zodToJsonSchema(tool.schema),
|
||||
},
|
||||
}));
|
||||
|
||||
return { tools, connectedTools };
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import * as image from './image';
|
||||
import * as text from './text';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Ollama',
|
||||
name: 'ollama',
|
||||
icon: 'file:ollama.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
|
||||
description: 'Interact with Ollama AI models',
|
||||
defaults: {
|
||||
name: 'Ollama',
|
||||
},
|
||||
usableAsTool: true,
|
||||
codex: {
|
||||
alias: ['LangChain', 'image', 'vision', 'AI', 'local'],
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Agents', 'Miscellaneous', 'Root Nodes'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.ollama/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
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: 'ollamaApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Image',
|
||||
value: 'image',
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
},
|
||||
],
|
||||
default: 'text',
|
||||
},
|
||||
...image.description,
|
||||
...text.description,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type * from './interfaces';
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface OllamaMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
images?: string[];
|
||||
tool_calls?: ToolCall[];
|
||||
tool_name?: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
function: {
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OllamaTool {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OllamaChatResponse {
|
||||
model: string;
|
||||
created_at: string;
|
||||
message: OllamaMessage;
|
||||
done: boolean;
|
||||
done_reason?: string;
|
||||
total_duration?: number;
|
||||
load_duration?: number;
|
||||
prompt_eval_count?: number;
|
||||
prompt_eval_duration?: number;
|
||||
eval_count?: number;
|
||||
eval_duration?: number;
|
||||
}
|
||||
|
||||
export interface OllamaModel {
|
||||
name: string;
|
||||
modified_at: string;
|
||||
size: number;
|
||||
digest: string;
|
||||
details: {
|
||||
format: string;
|
||||
family: string;
|
||||
families: string[] | null;
|
||||
parameter_size: string;
|
||||
quantization_level: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OllamaTagsResponse {
|
||||
models: OllamaModel[];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as listSearch from './listSearch';
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
|
||||
import * as transport from '../transport';
|
||||
import { modelSearch } from './listSearch';
|
||||
|
||||
describe('Ollama List Search Methods', () => {
|
||||
const loadOptionsFunctionsMock = mockDeep<ILoadOptionsFunctions>();
|
||||
const apiRequestMock = jest.spyOn(transport, 'apiRequest');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('modelSearch', () => {
|
||||
it('should return all models when no filter is provided', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'llama3.2:latest' },
|
||||
{ name: 'llama3.2:3b' },
|
||||
{ name: 'codellama:latest' },
|
||||
{ name: 'mistral:7b' },
|
||||
{ name: 'phi3:latest' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(loadOptionsFunctionsMock);
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'llama3.2:latest', value: 'llama3.2:latest' },
|
||||
{ name: 'llama3.2:3b', value: 'llama3.2:3b' },
|
||||
{ name: 'codellama:latest', value: 'codellama:latest' },
|
||||
{ name: 'mistral:7b', value: 'mistral:7b' },
|
||||
{ name: 'phi3:latest', value: 'phi3:latest' },
|
||||
],
|
||||
});
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('GET', '/api/tags');
|
||||
});
|
||||
|
||||
it('should filter models by name (case insensitive)', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'llama3.2:latest' },
|
||||
{ name: 'llama3.2:3b' },
|
||||
{ name: 'codellama:latest' },
|
||||
{ name: 'mistral:7b' },
|
||||
{ name: 'phi3:latest' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(loadOptionsFunctionsMock, 'llama');
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'llama3.2:latest', value: 'llama3.2:latest' },
|
||||
{ name: 'llama3.2:3b', value: 'llama3.2:3b' },
|
||||
{ name: 'codellama:latest', value: 'codellama:latest' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle case insensitive filtering', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [{ name: 'Llama3.2:latest' }, { name: 'CODELLAMA:latest' }, { name: 'mistral:7b' }],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(loadOptionsFunctionsMock, 'LLAMA');
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'Llama3.2:latest', value: 'Llama3.2:latest' },
|
||||
{ name: 'CODELLAMA:latest', value: 'CODELLAMA:latest' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty results when filter matches no models', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [{ name: 'llama3.2:latest' }, { name: 'mistral:7b' }],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(loadOptionsFunctionsMock, 'gpt');
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty model list', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(loadOptionsFunctionsMock);
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial string matching', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'llama3.2:3b-instruct' },
|
||||
{ name: 'llama3.2:7b' },
|
||||
{ name: 'mistral:3b-instruct' },
|
||||
{ name: 'phi3:3b' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(loadOptionsFunctionsMock, '3b');
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'llama3.2:3b-instruct', value: 'llama3.2:3b-instruct' },
|
||||
{ name: 'mistral:3b-instruct', value: 'mistral:3b-instruct' },
|
||||
{ name: 'phi3:3b', value: 'phi3:3b' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by tag', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'llama3.2:latest' },
|
||||
{ name: 'llama3.2:3b' },
|
||||
{ name: 'mistral:latest' },
|
||||
{ name: 'codellama:7b' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(loadOptionsFunctionsMock, 'latest');
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'llama3.2:latest', value: 'llama3.2:latest' },
|
||||
{ name: 'mistral:latest', value: 'mistral:latest' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle special characters in filter', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [
|
||||
{ name: 'llama3.2:latest' },
|
||||
{ name: 'model-with-dash:1.0' },
|
||||
{ name: 'model_with_underscore:2.0' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(loadOptionsFunctionsMock, 'with-dash');
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [{ name: 'model-with-dash:1.0', value: 'model-with-dash:1.0' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined filter as no filter', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [{ name: 'llama3.2:latest' }, { name: 'mistral:7b' }],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(loadOptionsFunctionsMock, undefined);
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'llama3.2:latest', value: 'llama3.2:latest' },
|
||||
{ name: 'mistral:7b', value: 'mistral:7b' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty string filter as no filter', async () => {
|
||||
apiRequestMock.mockResolvedValue({
|
||||
models: [{ name: 'llama3.2:latest' }, { name: 'mistral:7b' }],
|
||||
});
|
||||
|
||||
const result = await modelSearch.call(loadOptionsFunctionsMock, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'llama3.2:latest', value: 'llama3.2:latest' },
|
||||
{ name: 'mistral:7b', value: 'mistral:7b' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
||||
|
||||
import type { OllamaTagsResponse } from '../helpers/interfaces';
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
export async function modelSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const response: OllamaTagsResponse = await apiRequest.call(this, 'GET', '/api/tags');
|
||||
|
||||
let models = response.models;
|
||||
|
||||
if (filter) {
|
||||
models = models.filter((model) => model.name.toLowerCase().includes(filter.toLowerCase()));
|
||||
}
|
||||
|
||||
return {
|
||||
results: models.map((model) => ({ name: model.name, value: model.name })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="241.333" height="341.333" version="1.0" viewBox="0 0 181 256"><g fill="#7D7D87"><path d="M37.7 19.5c-5.2 1.8-8.3 4.9-11.7 11.6-4.5 8.9-6.2 19.2-5.8 35.5l.3 14.2-5.8 6.1c-14.8 15.5-18.5 38.7-9.2 57.4l3.4 6.9-2 4.4c-3.4 8.2-5 16.4-5 26.3 0 10.8 1.8 19 5.8 26.2l2.6 4.8-2.1 4.9c-1.2 2.7-2.6 7.1-3.2 9.8-1.4 6.2-1.5 22.1-.1 25.7 1 2.6 1.4 2.7 7.6 2.7 7.3 0 7 .4 5.3-8.6-1.5-8.2.2-18.8 4.2-26.6 3.7-7 3.8-10.4.5-14.8-4.7-6.4-6.8-13.6-6.9-24-.1-10.3 1.4-16 6.6-26.1 3.1-6.1 2.9-8.7-1-12.2-1.1-1-3.1-4.2-4.3-7-1.9-4.2-2.4-6.9-2.3-14.2 0-11.4 2.5-18.3 9.5-26 7-7.6 14.2-11 23.9-11.2 4.1 0 7.8-.2 8.2-.2.4-.1 1.7-2.2 2.9-4.7 3-5.9 9.6-11.9 16.7-15.2 4.9-2.3 7-2.7 14.7-2.7 7.9 0 9.7.4 14.9 2.9 6.8 3.3 13.3 9.4 15.9 14.8 1 2 2.3 4.1 3 4.5.6.4 4.6.8 8.7.8 6.7.1 8.3.5 14 3.6 12.3 6.8 19.3 18.7 19.3 33.4.1 6.7-.4 9-2.7 14.2-1.6 3.5-3.5 6.8-4.3 7.5-3.4 2.8-3.5 5.8-.5 11.7 5.2 10.1 6.7 15.8 6.6 26.1-.1 10.4-2.2 17.6-6.9 24-3.3 4.4-3.2 7.8.5 14.8 4 7.8 5.7 18.4 4.2 26.6-1.7 9-2 8.6 5.3 8.6 6.2 0 6.6-.1 7.6-2.7 1.4-3.6 1.3-19.5-.1-25.7-.6-2.7-2-7.1-3.2-9.8l-2.1-4.9 2.6-4.8c7.6-13.9 7.9-35.9.6-52.8l-2-4.7 2.5-4.6c9.9-18.3 6.4-43.9-8.1-59.1l-5.8-6.1.3-14.2c.4-16.4-1.3-26.6-5.8-35.7-6.4-12.6-17.2-15.9-26.3-7.9-5.4 4.7-9.2 13.8-12.3 29.8-.3 1.4-1 2.2-1.7 1.8-18.2-8-29.7-8.5-44.3-2.1L65 54.9l-.4-2.2C61 34.2 56.1 24.2 49 20.5c-4.3-2.1-7.4-2.4-11.3-1m7.7 16.8c4.2 7.1 8.1 30.1 5.7 33.6-.5.8-3.1 1.6-5.8 1.8-2.6.2-6.2.8-8 1.3l-3.1.8-.7-4.9c-.8-5.9.2-17.2 2.2-24.8C37.1 38.4 40.5 32 42 32c.5 0 2 1.9 3.4 4.3m96.5-1c4 6.5 6.9 23.9 5.6 33.6l-.7 4.9-3.1-.8c-1.8-.5-5.4-1.1-8-1.3-2.7-.2-5.3-1-5.8-1.8-1.2-1.7-.3-14.1 1.7-22.9 1.5-6.4 5.7-15 7.4-15 .4 0 1.8 1.5 2.9 3.3"/><path d="M77.8 119.9c-7.3 2.4-11.6 5.1-16.5 10.4-5.5 6-7.6 12-7.1 20.1.5 7.6 3.5 12.9 10.6 18.3 6.2 4.7 12.7 6.3 25.7 6.3 17.2 0 25.8-3.6 32.9-13.8 4.2-5.9 4.8-15.5 1.6-23-2.9-6.8-11.1-14.3-18.8-17.3-8-3.1-20.7-3.6-28.4-1m25.7 10c16.1 7.1 19.4 23.2 6.6 31.8-4.9 3.3-9.4 4.3-19.6 4.3s-14.7-1-19.6-4.3c-17.8-12-3.2-35.6 21.1-34.3 3.9.2 8.6 1.2 11.5 2.5"/><path d="M83.8 140.1c-2.5 1.4-2.2 4.4.7 6.7 2 1.6 2.4 2.6 1.9 4.9-.7 3.6 1.5 5.8 5.1 4.9 2.1-.5 2.5-1.2 2.5-4.6 0-2.9.5-4.2 2-5 2.7-1.5 2.7-6.6 0-7.5-1-.3-2.8-.1-4 .5-1.4.7-2.6.8-3.9 0-2.3-1.2-2.2-1.2-4.3.1m-44.1-18.9c-.9.7-2.3 3-3.2 5-2.1 5.3-.1 10.3 4.7 11.6 4.3 1.1 6 .6 9.2-2.7 4-4.1 4.3-8.1 1.1-11.9-2.1-2.5-3.4-3.2-6.4-3.2-2 0-4.5.6-5.4 1.2m89.8 2c-3.2 3.8-2.9 7.8 1.1 11.9 3.2 3.3 4.9 3.8 9.2 2.7 4.9-1.3 6.8-6.2 4.6-11.8-1.9-4.7-3.8-6-8.7-6-2.7 0-4.1.7-6.2 3.2"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,243 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from './index';
|
||||
|
||||
describe('Ollama Transport', () => {
|
||||
const executeFunctionsMock = mockDeep<IExecuteFunctions>();
|
||||
const loadOptionsFunctionsMock = mockDeep<ILoadOptionsFunctions>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('apiRequest', () => {
|
||||
it('should make API request with basic auth', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
baseUrl: 'http://localhost:11434',
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({
|
||||
model: 'test-model',
|
||||
response: 'test response',
|
||||
});
|
||||
|
||||
const result = await apiRequest.call(executeFunctionsMock, 'POST', '/api/chat', {
|
||||
body: { model: 'test-model', messages: [] },
|
||||
});
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'ollamaApi',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-api-key',
|
||||
},
|
||||
method: 'POST',
|
||||
body: { model: 'test-model', messages: [] },
|
||||
qs: undefined,
|
||||
url: 'http://localhost:11434/api/chat',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ model: 'test-model', response: 'test response' });
|
||||
});
|
||||
|
||||
it('should make API request without auth when no API key provided', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
baseUrl: 'http://localhost:11434',
|
||||
});
|
||||
executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({
|
||||
model: 'test-model',
|
||||
response: 'test response',
|
||||
});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'GET', '/api/tags');
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'ollamaApi',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'GET',
|
||||
body: undefined,
|
||||
qs: undefined,
|
||||
url: 'http://localhost:11434/api/tags',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle query parameters', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
baseUrl: 'http://localhost:11434',
|
||||
});
|
||||
executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue([]);
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'GET', '/api/tags', {
|
||||
qs: { limit: 10, offset: 0 },
|
||||
});
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'ollamaApi',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'GET',
|
||||
body: undefined,
|
||||
qs: { limit: 10, offset: 0 },
|
||||
url: 'http://localhost:11434/api/tags',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle custom headers', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
baseUrl: 'http://localhost:11434',
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'POST', '/api/generate', {
|
||||
headers: { 'X-Custom-Header': 'custom-value' },
|
||||
body: { model: 'test', prompt: 'hello' },
|
||||
});
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'ollamaApi',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-key',
|
||||
'X-Custom-Header': 'custom-value',
|
||||
},
|
||||
method: 'POST',
|
||||
body: { model: 'test', prompt: 'hello' },
|
||||
qs: undefined,
|
||||
url: 'http://localhost:11434/api/generate',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle additional options', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
baseUrl: 'http://localhost:11434',
|
||||
});
|
||||
executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'POST', '/api/chat', {
|
||||
body: { model: 'test' },
|
||||
option: { timeout: 30000, encoding: 'utf8' },
|
||||
});
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'ollamaApi',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: { model: 'test' },
|
||||
qs: undefined,
|
||||
url: 'http://localhost:11434/api/chat',
|
||||
json: true,
|
||||
timeout: 30000,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should work with ILoadOptionsFunctions', async () => {
|
||||
loadOptionsFunctionsMock.getCredentials.mockResolvedValue({
|
||||
baseUrl: 'http://localhost:11434',
|
||||
});
|
||||
loadOptionsFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({
|
||||
models: [{ name: 'llama3.2:latest' }],
|
||||
});
|
||||
|
||||
const result = await apiRequest.call(loadOptionsFunctionsMock, 'GET', '/api/tags');
|
||||
|
||||
expect(loadOptionsFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'ollamaApi',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'GET',
|
||||
body: undefined,
|
||||
qs: undefined,
|
||||
url: 'http://localhost:11434/api/tags',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ models: [{ name: 'llama3.2:latest' }] });
|
||||
});
|
||||
|
||||
it('should handle baseUrl with trailing slash', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
baseUrl: 'http://localhost:11434/',
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'GET', '/api/tags');
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'ollamaApi',
|
||||
expect.objectContaining({
|
||||
url: 'http://localhost:11434/api/tags',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty parameters object', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
baseUrl: 'http://localhost:11434',
|
||||
});
|
||||
executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'GET', '/api/tags', {});
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'ollamaApi',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'GET',
|
||||
body: undefined,
|
||||
qs: undefined,
|
||||
url: 'http://localhost:11434/api/tags',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined parameters', async () => {
|
||||
executeFunctionsMock.getCredentials.mockResolvedValue({
|
||||
baseUrl: 'http://localhost:11434',
|
||||
});
|
||||
executeFunctionsMock.helpers.httpRequestWithAuthentication.mockResolvedValue({});
|
||||
|
||||
await apiRequest.call(executeFunctionsMock, 'GET', '/api/tags');
|
||||
|
||||
expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'ollamaApi',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'GET',
|
||||
body: undefined,
|
||||
qs: undefined,
|
||||
url: 'http://localhost:11434/api/tags',
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
ILoadOptionsFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
type RequestParameters = {
|
||||
headers?: IDataObject;
|
||||
body?: IDataObject | string;
|
||||
qs?: IDataObject;
|
||||
option?: IDataObject;
|
||||
};
|
||||
|
||||
export async function apiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
parameters?: RequestParameters,
|
||||
) {
|
||||
const { body, qs, option } = parameters ?? {};
|
||||
|
||||
const credentials = await this.getCredentials<{
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
}>('ollamaApi');
|
||||
const apiKey = credentials.apiKey;
|
||||
if (apiKey !== undefined && typeof apiKey !== 'string') {
|
||||
throw new Error('API key must be a string');
|
||||
}
|
||||
|
||||
const url = new URL(endpoint, credentials.baseUrl).toString();
|
||||
|
||||
const headers = parameters?.headers ?? {};
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const options = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...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, 'ollamaApi', options);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
type IVersionedNodeType,
|
||||
VersionedNodeType,
|
||||
type INodeTypeBaseDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { prettifyOperation } from './helpers/description';
|
||||
import { OpenAiV1 } from './v1/OpenAiV1.node';
|
||||
import { OpenAiV2 } from './v2/OpenAiV2.node';
|
||||
|
||||
export class OpenAi extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'OpenAI',
|
||||
name: 'openAi',
|
||||
icon: { light: 'file:openAi.svg', dark: 'file:openAi.dark.svg' },
|
||||
group: ['transform'],
|
||||
defaultVersion: 2.1,
|
||||
subtitle: `={{(${prettifyOperation})($parameter.resource, $parameter.operation)}}`,
|
||||
description: 'Message an assistant or GPT, analyze images, generate audio, etc.',
|
||||
codex: {
|
||||
alias: [
|
||||
'LangChain',
|
||||
'ChatGPT',
|
||||
'Sora',
|
||||
'DallE',
|
||||
'whisper',
|
||||
'audio',
|
||||
'transcribe',
|
||||
'tts',
|
||||
'assistant',
|
||||
],
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Agents', 'Miscellaneous', 'Root Nodes'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-langchain.openai/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
builderHint: {
|
||||
message:
|
||||
'For text generation, reasoning and tools, use AI Agent with OpenAI Chat Model. This OpenAI node is for specialized operations: image generation (DALL-E), audio (Whisper, TTS), and video generation (Sora).',
|
||||
relatedNodes: [
|
||||
{
|
||||
nodeType: '@n8n/n8n-nodes-langchain.agent',
|
||||
relationHint: 'Prefer for most LLM tasks',
|
||||
},
|
||||
{
|
||||
nodeType: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
relationHint: 'Prefer for most LLM tasks',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new OpenAiV1(baseDescription),
|
||||
1.1: new OpenAiV1(baseDescription),
|
||||
1.2: new OpenAiV1(baseDescription),
|
||||
1.3: new OpenAiV1(baseDescription),
|
||||
1.4: new OpenAiV1(baseDescription),
|
||||
1.5: new OpenAiV1(baseDescription),
|
||||
1.6: new OpenAiV1(baseDescription),
|
||||
1.7: new OpenAiV1(baseDescription),
|
||||
1.8: new OpenAiV1(baseDescription),
|
||||
2: new OpenAiV2(baseDescription),
|
||||
2.1: new OpenAiV2(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
import { shouldIncludeModel } from '../modelFiltering';
|
||||
|
||||
describe('shouldIncludeModel', () => {
|
||||
const testCases: Array<{ modelId: string; officialAPI: boolean }> = [
|
||||
// Excluded model types
|
||||
{ modelId: 'babbage-002', officialAPI: false },
|
||||
{ modelId: 'davinci-002', officialAPI: false },
|
||||
{ modelId: 'computer-use-preview', officialAPI: false },
|
||||
{ modelId: 'dall-e-3', officialAPI: false },
|
||||
{ modelId: 'text-embedding-ada-002', officialAPI: false },
|
||||
{ modelId: 'tts-1', officialAPI: false },
|
||||
{ modelId: 'whisper-1', officialAPI: false },
|
||||
{ modelId: 'omni-moderation-latest', officialAPI: false },
|
||||
{ modelId: 'sora-1', officialAPI: false },
|
||||
{ modelId: 'gpt-4o-realtime-preview', officialAPI: false }, // infix check for -realtime
|
||||
{ modelId: 'gpt-3.5-turbo-instruct', officialAPI: false }, // gpt-* with instruct
|
||||
|
||||
// Included models (standard chat models)
|
||||
{ modelId: 'gpt-4', officialAPI: true },
|
||||
{ modelId: 'gpt-4o', officialAPI: true },
|
||||
{ modelId: 'o1-preview', officialAPI: true },
|
||||
{ modelId: 'ft:gpt-3.5-turbo', officialAPI: true }, // fine-tuned models
|
||||
|
||||
// Edge cases
|
||||
{ modelId: 'llama-3-70b-instruct', officialAPI: true }, // non-gpt instruct is allowed
|
||||
{ modelId: 'custom-model', officialAPI: true }, // arbitrary custom model names
|
||||
];
|
||||
|
||||
describe('Custom API behavior', () => {
|
||||
it.each(testCases)('should include "$modelId"', ({ modelId }) => {
|
||||
expect(shouldIncludeModel(modelId, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Official OpenAI API filtering', () => {
|
||||
const testCasesWithAction = testCases.map((tc) => ({
|
||||
...tc,
|
||||
action: tc.officialAPI ? 'include' : 'exclude',
|
||||
}));
|
||||
|
||||
it.each(testCasesWithAction)('should $action "$modelId"', ({ modelId, officialAPI }) => {
|
||||
expect(shouldIncludeModel(modelId, false)).toBe(officialAPI);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user