feat(Anthropic Node): New node (#17121)

Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
RomanDavydchuk
2025-07-21 11:40:11 +03:00
committed by GitHub
parent 115934573f
commit 5502361a97
33 changed files with 3706 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}

View File

@@ -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-3-5-sonnet-20241022',
},
],
};

View File

@@ -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');
}

View File

@@ -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,
];

View File

@@ -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 },
},
];
}

View File

@@ -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 },
},
];
}

View File

@@ -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,
];

View File

@@ -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 },
}));
}

View File

@@ -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,
},
},
];
}

View File

@@ -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');
}

View File

@@ -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,
];

View File

@@ -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>;

View File

@@ -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 },
},
];
}

View File

@@ -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 },
},
];
}

View File

@@ -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,
];

View File

@@ -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 },
},
];
}

View File

@@ -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');
});
});

View File

@@ -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];
}

View File

@@ -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,
];

View File

@@ -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);
}

View File

@@ -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'],
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,
],
};

View File

@@ -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

View File

@@ -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 },
},
];
}

View File

@@ -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;
}

View File

@@ -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');
});
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export * as listSearch from './listSearch';

View File

@@ -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',
},
],
});
});
});
});

View File

@@ -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,
})),
};
}

View File

@@ -0,0 +1,166 @@
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',
},
},
);
});
});

View File

@@ -0,0 +1,59 @@
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 options = {
headers: {
'anthropic-version': '2023-06-01',
'anthropic-beta': betas.join(','),
...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, 'anthropicApi', options);
}

View File

@@ -48,6 +48,7 @@
"dist/credentials/ZepApi.credentials.js"
],
"nodes": [
"dist/nodes/vendors/Anthropic/Anthropic.node.js",
"dist/nodes/vendors/GoogleGemini/GoogleGemini.node.js",
"dist/nodes/vendors/OpenAi/OpenAi.node.js",
"dist/nodes/agents/Agent/Agent.node.js",