diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/Anthropic.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/Anthropic.node.test.ts new file mode 100644 index 0000000000..64117e616f --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/Anthropic.node.test.ts @@ -0,0 +1,1008 @@ +import { mockDeep } from 'jest-mock-extended'; +import type { IExecuteFunctions, IBinaryData } from 'n8n-workflow'; + +import * as helpers from '@utils/helpers'; + +import * as file from './actions/file'; +import * as image from './actions/image'; +import * as prompt from './actions/prompt'; +import * as text from './actions/text'; +import * as utils from './helpers/utils'; +import * as transport from './transport'; +import type { File } from './helpers/interfaces'; + +describe('Anthropic Node', () => { + const executeFunctionsMock = mockDeep(); + const apiRequestMock = jest.spyOn(transport, 'apiRequest'); + const getConnectedToolsMock = jest.spyOn(helpers, 'getConnectedTools'); + const downloadFileMock = jest.spyOn(utils, 'downloadFile'); + const uploadFileMock = jest.spyOn(utils, 'uploadFile'); + const getBaseUrlMock = jest.spyOn(utils, 'getBaseUrl'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('Text -> Message', () => { + it('should call the api with the correct parameters', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'claude-sonnet-4-20250514'; + case 'messages.values': + return [{ role: 'user', content: 'Hello, world!' }]; + case 'simplify': + return true; + case 'addAttachments': + return false; + case 'options': + return { + system: 'You are a helpful assistant.', + codeExecution: true, + webSearch: true, + allowedDomains: 'https://example.com', + maxTokens: 1024, + temperature: 0.7, + topP: 0.9, + topK: 40, + }; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }, { type: 'ai_tool' }]); + getConnectedToolsMock.mockResolvedValue([]); + apiRequestMock.mockResolvedValue({ + content: [{ type: 'text', text: 'Hello! How can I help you today?' }], + stop_reason: 'end_turn', + }); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: [ + { + type: 'text', + text: 'Hello! How can I help you today?', + }, + ], + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/v1/messages', { + body: { + model: 'claude-sonnet-4-20250514', + max_tokens: 1024, + temperature: 0.7, + top_p: 0.9, + top_k: 40, + system: 'You are a helpful assistant.', + messages: [ + { + role: 'user', + content: 'Hello, world!', + }, + ], + tools: [ + { + type: 'code_execution_20250522', + name: 'code_execution', + }, + { + type: 'web_search_20250305', + name: 'web_search', + allowed_domains: ['https://example.com'], + }, + ], + }, + enableAnthropicBetas: { + codeExecution: true, + }, + }); + }); + + it('should add code execution attachments', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'claude-sonnet-4-20250514'; + case 'messages.values': + return [{ role: 'user', content: 'Hello, world!' }]; + case 'simplify': + return true; + case 'addAttachments': + return true; + case 'attachmentsInputType': + return 'url'; + case 'attachmentsUrls': + return 'https://example.com/file.pdf'; + case 'options': + return { + codeExecution: true, + }; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }, { type: 'ai_tool' }]); + getBaseUrlMock.mockResolvedValue('https://api.anthropic.com'); + downloadFileMock.mockResolvedValue({ + fileContent: Buffer.from('abcdefgh'), + mimeType: 'application/pdf', + }); + uploadFileMock.mockResolvedValue({ + id: 'file_123', + } as File); + getConnectedToolsMock.mockResolvedValue([]); + apiRequestMock.mockResolvedValue({ + content: [{ type: 'text', text: 'Hello! How can I help you today?' }], + stop_reason: 'end_turn', + }); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: [ + { + type: 'text', + text: 'Hello! How can I help you today?', + }, + ], + }, + pairedItem: { item: 0 }, + }, + ]); + expect(downloadFileMock).toHaveBeenCalledWith('https://example.com/file.pdf'); + expect(uploadFileMock).toHaveBeenCalledWith(Buffer.from('abcdefgh'), 'application/pdf'); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/v1/messages', { + body: { + model: 'claude-sonnet-4-20250514', + max_tokens: 1024, + messages: [ + { + role: 'user', + content: 'Hello, world!', + }, + { + role: 'user', + content: [ + { + type: 'container_upload', + file_id: 'file_123', + }, + ], + }, + ], + tools: [ + { + type: 'code_execution_20250522', + name: 'code_execution', + }, + ], + }, + enableAnthropicBetas: { + codeExecution: true, + }, + }); + }); + + it('should add regular attachments', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'claude-sonnet-4-20250514'; + case 'messages.values': + return [{ role: 'user', content: 'Hello, world!' }]; + case 'simplify': + return true; + case 'addAttachments': + return true; + case 'attachmentsInputType': + return 'url'; + case 'attachmentsUrls': + return 'https://example.com/file.pdf'; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }, { type: 'ai_tool' }]); + getBaseUrlMock.mockResolvedValue('https://api.anthropic.com'); + executeFunctionsMock.helpers.httpRequest.mockResolvedValue({ + headers: { + 'content-type': 'application/pdf', + }, + }); + getConnectedToolsMock.mockResolvedValue([]); + apiRequestMock.mockResolvedValue({ + content: [{ type: 'text', text: 'Hello! How can I help you today?' }], + stop_reason: 'end_turn', + }); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: [ + { + type: 'text', + text: 'Hello! How can I help you today?', + }, + ], + }, + pairedItem: { item: 0 }, + }, + ]); + expect(executeFunctionsMock.helpers.httpRequest).toHaveBeenCalledWith({ + method: 'HEAD', + url: 'https://example.com/file.pdf', + returnFullResponse: true, + }); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/v1/messages', { + body: { + model: 'claude-sonnet-4-20250514', + max_tokens: 1024, + messages: [ + { + role: 'user', + content: 'Hello, world!', + }, + { + role: 'user', + content: [ + { + type: 'document', + source: { + type: 'url', + url: 'https://example.com/file.pdf', + }, + }, + ], + }, + ], + tools: [], + }, + enableAnthropicBetas: {}, + }); + }); + }); + + describe('File -> Upload', () => { + it('should upload file from URL', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'inputType': + return 'url'; + case 'fileUrl': + return 'https://example.com/file.pdf'; + case 'options.fileName': + return 'test.pdf'; + default: + return undefined; + } + }); + executeFunctionsMock.getCredentials.mockResolvedValue({ + url: 'https://api.anthropic.com', + }); + downloadFileMock.mockResolvedValue({ + fileContent: Buffer.from('test file content'), + mimeType: 'application/pdf', + }); + uploadFileMock.mockResolvedValue({ + created_at: '2025-01-01T10:00:00Z', + downloadable: true, + filename: 'test.pdf', + id: 'file_123', + mime_type: 'application/pdf', + size_bytes: 17, + type: 'file', + }); + + const result = await file.upload.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + created_at: '2025-01-01T10:00:00Z', + downloadable: true, + filename: 'test.pdf', + id: 'file_123', + mime_type: 'application/pdf', + size_bytes: 17, + type: 'file', + url: 'https://api.anthropic.com/v1/files/file_123', + }, + pairedItem: { item: 0 }, + }, + ]); + expect(downloadFileMock).toHaveBeenCalledWith('https://example.com/file.pdf'); + expect(uploadFileMock).toHaveBeenCalledWith( + Buffer.from('test file content'), + 'application/pdf', + 'test.pdf', + ); + }); + + it('should upload file from binary data', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'inputType': + return 'binary'; + case 'binaryPropertyName': + return 'data'; + case 'options.fileName': + return 'file'; + default: + return undefined; + } + }); + executeFunctionsMock.getCredentials.mockResolvedValue({}); + const mockBinaryData: IBinaryData = { + mimeType: 'application/pdf', + fileName: 'test.pdf', + fileSize: '1024', + fileExtension: 'pdf', + data: 'test', + }; + executeFunctionsMock.helpers.assertBinaryData.mockReturnValue(mockBinaryData); + executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue( + Buffer.from('test file content'), + ); + uploadFileMock.mockResolvedValue({ + created_at: '2025-01-01T10:00:00Z', + downloadable: true, + filename: 'file', + id: 'file_456', + mime_type: 'application/pdf', + size_bytes: 17, + type: 'file', + }); + + const result = await file.upload.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + created_at: '2025-01-01T10:00:00Z', + downloadable: true, + filename: 'file', + id: 'file_456', + mime_type: 'application/pdf', + size_bytes: 17, + type: 'file', + url: 'https://api.anthropic.com/v1/files/file_456', + }, + pairedItem: { item: 0 }, + }, + ]); + expect(uploadFileMock).toHaveBeenCalledWith( + Buffer.from('test file content'), + 'application/pdf', + 'file', + ); + }); + }); + + describe('File -> List', () => { + it('should list files with a limit', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'returnAll': + return false; + case 'limit': + return 10; + default: + return undefined; + } + }); + getBaseUrlMock.mockResolvedValue('https://api.anthropic.com'); + apiRequestMock.mockResolvedValue({ + data: [ + { + id: 'file_123', + filename: 'test.pdf', + mime_type: 'application/pdf', + }, + { + id: 'file_456', + filename: 'test.png', + mime_type: 'image/png', + }, + ], + first_id: '', + last_id: '', + has_more: false, + }); + + const result = await file.list.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + id: 'file_123', + filename: 'test.pdf', + mime_type: 'application/pdf', + url: 'https://api.anthropic.com/v1/files/file_123', + }, + pairedItem: { item: 0 }, + }, + { + json: { + id: 'file_456', + filename: 'test.png', + mime_type: 'image/png', + url: 'https://api.anthropic.com/v1/files/file_456', + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith('GET', '/v1/files', { + qs: { + limit: 10, + }, + }); + }); + + it('should list all files', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'returnAll': + return true; + default: + return undefined; + } + }); + getBaseUrlMock.mockResolvedValue('https://api.anthropic.com'); + apiRequestMock.mockResolvedValueOnce({ + data: [ + { + id: 'file_001', + filename: 'test-1.pdf', + mime_type: 'application/pdf', + }, + { + id: 'file_002', + filename: 'test-2.pdf', + mime_type: 'application/pdf', + }, + ], + first_id: 'file_001', + last_id: 'file_002', + has_more: true, + }); + apiRequestMock.mockResolvedValueOnce({ + data: [ + { + id: 'file_003', + filename: 'test-3.pdf', + mime_type: 'application/pdf', + }, + ], + first_id: 'file_003', + last_id: 'file_003', + has_more: false, + }); + + const result = await file.list.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + id: 'file_001', + filename: 'test-1.pdf', + mime_type: 'application/pdf', + url: 'https://api.anthropic.com/v1/files/file_001', + }, + pairedItem: { item: 0 }, + }, + { + json: { + id: 'file_002', + filename: 'test-2.pdf', + mime_type: 'application/pdf', + url: 'https://api.anthropic.com/v1/files/file_002', + }, + pairedItem: { item: 0 }, + }, + { + json: { + id: 'file_003', + filename: 'test-3.pdf', + mime_type: 'application/pdf', + url: 'https://api.anthropic.com/v1/files/file_003', + }, + pairedItem: { item: 0 }, + }, + ]); + }); + }); + + describe('File -> Delete', () => { + it('should delete file', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'fileId': + return 'file_123'; + default: + return undefined; + } + }); + apiRequestMock.mockResolvedValue({ + id: 'file_123', + }); + + const result = await file.deleteFile.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([{ json: { id: 'file_123' }, pairedItem: { item: 0 } }]); + expect(apiRequestMock).toHaveBeenCalledWith('DELETE', '/v1/files/file_123'); + }); + }); + + describe('File -> Get', () => { + it('should get file', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'fileId': + return 'file_123'; + default: + return undefined; + } + }); + getBaseUrlMock.mockResolvedValue('https://api.anthropic.com'); + apiRequestMock.mockResolvedValue({ + id: 'file_123', + filename: 'test.pdf', + mime_type: 'application/pdf', + }); + + const result = await file.get.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + id: 'file_123', + filename: 'test.pdf', + mime_type: 'application/pdf', + url: 'https://api.anthropic.com/v1/files/file_123', + }, + pairedItem: { item: 0 }, + }, + ]); + }); + }); + + describe('Image -> Analyze', () => { + it('should analyze image from URL', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'claude-sonnet-4-20250514'; + case 'inputType': + return 'url'; + case 'imageUrls': + return 'https://example.com/image.png'; + case 'text': + return "What's in this image?"; + case 'simplify': + return true; + case 'options': + return { + maxTokens: 1024, + }; + default: + return undefined; + } + }); + executeFunctionsMock.getCredentials.mockResolvedValue({}); + apiRequestMock.mockResolvedValue({ + content: [ + { + type: 'text', + text: 'This image shows a beautiful sunset over a mountain landscape.', + }, + ], + stop_reason: 'end_turn', + }); + + const result = await image.analyze.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: [ + { + type: 'text', + text: 'This image shows a beautiful sunset over a mountain landscape.', + }, + ], + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/v1/messages', { + body: { + model: 'claude-sonnet-4-20250514', + max_tokens: 1024, + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'url', + url: 'https://example.com/image.png', + }, + }, + { + type: 'text', + text: "What's in this image?", + }, + ], + }, + ], + }, + }); + }); + + it('should analyze image from binary data', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'claude-sonnet-4-20250514'; + case 'inputType': + return 'binary'; + case 'binaryPropertyName': + return 'data'; + case 'text': + return "What's in this image?"; + case 'simplify': + return true; + case 'options': + return { + maxTokens: 1024, + }; + default: + return undefined; + } + }); + executeFunctionsMock.getCredentials.mockResolvedValue({}); + const mockBinaryData: IBinaryData = { + mimeType: 'image/png', + fileName: 'test.png', + fileSize: '2048', + fileExtension: 'png', + data: 'test', + }; + executeFunctionsMock.helpers.assertBinaryData.mockReturnValue(mockBinaryData); + executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue( + Buffer.from('test image data'), + ); + apiRequestMock.mockResolvedValue({ + content: [ + { + type: 'text', + text: 'This image shows a beautiful sunset over a mountain landscape.', + }, + ], + stop_reason: 'end_turn', + }); + + const result = await image.analyze.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: [ + { + type: 'text', + text: 'This image shows a beautiful sunset over a mountain landscape.', + }, + ], + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/v1/messages', { + body: { + model: 'claude-sonnet-4-20250514', + max_tokens: 1024, + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'dGVzdCBpbWFnZSBkYXRh', + }, + }, + { + type: 'text', + text: "What's in this image?", + }, + ], + }, + ], + }, + }); + }); + + it('should analyze image from Anthropic file URL', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'claude-sonnet-4-20250514'; + case 'inputType': + return 'url'; + case 'imageUrls': + return 'https://api.anthropic.com/v1/files/file_123'; + case 'text': + return "What's in this image?"; + case 'simplify': + return true; + case 'options': + return { + maxTokens: 1024, + }; + default: + return undefined; + } + }); + executeFunctionsMock.getCredentials.mockResolvedValue({}); + apiRequestMock.mockResolvedValue({ + content: [ + { + type: 'text', + text: 'This image shows a beautiful sunset over a mountain landscape.', + }, + ], + stop_reason: 'end_turn', + }); + + const result = await image.analyze.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: [ + { + type: 'text', + text: 'This image shows a beautiful sunset over a mountain landscape.', + }, + ], + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/v1/messages', { + body: { + model: 'claude-sonnet-4-20250514', + max_tokens: 1024, + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'file', + file_id: 'file_123', + }, + }, + { + type: 'text', + text: "What's in this image?", + }, + ], + }, + ], + }, + }); + }); + }); + + describe('Prompt -> Generate', () => { + it('should generate prompt from task description', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'task': + return 'A chef for a meal prep planning service'; + case 'simplify': + return true; + default: + return undefined; + } + }); + apiRequestMock.mockResolvedValue({ + messages: [ + { + role: 'user', + content: 'Plan a healthy weekly meal prep menu for busy professionals', + }, + ], + system: + 'You are a professional chef specializing in meal prep services for busy professionals. Create balanced, nutritious, and convenient meal plans.', + }); + + const result = await prompt.generate.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + messages: [ + { + role: 'user', + content: 'Plan a healthy weekly meal prep menu for busy professionals', + }, + ], + system: + 'You are a professional chef specializing in meal prep services for busy professionals. Create balanced, nutritious, and convenient meal plans.', + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/v1/experimental/generate_prompt', { + body: { + task: 'A chef for a meal prep planning service', + }, + enableAnthropicBetas: { + promptTools: true, + }, + }); + }); + }); + + describe('Prompt -> Improve', () => { + it('should improve existing prompt with feedback', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'messages.values': + return [ + { + role: 'user', + content: 'Plan meals for the week', + }, + ]; + case 'simplify': + return true; + case 'options': + return { + system: 'You are a chef', + feedback: 'Make it more detailed and include cooking times', + }; + default: + return undefined; + } + }); + apiRequestMock.mockResolvedValue({ + messages: [ + { + role: 'user', + content: + 'Plan a detailed weekly meal prep menu with cooking times and nutritional information for busy professionals', + }, + ], + system: + 'You are a professional chef and nutritionist specializing in meal prep services. Provide detailed cooking instructions, prep times, and nutritional breakdowns.', + }); + + const result = await prompt.improve.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + messages: [ + { + role: 'user', + content: + 'Plan a detailed weekly meal prep menu with cooking times and nutritional information for busy professionals', + }, + ], + system: + 'You are a professional chef and nutritionist specializing in meal prep services. Provide detailed cooking instructions, prep times, and nutritional breakdowns.', + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/v1/experimental/improve_prompt', { + body: { + messages: [ + { + role: 'user', + content: 'Plan meals for the week', + }, + ], + system: 'You are a chef', + feedback: 'Make it more detailed and include cooking times', + }, + enableAnthropicBetas: { + promptTools: true, + }, + }); + }); + }); + + describe('Prompt -> Templatize', () => { + it('should templatize prompt with variables', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'messages.values': + return [ + { + role: 'user', + content: 'Translate hello to German', + }, + ]; + case 'simplify': + return true; + case 'options': + return { + system: 'You are a professional English to German translator', + }; + default: + return undefined; + } + }); + apiRequestMock.mockResolvedValue({ + messages: [ + { + role: 'user', + content: 'Translate {{WORD}} to {{TARGET_LANGUAGE}}', + }, + ], + system: 'You are a professional {{SOURCE_LANGUAGE}} to {{TARGET_LANGUAGE}} translator', + variable_values: { + WORD: 'hello', + TARGET_LANGUAGE: 'German', + SOURCE_LANGUAGE: 'English', + }, + }); + + const result = await prompt.templatize.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + messages: [ + { + role: 'user', + content: 'Translate {{WORD}} to {{TARGET_LANGUAGE}}', + }, + ], + system: 'You are a professional {{SOURCE_LANGUAGE}} to {{TARGET_LANGUAGE}} translator', + variable_values: { + WORD: 'hello', + TARGET_LANGUAGE: 'German', + SOURCE_LANGUAGE: 'English', + }, + }, + pairedItem: { item: 0 }, + }, + ]); + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/v1/experimental/templatize_prompt', { + body: { + messages: [ + { + role: 'user', + content: 'Translate hello to German', + }, + ], + system: 'You are a professional English to German translator', + }, + enableAnthropicBetas: { + promptTools: true, + }, + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/Anthropic.node.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/Anthropic.node.ts new file mode 100644 index 0000000000..65e3d37b25 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/Anthropic.node.ts @@ -0,0 +1,17 @@ +import type { IExecuteFunctions, INodeType } from 'n8n-workflow'; + +import { router } from './actions/router'; +import { versionDescription } from './actions/versionDescription'; +import { listSearch } from './methods'; + +export class Anthropic implements INodeType { + description = versionDescription; + + methods = { + listSearch, + }; + + async execute(this: IExecuteFunctions) { + return await router.call(this); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/descriptions.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/descriptions.ts new file mode 100644 index 0000000000..59d91f2aaa --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/descriptions.ts @@ -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', + }, + ], +}; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/document/analyze.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/document/analyze.operation.ts new file mode 100644 index 0000000000..cf73b36fc4 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/document/analyze.operation.ts @@ -0,0 +1,103 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { baseAnalyze } from '../../helpers/baseAnalyze'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC, + { + 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 { + return await baseAnalyze.call(this, i, 'documentUrls', 'document'); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/document/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/document/index.ts new file mode 100644 index 0000000000..046f840713 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/document/index.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as analyze from './analyze.operation'; + +export { analyze }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Analyze Document', + value: 'analyze', + action: 'Analyze document', + description: 'Take in documents and answer questions about them', + }, + ], + default: 'analyze', + displayOptions: { + show: { + resource: ['document'], + }, + }, + }, + ...analyze.description, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/delete.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/delete.operation.ts new file mode 100644 index 0000000000..266cd27034 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/delete.operation.ts @@ -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 { + 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 }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/get.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/get.operation.ts new file mode 100644 index 0000000000..bbdde06af9 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/get.operation.ts @@ -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 { + 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 }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/index.ts new file mode 100644 index 0000000000..b7c2a7c473 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/index.ts @@ -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, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/list.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/list.operation.ts new file mode 100644 index 0000000000..b66986ede1 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/list.operation.ts @@ -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 { + 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 }, + })); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/upload.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/upload.operation.ts new file mode 100644 index 0000000000..d9f729c50c --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/file/upload.operation.ts @@ -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 { + 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, + }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/image/analyze.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/image/analyze.operation.ts new file mode 100644 index 0000000000..4bc3b84319 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/image/analyze.operation.ts @@ -0,0 +1,102 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { baseAnalyze } from '../../helpers/baseAnalyze'; +import { modelRLC } from '../descriptions'; + +const properties: INodeProperties[] = [ + modelRLC, + { + 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 { + return await baseAnalyze.call(this, i, 'imageUrls', 'image'); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/image/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/image/index.ts new file mode 100644 index 0000000000..313ee44db4 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/image/index.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as analyze from './analyze.operation'; + +export { analyze }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Analyze Image', + value: 'analyze', + action: 'Analyze image', + description: 'Take in images and answer questions about them', + }, + ], + default: 'analyze', + displayOptions: { + show: { + resource: ['image'], + }, + }, + }, + ...analyze.description, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/node.type.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/node.type.ts new file mode 100644 index 0000000000..5284e597b0 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/node.type.ts @@ -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; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/generate.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/generate.operation.ts new file mode 100644 index 0000000000..648a9f752c --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/generate.operation.ts @@ -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 { + 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 }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/improve.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/improve.operation.ts new file mode 100644 index 0000000000..98026e3ea0 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/improve.operation.ts @@ -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 { + 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 }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/index.ts new file mode 100644 index 0000000000..c978e3b4e8 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/index.ts @@ -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 prompt tools APIs 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, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/templatize.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/templatize.operation.ts new file mode 100644 index 0000000000..3c2b6da729 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/prompt/templatize.operation.ts @@ -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 { + 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 }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/router.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/router.test.ts new file mode 100644 index 0000000000..89518b6e32 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/router.test.ts @@ -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(); + 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'); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/router.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/router.ts new file mode 100644 index 0000000000..9574690156 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/router.ts @@ -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]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/text/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/text/index.ts new file mode 100644 index 0000000000..b2f3b64cc1 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/text/index.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as message from './message.operation'; + +export { message }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Message a Model', + value: 'message', + action: 'Message a model', + description: 'Create a completion with Anthropic model', + }, + ], + default: 'message', + displayOptions: { + show: { + resource: ['text'], + }, + }, + }, + ...message.description, +]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/text/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/text/message.operation.ts new file mode 100644 index 0000000000..0a0b2dd903 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/text/message.operation.ts @@ -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 { + 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); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/versionDescription.ts new file mode 100644 index 0000000000..5b5f184feb --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/actions/versionDescription.ts @@ -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, + ], +}; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/anthropic.svg b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/anthropic.svg new file mode 100644 index 0000000000..0331484705 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/anthropic.svg @@ -0,0 +1 @@ + diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/baseAnalyze.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/baseAnalyze.ts new file mode 100644 index 0000000000..b791e1e2df --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/baseAnalyze.ts @@ -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 { + 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 }, + }, + ]; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/interfaces.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/interfaces.ts new file mode 100644 index 0000000000..b5c63cb911 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/interfaces.ts @@ -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; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/utils.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/utils.test.ts new file mode 100644 index 0000000000..20b915137e --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/utils.test.ts @@ -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(); + 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'); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/utils.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/utils.ts new file mode 100644 index 0000000000..a59d6c60b9 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/helpers/utils.ts @@ -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; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/methods/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/methods/index.ts new file mode 100644 index 0000000000..c7fb720e47 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/methods/index.ts @@ -0,0 +1 @@ +export * as listSearch from './listSearch'; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/methods/listSearch.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/methods/listSearch.test.ts new file mode 100644 index 0000000000..13976cdcb3 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/methods/listSearch.test.ts @@ -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(); + 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', + }, + ], + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/methods/listSearch.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/methods/listSearch.ts new file mode 100644 index 0000000000..e4f27306a6 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/methods/listSearch.ts @@ -0,0 +1,24 @@ +import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow'; + +import { apiRequest } from '../transport'; + +export async function modelSearch( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + 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, + })), + }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.test.ts new file mode 100644 index 0000000000..49e313398d --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.test.ts @@ -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(); + + 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', + }, + }, + ); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.ts new file mode 100644 index 0000000000..c421a916f1 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.ts @@ -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); +} diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index f47fb34b42..4456191dc1 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -48,6 +48,7 @@ "dist/credentials/ZepApi.credentials.js" ], "nodes": [ + "dist/nodes/vendors/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",