From 7706ec82c01d9aa22b0c88996bfc77298ae0fcce Mon Sep 17 00:00:00 2001 From: Idir Ouhab Meskine Date: Fri, 17 Oct 2025 09:10:29 +0200 Subject: [PATCH] feat(Anthropic Node): Support custom headers for model requests (#20253) --- .../credentials/AnthropicApi.credentials.ts | 63 +- .../test/AnthropicApi.credentials.test.ts | 158 +++++ .../LMChatAnthropic/LmChatAnthropic.node.ts | 36 +- .../nodes/llms/test/LmChatAnthropic.test.ts | 567 ++++++++++++++++++ .../vendors/Anthropic/transport/index.test.ts | 101 ++++ .../vendors/Anthropic/transport/index.ts | 21 +- 6 files changed, 923 insertions(+), 23 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/credentials/test/AnthropicApi.credentials.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts diff --git a/packages/@n8n/nodes-langchain/credentials/AnthropicApi.credentials.ts b/packages/@n8n/nodes-langchain/credentials/AnthropicApi.credentials.ts index bbc135c54aa..71d1c95fb41 100644 --- a/packages/@n8n/nodes-langchain/credentials/AnthropicApi.credentials.ts +++ b/packages/@n8n/nodes-langchain/credentials/AnthropicApi.credentials.ts @@ -1,7 +1,8 @@ import type { - IAuthenticateGeneric, + ICredentialDataDecryptedObject, ICredentialTestRequest, ICredentialType, + IHttpRequestOptions, INodeProperties, } from 'n8n-workflow'; @@ -28,16 +29,38 @@ export class AnthropicApi implements ICredentialType { default: 'https://api.anthropic.com', description: 'Override the default base URL for the API', }, - ]; - - authenticate: IAuthenticateGeneric = { - type: 'generic', - properties: { - headers: { - 'x-api-key': '={{$credentials.apiKey}}', - }, + { + displayName: 'Add Custom Header', + name: 'header', + type: 'boolean', + default: false, }, - }; + { + displayName: 'Header Name', + name: 'headerName', + type: 'string', + displayOptions: { + show: { + header: [true], + }, + }, + default: '', + }, + { + displayName: 'Header Value', + name: 'headerValue', + type: 'string', + typeOptions: { + password: true, + }, + displayOptions: { + show: { + header: [true], + }, + }, + default: '', + }, + ]; test: ICredentialTestRequest = { request: { @@ -54,4 +77,24 @@ export class AnthropicApi implements ICredentialType { }, }, }; + + async authenticate( + credentials: ICredentialDataDecryptedObject, + requestOptions: IHttpRequestOptions, + ): Promise { + requestOptions.headers ??= {}; + + requestOptions.headers['x-api-key'] = credentials.apiKey; + + if ( + credentials.header && + typeof credentials.headerName === 'string' && + credentials.headerName && + typeof credentials.headerValue === 'string' + ) { + requestOptions.headers[credentials.headerName] = credentials.headerValue; + } + + return requestOptions; + } } diff --git a/packages/@n8n/nodes-langchain/credentials/test/AnthropicApi.credentials.test.ts b/packages/@n8n/nodes-langchain/credentials/test/AnthropicApi.credentials.test.ts new file mode 100644 index 00000000000..9c2396dee30 --- /dev/null +++ b/packages/@n8n/nodes-langchain/credentials/test/AnthropicApi.credentials.test.ts @@ -0,0 +1,158 @@ +import type { ICredentialDataDecryptedObject, IHttpRequestOptions } from 'n8n-workflow'; + +import { AnthropicApi } from '../AnthropicApi.credentials'; + +describe('AnthropicApi Credential', () => { + const anthropicApi = new AnthropicApi(); + + it('should have correct properties', () => { + expect(anthropicApi.name).toBe('anthropicApi'); + expect(anthropicApi.displayName).toBe('Anthropic'); + expect(anthropicApi.documentationUrl).toBe('anthropic'); + expect(anthropicApi.properties).toHaveLength(5); + expect(anthropicApi.test.request.baseURL).toBe('={{$credentials?.url}}'); + expect(anthropicApi.test.request.url).toBe('/v1/messages'); + }); + + describe('authenticate', () => { + it('should add x-api-key header with API key only', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-ant-test123456789', + }; + + const requestOptions: IHttpRequestOptions = { + headers: {}, + url: '/v1/messages', + baseURL: 'https://api.anthropic.com', + }; + + const result = await anthropicApi.authenticate(credentials, requestOptions); + + expect(result.headers).toEqual({ + 'x-api-key': 'sk-ant-test123456789', + }); + }); + + it('should add custom header when header toggle is enabled', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-ant-test123456789', + header: true, + headerName: 'X-Custom-Header', + headerValue: 'custom-value-123', + }; + + const requestOptions: IHttpRequestOptions = { + headers: {}, + url: '/v1/messages', + baseURL: 'https://api.anthropic.com', + }; + + const result = await anthropicApi.authenticate(credentials, requestOptions); + + expect(result.headers).toEqual({ + 'x-api-key': 'sk-ant-test123456789', + 'X-Custom-Header': 'custom-value-123', + }); + }); + + it('should not add custom header when header toggle is disabled', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-ant-test123456789', + header: false, + headerName: 'X-Custom-Header', + headerValue: 'custom-value-123', + }; + + const requestOptions: IHttpRequestOptions = { + headers: {}, + url: '/v1/messages', + baseURL: 'https://api.anthropic.com', + }; + + const result = await anthropicApi.authenticate(credentials, requestOptions); + + expect(result.headers).toEqual({ + 'x-api-key': 'sk-ant-test123456789', + }); + expect(result.headers?.['X-Custom-Header']).toBeUndefined(); + }); + + it('should preserve existing headers', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-ant-test123456789', + header: true, + headerName: 'X-Custom-Header', + headerValue: 'custom-value-123', + }; + + const requestOptions: IHttpRequestOptions = { + url: '/v1/messages', + baseURL: 'https://api.anthropic.com', + }; + + const result = await anthropicApi.authenticate(credentials, requestOptions); + + const raw = + typeof (result.headers as any)?.get === 'function' + ? Object.fromEntries((result.headers as unknown as Headers).entries()) + : (result.headers as Record); + + const headers = Object.fromEntries(Object.entries(raw).map(([k, v]) => [k.toLowerCase(), v])); + + expect(headers).toEqual( + expect.objectContaining({ + 'x-api-key': 'sk-ant-test123456789', + 'x-custom-header': 'custom-value-123', + }), + ); + }); + + it('should preserve existing headers when adding auth headers', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-ant-test123456789', + }; + + const requestOptions: IHttpRequestOptions = { + headers: { + 'anthropic-version': '2023-06-01', + }, + url: '/v1/messages', + baseURL: 'https://api.anthropic.com', + }; + + const result = await anthropicApi.authenticate(credentials, requestOptions); + + expect(result.headers).toEqual({ + 'anthropic-version': '2023-06-01', + 'x-api-key': 'sk-ant-test123456789', + }); + }); + + it('should preserve existing headers even with custom header option enabled', async () => { + const credentials: ICredentialDataDecryptedObject = { + apiKey: 'sk-ant-test123456789', + header: true, + headerName: 'X-Additional-Header', + headerValue: 'additional-value', + }; + + const requestOptions: IHttpRequestOptions = { + headers: { + 'anthropic-version': '2023-06-01', + 'X-Existing-Header': 'existing-value', + }, + url: '/v1/messages', + baseURL: 'https://api.anthropic.com', + }; + + const result = await anthropicApi.authenticate(credentials, requestOptions); + + expect(result.headers).toEqual({ + 'anthropic-version': '2023-06-01', + 'X-Existing-Header': 'existing-value', + 'x-api-key': 'sk-ant-test123456789', + 'X-Additional-Header': 'additional-value', + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts index 2ade5da73e3..385d8d1e68a 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts @@ -264,9 +264,13 @@ export class LmChatAnthropic implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const credentials = await this.getCredentials<{ url?: string; apiKey?: string }>( - 'anthropicApi', - ); + const credentials = await this.getCredentials<{ + url?: string; + apiKey?: string; + header?: boolean; + headerName?: string; + headerValue?: string; + }>('anthropicApi'); const baseURL = credentials.url ?? 'https://api.anthropic.com'; const version = this.getNode().typeVersion; const modelName = @@ -319,6 +323,26 @@ export class LmChatAnthropic implements INodeType { }; } + const clientOptions: { + fetchOptions?: { dispatcher: any }; + defaultHeaders?: Record; + } = { + fetchOptions: { + dispatcher: getProxyAgent(baseURL), + }, + }; + + if ( + credentials.header && + typeof credentials.headerName === 'string' && + credentials.headerName && + typeof credentials.headerValue === 'string' + ) { + clientOptions.defaultHeaders = { + [credentials.headerName]: credentials.headerValue, + }; + } + const model = new ChatAnthropic({ anthropicApiKey: credentials.apiKey, model: modelName, @@ -330,11 +354,7 @@ export class LmChatAnthropic implements INodeType { callbacks: [new N8nLlmTracing(this, { tokensUsageParser })], onFailedAttempt: makeN8nLlmFailedAttemptHandler(this), invocationKwargs, - clientOptions: { - fetchOptions: { - dispatcher: getProxyAgent(baseURL), - }, - }, + clientOptions, }); // Some Anthropic models do not support Langchain default of -1 for topP so we need to unset it diff --git a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts new file mode 100644 index 00000000000..707c6521240 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts @@ -0,0 +1,567 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +/* eslint-disable @typescript-eslint/unbound-method */ +import { ChatAnthropic } from '@langchain/anthropic'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import type { ILoadOptionsFunctions, INode, ISupplyDataFunctions } from 'n8n-workflow'; + +import { LmChatAnthropic } from '../LMChatAnthropic/LmChatAnthropic.node'; +import { N8nLlmTracing } from '../N8nLlmTracing'; +import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; + +jest.mock('@langchain/anthropic'); +jest.mock('../N8nLlmTracing'); +jest.mock('../n8nLlmFailedAttemptHandler'); +jest.mock('@utils/httpProxyAgent', () => ({ + getProxyAgent: jest.fn().mockReturnValue({}), +})); + +const MockedChatAnthropic = jest.mocked(ChatAnthropic); +const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing); +const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler); + +describe('LmChatAnthropic', () => { + let lmChatAnthropic: LmChatAnthropic; + let mockContext: jest.Mocked; + + const mockNode: INode = { + id: '1', + name: 'Anthropic Chat Model', + typeVersion: 1.3, + type: 'n8n-nodes-langchain.lmChatAnthropic', + position: [0, 0], + parameters: {}, + }; + + const setupMockContext = (nodeOverrides: Partial = {}) => { + const node = { ...mockNode, ...nodeOverrides }; + mockContext = createMockExecuteFunction( + {}, + node, + ) as jest.Mocked; + + // Setup default mocks + mockContext.getCredentials = jest.fn().mockResolvedValue({ + apiKey: 'test-api-key', + }); + mockContext.getNode = jest.fn().mockReturnValue(node); + mockContext.getNodeParameter = jest.fn(); + + // Mock the constructors/functions properly + MockedN8nLlmTracing.mockImplementation(() => ({}) as any); + mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(jest.fn()); + + return mockContext; + }; + + beforeEach(() => { + lmChatAnthropic = new LmChatAnthropic(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('node description', () => { + it('should have correct node properties', () => { + expect(lmChatAnthropic.description).toMatchObject({ + displayName: 'Anthropic Chat Model', + name: 'lmChatAnthropic', + group: ['transform'], + version: [1, 1.1, 1.2, 1.3], + description: 'Language Model Anthropic', + }); + }); + + it('should have correct credentials configuration', () => { + expect(lmChatAnthropic.description.credentials).toEqual([ + { + name: 'anthropicApi', + required: true, + }, + ]); + }); + + it('should have correct output configuration', () => { + expect(lmChatAnthropic.description.outputs).toEqual(['ai_languageModel']); + expect(lmChatAnthropic.description.outputNames).toEqual(['Model']); + }); + }); + + describe('supplyData', () => { + it('should create ChatAnthropic instance with basic configuration (version >= 1.3)', async () => { + const mockContext = setupMockContext({ typeVersion: 1.3 }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-20250514'; + if (paramName === 'options') return {}; + return undefined; + }); + + const result = await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(mockContext.getCredentials).toHaveBeenCalledWith('anthropicApi'); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('model.value', 0); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('options', 0, {}); + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + anthropicApiKey: 'test-api-key', + model: 'claude-sonnet-4-20250514', + anthropicApiUrl: 'https://api.anthropic.com', + callbacks: expect.arrayContaining([expect.any(Object)]), + onFailedAttempt: expect.any(Function), + invocationKwargs: {}, + clientOptions: { + fetchOptions: { + dispatcher: {}, + }, + }, + }), + ); + + expect(result).toEqual({ + response: expect.any(Object), + }); + }); + + it('should create ChatAnthropic instance with basic configuration (version < 1.3)', async () => { + const mockContext = setupMockContext({ typeVersion: 1.2 }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'claude-3-5-sonnet-20240620'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('model', 0); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('options', 0, {}); + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + anthropicApiKey: 'test-api-key', + model: 'claude-3-5-sonnet-20240620', + anthropicApiUrl: 'https://api.anthropic.com', + callbacks: expect.arrayContaining([expect.any(Object)]), + onFailedAttempt: expect.any(Function), + invocationKwargs: {}, + clientOptions: { + fetchOptions: { + dispatcher: {}, + }, + }, + }), + ); + }); + + it('should handle custom baseURL from credentials', async () => { + const customURL = 'https://custom-anthropic.example.com'; + const mockContext = setupMockContext(); + + mockContext.getCredentials.mockResolvedValue({ + apiKey: 'test-api-key', + url: customURL, + }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-20250514'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + anthropicApiKey: 'test-api-key', + model: 'claude-sonnet-4-20250514', + anthropicApiUrl: customURL, + callbacks: expect.arrayContaining([expect.any(Object)]), + onFailedAttempt: expect.any(Function), + invocationKwargs: {}, + clientOptions: { + fetchOptions: { + dispatcher: {}, + }, + }, + }), + ); + }); + + it('should handle custom headers from credentials', async () => { + const mockContext = setupMockContext(); + + mockContext.getCredentials.mockResolvedValue({ + apiKey: 'test-api-key', + header: true, + headerName: 'X-Custom-Header', + headerValue: 'custom-value', + }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-20250514'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + anthropicApiKey: 'test-api-key', + model: 'claude-sonnet-4-20250514', + anthropicApiUrl: 'https://api.anthropic.com', + callbacks: expect.arrayContaining([expect.any(Object)]), + onFailedAttempt: expect.any(Function), + invocationKwargs: {}, + clientOptions: { + fetchOptions: { + dispatcher: {}, + }, + defaultHeaders: { + 'X-Custom-Header': 'custom-value', + }, + }, + }), + ); + }); + + it('should handle all available options', async () => { + const mockContext = setupMockContext(); + const options = { + maxTokensToSample: 1000, + temperature: 0.8, + topK: 5, + topP: 0.9, + }; + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-20250514'; + if (paramName === 'options') return options; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + anthropicApiKey: 'test-api-key', + model: 'claude-sonnet-4-20250514', + maxTokens: 1000, + temperature: 0.8, + topK: 5, + topP: 0.9, + anthropicApiUrl: 'https://api.anthropic.com', + callbacks: expect.arrayContaining([expect.any(Object)]), + onFailedAttempt: expect.any(Function), + invocationKwargs: {}, + clientOptions: { + fetchOptions: { + dispatcher: {}, + }, + }, + }), + ); + }); + + it('should handle thinking mode', async () => { + const mockContext = setupMockContext(); + const options = { + thinking: true, + thinkingBudget: 2048, + maxTokensToSample: 4096, + }; + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-20250514'; + if (paramName === 'options') return options; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + anthropicApiKey: 'test-api-key', + model: 'claude-sonnet-4-20250514', + maxTokens: 4096, + anthropicApiUrl: 'https://api.anthropic.com', + callbacks: expect.arrayContaining([expect.any(Object)]), + onFailedAttempt: expect.any(Function), + invocationKwargs: { + thinking: { + type: 'enabled', + budget_tokens: 2048, + }, + max_tokens: 4096, + top_k: undefined, + top_p: undefined, + temperature: undefined, + }, + clientOptions: { + fetchOptions: { + dispatcher: {}, + }, + }, + }), + ); + }); + + it('should create N8nLlmTracing callback', async () => { + const mockContext = setupMockContext(); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-20250514'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedN8nLlmTracing).toHaveBeenCalledWith(mockContext, { + tokensUsageParser: expect.any(Function), + }); + }); + + it('should create failed attempt handler', async () => { + const mockContext = setupMockContext(); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-20250514'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(mockedMakeN8nLlmFailedAttemptHandler).toHaveBeenCalledWith(mockContext); + }); + + it('should not add custom headers when header toggle is disabled', async () => { + const mockContext = setupMockContext(); + + mockContext.getCredentials.mockResolvedValue({ + apiKey: 'test-api-key', + header: false, + headerName: 'X-Custom-Header', + headerValue: 'custom-value', + }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-20250514'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + clientOptions: { + fetchOptions: { + dispatcher: {}, + }, + }, + }), + ); + + // Verify that defaultHeaders is not set + const callArgs = MockedChatAnthropic.mock.calls[0]?.[0]; + expect(callArgs?.clientOptions?.defaultHeaders).toBeUndefined(); + }); + + it('should handle custom headers and custom URL together', async () => { + const customURL = 'https://custom-anthropic.example.com'; + const mockContext = setupMockContext(); + + mockContext.getCredentials.mockResolvedValue({ + apiKey: 'test-api-key', + url: customURL, + header: true, + headerName: 'X-Custom-Header', + headerValue: 'custom-value', + }); + + mockContext.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model.value') return 'claude-sonnet-4-20250514'; + if (paramName === 'options') return {}; + return undefined; + }); + + await lmChatAnthropic.supplyData.call(mockContext, 0); + + expect(MockedChatAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + anthropicApiKey: 'test-api-key', + model: 'claude-sonnet-4-20250514', + anthropicApiUrl: customURL, + callbacks: expect.arrayContaining([expect.any(Object)]), + onFailedAttempt: expect.any(Function), + invocationKwargs: {}, + clientOptions: { + fetchOptions: { + dispatcher: {}, + }, + defaultHeaders: { + 'X-Custom-Header': 'custom-value', + }, + }, + }), + ); + }); + }); + + describe('methods', () => { + describe('searchModels', () => { + let mockLoadContext: ILoadOptionsFunctions; + let mockGetCredentials: jest.Mock; + let mockHttpRequest: jest.Mock; + + beforeEach(() => { + mockGetCredentials = jest.fn(); + mockHttpRequest = jest.fn(); + + mockLoadContext = { + getCredentials: mockGetCredentials, + helpers: { + httpRequestWithAuthentication: mockHttpRequest, + }, + } as unknown as ILoadOptionsFunctions; + }); + + it('should return all models sorted by creation date', async () => { + const mockModels = [ + { + id: 'claude-2', + display_name: 'Claude 2', + type: 'chat', + created_at: '2023-01-01T00:00:00Z', + }, + { + id: 'claude-3-opus-20240229', + display_name: 'Claude 3 Opus', + type: 'chat', + created_at: '2024-02-29T00:00:00Z', + }, + { + id: 'claude-3-sonnet-20240229', + display_name: 'Claude 3 Sonnet', + type: 'chat', + created_at: '2024-02-29T00:00:00Z', + }, + ]; + + mockGetCredentials.mockResolvedValue({}); + mockHttpRequest.mockResolvedValue({ + data: mockModels, + }); + + const { searchModels } = lmChatAnthropic.methods.listSearch; + const result = await searchModels.call(mockLoadContext); + + expect(mockHttpRequest).toHaveBeenCalledWith('anthropicApi', { + url: 'https://api.anthropic.com/v1/models', + headers: { + 'anthropic-version': '2023-06-01', + }, + }); + + expect(result.results).toHaveLength(3); + // Verify sorted by creation date (newest first) + expect(result.results[0].value).toBe('claude-3-opus-20240229'); + expect(result.results[0].name).toBe('Claude 3 Opus'); + expect(result.results[2].value).toBe('claude-2'); + }); + + it('should filter models by search term', async () => { + const mockModels = [ + { + id: 'claude-2', + display_name: 'Claude 2', + type: 'chat', + created_at: '2023-01-01T00:00:00Z', + }, + { + id: 'claude-3-opus-20240229', + display_name: 'Claude 3 Opus', + type: 'chat', + created_at: '2024-02-29T00:00:00Z', + }, + { + id: 'claude-3-sonnet-20240229', + display_name: 'Claude 3 Sonnet', + type: 'chat', + created_at: '2024-02-29T00:00:00Z', + }, + ]; + + mockGetCredentials.mockResolvedValue({}); + mockHttpRequest.mockResolvedValue({ + data: mockModels, + }); + + const { searchModels } = lmChatAnthropic.methods.listSearch; + const result = await searchModels.call(mockLoadContext, 'opus'); + + expect(result.results).toHaveLength(1); + expect(result.results[0].value).toBe('claude-3-opus-20240229'); + expect(result.results[0].name).toBe('Claude 3 Opus'); + }); + + it('should filter models case-insensitively', async () => { + const mockModels = [ + { + id: 'claude-3-sonnet-20240229', + display_name: 'Claude 3 Sonnet', + type: 'chat', + created_at: '2024-02-29T00:00:00Z', + }, + ]; + + mockGetCredentials.mockResolvedValue({}); + mockHttpRequest.mockResolvedValue({ + data: mockModels, + }); + + const { searchModels } = lmChatAnthropic.methods.listSearch; + const result = await searchModels.call(mockLoadContext, 'SONNET'); + + expect(result.results).toHaveLength(1); + expect(result.results[0].value).toBe('claude-3-sonnet-20240229'); + }); + + it('should use custom URL from credentials', async () => { + const customURL = 'https://custom-anthropic.example.com'; + + mockGetCredentials.mockResolvedValue({ + url: customURL, + }); + mockHttpRequest.mockResolvedValue({ + data: [], + }); + + const { searchModels } = lmChatAnthropic.methods.listSearch; + await searchModels.call(mockLoadContext); + + expect(mockHttpRequest).toHaveBeenCalledWith('anthropicApi', { + url: `${customURL}/v1/models`, + headers: { + 'anthropic-version': '2023-06-01', + }, + }); + }); + + it('should handle empty model list', async () => { + mockGetCredentials.mockResolvedValue({}); + mockHttpRequest.mockResolvedValue({ + data: [], + }); + + const { searchModels } = lmChatAnthropic.methods.listSearch; + const result = await searchModels.call(mockLoadContext); + + expect(result.results).toHaveLength(0); + }); + }); + }); +}); 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 index 49e313398d8..70c16f71ddb 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.test.ts @@ -163,4 +163,105 @@ describe('Anthropic transport', () => { }, ); }); + + it('should add custom header when header toggle is enabled', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + header: true, + headerName: 'X-Custom-Header', + headerValue: 'custom-value-123', + }); + + await apiRequest.call(executeFunctionsMock, 'POST', '/v1/messages'); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'anthropicApi', + { + method: 'POST', + url: 'https://api.anthropic.com/v1/messages', + json: true, + headers: { + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'files-api-2025-04-14', + 'X-Custom-Header': 'custom-value-123', + }, + }, + ); + }); + + it('should not add custom header when header toggle is disabled', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + header: false, + headerName: 'X-Custom-Header', + headerValue: 'custom-value-123', + }); + + await apiRequest.call(executeFunctionsMock, 'POST', '/v1/messages'); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'anthropicApi', + { + method: 'POST', + url: 'https://api.anthropic.com/v1/messages', + json: true, + headers: { + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'files-api-2025-04-14', + }, + }, + ); + }); + + it('should add custom header along with other headers', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + header: true, + headerName: 'X-Custom-Header', + headerValue: 'custom-value-123', + }); + + await apiRequest.call(executeFunctionsMock, 'POST', '/v1/messages', { + headers: { + 'Content-Type': 'application/json', + }, + }); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'anthropicApi', + { + method: 'POST', + url: 'https://api.anthropic.com/v1/messages', + json: true, + headers: { + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'files-api-2025-04-14', + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value-123', + }, + }, + ); + }); + + it('should handle custom header with custom URL', async () => { + executeFunctionsMock.getCredentials.mockResolvedValue({ + url: 'https://custom-url.com', + header: true, + headerName: 'X-Custom-Header', + headerValue: 'custom-value-123', + }); + + await apiRequest.call(executeFunctionsMock, 'POST', '/v1/messages'); + + expect(executeFunctionsMock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'anthropicApi', + { + method: 'POST', + url: 'https://custom-url.com/v1/messages', + json: true, + headers: { + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'files-api-2025-04-14', + 'X-Custom-Header': 'custom-value-123', + }, + }, + ); + }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.ts index c421a916f1e..750c9a9aa9a 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Anthropic/transport/index.ts @@ -38,12 +38,23 @@ export async function apiRequest( betas.push('code-execution-2025-05-22'); } + const requestHeaders: IDataObject = { + 'anthropic-version': '2023-06-01', + 'anthropic-beta': betas.join(','), + ...headers, + }; + + if ( + credentials.header && + typeof credentials.headerName === 'string' && + credentials.headerName && + typeof credentials.headerValue === 'string' + ) { + requestHeaders[credentials.headerName] = credentials.headerValue; + } + const options = { - headers: { - 'anthropic-version': '2023-06-01', - 'anthropic-beta': betas.join(','), - ...headers, - }, + headers: requestHeaders, method, body, qs,