diff --git a/packages/@n8n/nodes-langchain/credentials/AlibabaCloudApi.credentials.ts b/packages/@n8n/nodes-langchain/credentials/AlibabaCloudApi.credentials.ts new file mode 100644 index 00000000000..3cb05af7ca7 --- /dev/null +++ b/packages/@n8n/nodes-langchain/credentials/AlibabaCloudApi.credentials.ts @@ -0,0 +1,86 @@ +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +import { BASE_URL_EXPRESSION } from '../nodes/llms/LmChatAlibabaCloud/alibaba-cloud-base-url'; + +export class AlibabaCloudApi implements ICredentialType { + name = 'alibabaCloudApi'; + + displayName = 'Alibaba Cloud'; + + documentationUrl = 'alibaba'; + + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + typeOptions: { password: true }, + required: true, + default: '', + }, + { + displayName: 'Region', + name: 'region', + type: 'options', + default: 'ap-southeast-1', + options: [ + { + name: 'Singapore (International)', + value: 'ap-southeast-1', + }, + { + name: 'US (Virginia)', + value: 'us-east-1', + }, + { + name: 'China (Beijing)', + value: 'cn-beijing', + }, + { + name: 'Hong Kong (China)', + value: 'cn-hongkong', + }, + { + name: 'Germany (Frankfurt)', + value: 'eu-central-1', + }, + ], + description: 'The region for the Alibaba Cloud Model Studio API endpoint', + }, + { + displayName: 'Workspace ID', + name: 'workspaceId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + region: ['eu-central-1'], + }, + }, + description: + 'The Workspace ID required for the Germany (Frankfurt) region. Find it in the Model Studio console under the Germany region settings.', + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '=Bearer {{$credentials.apiKey}}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: BASE_URL_EXPRESSION, + url: '/models', + }, + }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/LmChatAlibabaCloud.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/LmChatAlibabaCloud.node.ts new file mode 100644 index 00000000000..8c86e054223 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/LmChatAlibabaCloud.node.ts @@ -0,0 +1,272 @@ +import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; +import { + getProxyAgent, + makeN8nLlmFailedAttemptHandler, + N8nLlmTracing, + getConnectionHintNoticeField, +} from '@n8n/ai-utilities'; +import { + NodeConnectionTypes, + NodeOperationError, + type INodeType, + type INodeTypeDescription, + type ISupplyDataFunctions, + type SupplyData, +} from 'n8n-workflow'; + +import { BASE_URL_EXPRESSION, getBaseUrl } from './alibaba-cloud-base-url'; +import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; + +export class LmChatAlibabaCloud implements INodeType { + description: INodeTypeDescription = { + displayName: 'Alibaba Cloud Chat Model', + + name: 'lmChatAlibabaCloud', + icon: 'file:alibaba.svg', + group: ['transform'], + version: [1], + description: 'For advanced usage with an AI chain', + defaults: { + name: 'Alibaba Cloud Chat Model', + }, + codex: { + categories: ['AI'], + subcategories: { + AI: ['Language Models', 'Root Nodes'], + 'Language Models': ['Chat Models (Recommended)'], + }, + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatalibabacloud/', + }, + ], + }, + alias: ['qwen', 'dashscope', 'alibaba', 'model studio'], + }, + + inputs: [], + + outputs: [NodeConnectionTypes.AiLanguageModel], + outputNames: ['Model'], + credentials: [ + { + name: 'alibabaCloudApi', + required: true, + }, + ], + requestDefaults: { + ignoreHttpStatusErrors: true, + baseURL: BASE_URL_EXPRESSION, + }, + properties: [ + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), + { + displayName: + 'If using JSON response format, you must include word "json" in the prompt in your chain or agent.', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + '/options.responseFormat': ['json_object'], + }, + }, + }, + { + displayName: 'Model', + name: 'model', + type: 'options', + description: + 'The model which will generate the completion. Learn more.', + typeOptions: { + loadOptions: { + routing: { + request: { + method: 'GET', + url: '/models', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.id}}', + value: '={{$responseItem.id}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + }, + routing: { + send: { + type: 'body', + property: 'model', + }, + }, + default: 'qwen-plus', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + description: 'Additional options to add', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Frequency Penalty', + name: 'frequencyPenalty', + default: 0, + typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 }, + description: + "Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim", + type: 'number', + }, + { + displayName: 'Maximum Number of Tokens', + name: 'maxTokens', + default: -1, + description: + 'The maximum number of tokens to generate in the completion. The limit depends on the selected model.', + type: 'number', + }, + { + displayName: 'Response Format', + name: 'responseFormat', + default: 'text', + type: 'options', + options: [ + { + name: 'Text', + value: 'text', + description: 'Regular text response', + }, + { + name: 'JSON', + value: 'json_object', + description: + 'Enables JSON mode, which should guarantee the message the model generates is valid JSON', + }, + ], + }, + { + displayName: 'Presence Penalty', + name: 'presencePenalty', + default: 0, + typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 }, + description: + "Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics", + type: 'number', + }, + { + displayName: 'Sampling Temperature', + name: 'temperature', + default: 0.7, + typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 }, + description: + 'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.', + type: 'number', + }, + { + displayName: 'Timeout', + name: 'timeout', + default: 360000, + description: 'Maximum amount of time a request is allowed to take in milliseconds', + type: 'number', + }, + { + displayName: 'Max Retries', + name: 'maxRetries', + default: 2, + description: 'Maximum number of retries to attempt', + type: 'number', + }, + { + displayName: 'Top P', + name: 'topP', + default: 1, + typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 }, + description: + 'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.', + type: 'number', + }, + ], + }, + ], + }; + + async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { + const credentials = await this.getCredentials<{ + apiKey: string; + region: string; + workspaceId?: string; + }>('alibabaCloudApi'); + + const modelName = this.getNodeParameter('model', itemIndex) as string; + + const options = this.getNodeParameter('options', itemIndex, {}) as { + frequencyPenalty?: number; + maxTokens?: number; + maxRetries: number; + timeout: number; + presencePenalty?: number; + temperature?: number; + topP?: number; + responseFormat?: 'text' | 'json_object'; + }; + + if (credentials.region === 'eu-central-1' && !credentials.workspaceId) { + throw new NodeOperationError( + this.getNode(), + 'Workspace ID is required for the Germany (Frankfurt) region', + ); + } + + const baseURL = getBaseUrl(credentials.region, credentials.workspaceId); + const timeout = options.timeout; + const configuration: ClientOptions = { + baseURL, + fetchOptions: { + dispatcher: getProxyAgent(baseURL, { + headersTimeout: timeout, + bodyTimeout: timeout, + }), + }, + }; + + const model = new ChatOpenAI({ + apiKey: credentials.apiKey, + model: modelName, + ...options, + timeout, + maxRetries: options.maxRetries ?? 2, + configuration, + callbacks: [new N8nLlmTracing(this)], + modelKwargs: options.responseFormat + ? { + response_format: { type: options.responseFormat }, + } + : undefined, + onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler), + }); + + return { + response: model, + }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/alibaba-cloud-base-url.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/alibaba-cloud-base-url.ts new file mode 100644 index 00000000000..ddfb8b42131 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/alibaba-cloud-base-url.ts @@ -0,0 +1,14 @@ +export function getBaseUrl(region: string, workspaceId?: string): string { + const urls: Record = { + 'ap-southeast-1': 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + 'us-east-1': 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + 'cn-beijing': 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'cn-hongkong': 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', + }; + if (region === 'eu-central-1') { + return 'https://' + workspaceId + '.eu-central-1.maas.aliyuncs.com/compatible-mode/v1'; + } + return urls[region] || urls['ap-southeast-1']; +} + +export const BASE_URL_EXPRESSION = `={{ (${getBaseUrl.toString()})($credentials.region, $credentials.workspaceId) }}`; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/alibaba.svg b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/alibaba.svg new file mode 100644 index 00000000000..375c6943558 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/alibaba.svg @@ -0,0 +1 @@ +Alibaba Cloud diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/test/LmChatAlibabaCloud.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/test/LmChatAlibabaCloud.test.ts new file mode 100644 index 00000000000..843ce1ee6dc --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/test/LmChatAlibabaCloud.test.ts @@ -0,0 +1,280 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/unbound-method */ +import { ChatOpenAI } from '@langchain/openai'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; + +import { LmChatAlibabaCloud } from '../LmChatAlibabaCloud.node'; + +jest.mock('@langchain/openai'); +jest.mock('@n8n/ai-utilities'); + +const MockedChatOpenAI = jest.mocked(ChatOpenAI); +const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing); +const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler); +const mockedGetProxyAgent = jest.mocked(getProxyAgent); + +describe('LmChatAlibabaCloud', () => { + let node: LmChatAlibabaCloud; + + const mockNodeDef: INode = { + id: '1', + name: 'Alibaba Cloud Chat Model', + typeVersion: 1, + type: '@n8n/n8n-nodes-langchain.lmChatAlibabaCloud', + position: [0, 0], + parameters: {}, + }; + + const setupMockContext = (nodeOverrides: Partial = {}) => { + const nodeDef = { ...mockNodeDef, ...nodeOverrides }; + const ctx = createMockExecuteFunction( + {}, + nodeDef, + ) as jest.Mocked; + + ctx.getCredentials = jest.fn().mockResolvedValue({ + apiKey: 'test-dashscope-key', + region: 'ap-southeast-1', + }); + ctx.getNode = jest.fn().mockReturnValue(nodeDef); + ctx.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'qwen-plus'; + if (paramName === 'options') return {}; + return undefined; + }); + + MockedN8nLlmTracing.mockImplementation(() => ({}) as unknown as N8nLlmTracing); + mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(jest.fn()); + mockedGetProxyAgent.mockReturnValue({} as any); + return ctx; + }; + + beforeEach(() => { + node = new LmChatAlibabaCloud(); + jest.clearAllMocks(); + }); + + describe('node description', () => { + it('should have correct node properties', () => { + expect(node.description).toMatchObject({ + displayName: 'Alibaba Cloud Chat Model', + name: 'lmChatAlibabaCloud', + group: ['transform'], + version: [1], + }); + }); + + it('should require alibabaCloudApi credentials', () => { + expect(node.description.credentials).toEqual([{ name: 'alibabaCloudApi', required: true }]); + }); + + it('should output ai_languageModel', () => { + expect(node.description.outputs).toEqual(['ai_languageModel']); + expect(node.description.outputNames).toEqual(['Model']); + }); + }); + + describe('supplyData', () => { + it('should create ChatOpenAI with DashScope base URL', async () => { + const ctx = setupMockContext(); + + const result = await node.supplyData.call(ctx, 0); + + expect(ctx.getCredentials).toHaveBeenCalledWith('alibabaCloudApi'); + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-dashscope-key', + model: 'qwen-plus', + maxRetries: 2, + callbacks: expect.arrayContaining([expect.any(Object)]), + onFailedAttempt: expect.any(Function), + configuration: expect.objectContaining({ + baseURL: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + }), + }), + ); + expect(result).toEqual({ response: expect.any(Object) }); + }); + + it('should pass options to ChatOpenAI', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'qwen-turbo'; + if (paramName === 'options') + return { + temperature: 0.5, + maxTokens: 2000, + topP: 0.9, + frequencyPenalty: 0.3, + presencePenalty: 0.2, + timeout: 60000, + maxRetries: 5, + }; + return undefined; + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'qwen-turbo', + temperature: 0.5, + maxTokens: 2000, + topP: 0.9, + frequencyPenalty: 0.3, + presencePenalty: 0.2, + timeout: 60000, + maxRetries: 5, + }), + ); + }); + + it('should set response_format in modelKwargs when responseFormat is provided', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'qwen-plus'; + if (paramName === 'options') return { responseFormat: 'json_object' }; + return undefined; + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + modelKwargs: { response_format: { type: 'json_object' } }, + }), + ); + }); + + it('should not set modelKwargs when no responseFormat', async () => { + const ctx = setupMockContext(); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + modelKwargs: undefined, + }), + ); + }); + + it('should configure proxy agent with credentials URL', async () => { + const ctx = setupMockContext(); + + await node.supplyData.call(ctx, 0); + + expect(mockedGetProxyAgent).toHaveBeenCalledWith( + 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + expect.objectContaining({ + headersTimeout: undefined, + bodyTimeout: undefined, + }), + ); + }); + + it('should configure proxy agent with custom timeout', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'qwen-plus'; + if (paramName === 'options') return { timeout: 120000 }; + return undefined; + }); + + await node.supplyData.call(ctx, 0); + + expect(mockedGetProxyAgent).toHaveBeenCalledWith( + 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + expect.objectContaining({ + headersTimeout: 120000, + bodyTimeout: 120000, + }), + ); + }); + + it('should use US region base URL', async () => { + const ctx = setupMockContext(); + ctx.getCredentials = jest.fn().mockResolvedValue({ + apiKey: 'test-key', + region: 'us-east-1', + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: expect.objectContaining({ + baseURL: 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + }), + }), + ); + }); + + it('should use Frankfurt region base URL with workspace ID', async () => { + const ctx = setupMockContext(); + ctx.getCredentials = jest.fn().mockResolvedValue({ + apiKey: 'test-key', + region: 'eu-central-1', + workspaceId: 'ws-abc123', + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: expect.objectContaining({ + baseURL: 'https://ws-abc123.eu-central-1.maas.aliyuncs.com/compatible-mode/v1', + }), + }), + ); + }); + + it('should use China (Beijing) region base URL', async () => { + const ctx = setupMockContext(); + ctx.getCredentials = jest.fn().mockResolvedValue({ + apiKey: 'test-key', + region: 'cn-beijing', + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: expect.objectContaining({ + baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + }), + }), + ); + }); + + it('should use Hong Kong region base URL', async () => { + const ctx = setupMockContext(); + ctx.getCredentials = jest.fn().mockResolvedValue({ + apiKey: 'test-key', + region: 'cn-hongkong', + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: expect.objectContaining({ + baseURL: 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', + }), + }), + ); + }); + + it('should throw when eu-central-1 is selected without workspaceId', async () => { + const ctx = setupMockContext(); + ctx.getCredentials = jest.fn().mockResolvedValue({ + apiKey: 'test-key', + region: 'eu-central-1', + }); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Workspace ID'); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index ea50a77f178..e5129fcadd5 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -44,6 +44,7 @@ "n8n": { "n8nNodesApiVersion": 1, "credentials": [ + "dist/credentials/AlibabaCloudApi.credentials.js", "dist/credentials/AnthropicApi.credentials.js", "dist/credentials/AzureAiSearchApi.credentials.js", "dist/credentials/AzureOpenAiApi.credentials.js", @@ -107,6 +108,7 @@ "dist/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.js", "dist/nodes/embeddings/EmbeddingsLemonade/EmbeddingsLemonade.node.js", "dist/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.js", + "dist/nodes/llms/LmChatAlibabaCloud/LmChatAlibabaCloud.node.js", "dist/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.js", "dist/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.js", "dist/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.js",