diff --git a/packages/@n8n/nodes-langchain/credentials/AlibabaCloudApi.credentials.ts b/packages/@n8n/nodes-langchain/credentials/AlibabaCloudApi.credentials.ts index 3cb05af7ca7..c18d067958d 100644 --- a/packages/@n8n/nodes-langchain/credentials/AlibabaCloudApi.credentials.ts +++ b/packages/@n8n/nodes-langchain/credentials/AlibabaCloudApi.credentials.ts @@ -5,7 +5,10 @@ import type { INodeProperties, } from 'n8n-workflow'; -import { BASE_URL_EXPRESSION } from '../nodes/llms/LmChatAlibabaCloud/alibaba-cloud-base-url'; +import { + COMPATIBLE_MODE_SUFFIX, + REGION_BASE_HOSTS, +} from '../nodes/llms/LmChatAlibabaCloud/alibaba-cloud-base-url'; export class AlibabaCloudApi implements ICredentialType { name = 'alibabaCloudApi'; @@ -66,6 +69,12 @@ export class AlibabaCloudApi implements ICredentialType { description: 'The Workspace ID required for the Germany (Frankfurt) region. Find it in the Model Studio console under the Germany region settings.', }, + { + displayName: 'Base URL', + name: 'url', + type: 'hidden', + default: `={{ (() => { const hosts = ${JSON.stringify(REGION_BASE_HOSTS)}; const region = $self.region; if (region === "eu-central-1") { return "https://" + $self.workspaceId + ".eu-central-1.maas.aliyuncs.com"; } return hosts[region] || hosts["ap-southeast-1"]; })() }}`, + }, ]; authenticate: IAuthenticateGeneric = { @@ -79,8 +88,8 @@ export class AlibabaCloudApi implements ICredentialType { test: ICredentialTestRequest = { request: { - baseURL: BASE_URL_EXPRESSION, - url: '/models', + baseURL: '={{ $credentials.url }}', + url: `${COMPATIBLE_MODE_SUFFIX}/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 index 8c86e054223..abe436c4c3a 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/LmChatAlibabaCloud.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/LmChatAlibabaCloud.node.ts @@ -14,7 +14,7 @@ import { type SupplyData, } from 'n8n-workflow'; -import { BASE_URL_EXPRESSION, getBaseUrl } from './alibaba-cloud-base-url'; +import { COMPATIBLE_MODE_SUFFIX } from './alibaba-cloud-base-url'; import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; export class LmChatAlibabaCloud implements INodeType { @@ -57,7 +57,7 @@ export class LmChatAlibabaCloud implements INodeType { ], requestDefaults: { ignoreHttpStatusErrors: true, - baseURL: BASE_URL_EXPRESSION, + baseURL: `={{ $credentials?.url + "${COMPATIBLE_MODE_SUFFIX}" }}`, }, properties: [ getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), @@ -213,6 +213,7 @@ export class LmChatAlibabaCloud implements INodeType { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { const credentials = await this.getCredentials<{ apiKey: string; + url: string; region: string; workspaceId?: string; }>('alibabaCloudApi'); @@ -237,7 +238,7 @@ export class LmChatAlibabaCloud implements INodeType { ); } - const baseURL = getBaseUrl(credentials.region, credentials.workspaceId); + const baseURL = credentials.url + COMPATIBLE_MODE_SUFFIX; const timeout = options.timeout; const configuration: ClientOptions = { baseURL, 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 index b12efb3e96a..cf0038de64d 100644 --- 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 @@ -1,28 +1,8 @@ -const REGION_BASE_HOSTS: Record = { +export const REGION_BASE_HOSTS: Record = { 'ap-southeast-1': 'https://dashscope-intl.aliyuncs.com', 'us-east-1': 'https://dashscope-us.aliyuncs.com', 'cn-beijing': 'https://dashscope.aliyuncs.com', 'cn-hongkong': 'https://cn-hongkong.dashscope.aliyuncs.com', }; -const COMPATIBLE_MODE_SUFFIX = '/compatible-mode/v1'; - -export function getBaseHost(region: string, workspaceId?: string): string { - if (region === 'eu-central-1') { - return `https://${workspaceId}.eu-central-1.maas.aliyuncs.com`; - } - return REGION_BASE_HOSTS[region] ?? REGION_BASE_HOSTS['ap-southeast-1']; -} - -export function getBaseUrl(region: string, workspaceId?: string): string { - return getBaseHost(region, workspaceId) + COMPATIBLE_MODE_SUFFIX; -} - -export const BASE_URL_EXPRESSION = `={{ (() => { - const hosts = ${JSON.stringify(REGION_BASE_HOSTS)}; - const region = $credentials.region; - if (region === 'eu-central-1') { - return 'https://' + $credentials.workspaceId + '.eu-central-1.maas.aliyuncs.com' + '${COMPATIBLE_MODE_SUFFIX}'; - } - return (hosts[region] || hosts['ap-southeast-1']) + '${COMPATIBLE_MODE_SUFFIX}'; -})() }}`; +export const COMPATIBLE_MODE_SUFFIX = '/compatible-mode/v1'; 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 index 843ce1ee6dc..69f43a11557 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/test/LmChatAlibabaCloud.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAlibabaCloud/test/LmChatAlibabaCloud.test.ts @@ -38,6 +38,7 @@ describe('LmChatAlibabaCloud', () => { ctx.getCredentials = jest.fn().mockResolvedValue({ apiKey: 'test-dashscope-key', region: 'ap-southeast-1', + url: 'https://dashscope-intl.aliyuncs.com', }); ctx.getNode = jest.fn().mockReturnValue(nodeDef); ctx.getNodeParameter = jest.fn().mockImplementation((paramName: string) => { @@ -199,6 +200,7 @@ describe('LmChatAlibabaCloud', () => { ctx.getCredentials = jest.fn().mockResolvedValue({ apiKey: 'test-key', region: 'us-east-1', + url: 'https://dashscope-us.aliyuncs.com', }); await node.supplyData.call(ctx, 0); @@ -218,6 +220,7 @@ describe('LmChatAlibabaCloud', () => { apiKey: 'test-key', region: 'eu-central-1', workspaceId: 'ws-abc123', + url: 'https://ws-abc123.eu-central-1.maas.aliyuncs.com', }); await node.supplyData.call(ctx, 0); @@ -236,6 +239,7 @@ describe('LmChatAlibabaCloud', () => { ctx.getCredentials = jest.fn().mockResolvedValue({ apiKey: 'test-key', region: 'cn-beijing', + url: 'https://dashscope.aliyuncs.com', }); await node.supplyData.call(ctx, 0); @@ -254,6 +258,7 @@ describe('LmChatAlibabaCloud', () => { ctx.getCredentials = jest.fn().mockResolvedValue({ apiKey: 'test-key', region: 'cn-hongkong', + url: 'https://cn-hongkong.dashscope.aliyuncs.com', }); await node.supplyData.call(ctx, 0); @@ -267,11 +272,31 @@ describe('LmChatAlibabaCloud', () => { ); }); + it('should use gateway URL when provided via credentials', async () => { + const ctx = setupMockContext(); + ctx.getCredentials = jest.fn().mockResolvedValue({ + apiKey: 'gateway-jwt-token', + url: 'https://gateway.example.com/v1/gateway/alibaba', + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'gateway-jwt-token', + configuration: expect.objectContaining({ + baseURL: 'https://gateway.example.com/v1/gateway/alibaba/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', + url: 'https://undefined.eu-central-1.maas.aliyuncs.com', }); await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Workspace ID'); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/AlibabaCloud/test/transport.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/AlibabaCloud/test/transport.test.ts index 29f9766817c..7cd3b502152 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/AlibabaCloud/test/transport.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/AlibabaCloud/test/transport.test.ts @@ -2,7 +2,7 @@ import { mockDeep } from 'jest-mock-extended'; import type { IExecuteFunctions } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; -import { apiRequest, getBaseUrl, pollTaskResult } from '../transport'; +import { apiRequest, pollTaskResult } from '../transport'; jest.mock('n8n-workflow', () => { const actual = jest.requireActual('n8n-workflow'); @@ -19,7 +19,7 @@ describe('AlicloudModelStudio Transport', () => { mockExecuteFunctions = mockDeep(); mockExecuteFunctions.getCredentials.mockResolvedValue({ apiKey: 'test-key', - region: 'ap-southeast-1', + url: 'https://dashscope-intl.aliyuncs.com', }); mockExecuteFunctions.getNode.mockReturnValue({ id: 'test-node-id', @@ -92,7 +92,7 @@ describe('AlicloudModelStudio Transport', () => { it('should resolve US (Virginia) region to correct base URL', async () => { mockExecuteFunctions.getCredentials.mockResolvedValue({ apiKey: 'test-key', - region: 'us-east-1', + url: 'https://dashscope-us.aliyuncs.com', }); mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue({}); @@ -109,8 +109,7 @@ describe('AlicloudModelStudio Transport', () => { it('should resolve EU (Frankfurt) region with workspaceId to correct base URL', async () => { mockExecuteFunctions.getCredentials.mockResolvedValue({ apiKey: 'test-key', - region: 'eu-central-1', - workspaceId: 'ws123', + url: 'https://ws123.eu-central-1.maas.aliyuncs.com', }); mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue({}); @@ -123,31 +122,23 @@ describe('AlicloudModelStudio Transport', () => { }), ); }); - }); - describe('getBaseUrl', () => { - it('should default to Singapore when no region is provided', () => { - expect(getBaseUrl({})).toBe('https://dashscope-intl.aliyuncs.com'); - }); + it('should use gateway URL when provided via credentials', async () => { + mockExecuteFunctions.getCredentials.mockResolvedValue({ + apiKey: 'gateway-jwt-token', + url: 'https://gateway.example.com/v1/gateway/alibaba', + }); + mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue({}); - it.each([ - ['ap-southeast-1', 'https://dashscope-intl.aliyuncs.com'], - ['us-east-1', 'https://dashscope-us.aliyuncs.com'], - ['cn-beijing', 'https://dashscope.aliyuncs.com'], - ['cn-hongkong', 'https://cn-hongkong.dashscope.aliyuncs.com'], - ])('should return correct URL for region %s', (region, expectedUrl) => { - expect(getBaseUrl({ region })).toBe(expectedUrl); - }); + await apiRequest.call(mockExecuteFunctions, 'GET', '/api/v1/tasks/789'); - it('should construct Frankfurt URL with workspaceId', () => { - expect(getBaseUrl({ region: 'eu-central-1', workspaceId: 'ws123' })).toBe( - 'https://ws123.eu-central-1.maas.aliyuncs.com', + expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'alibabaCloudApi', + expect.objectContaining({ + url: 'https://gateway.example.com/v1/gateway/alibaba/api/v1/tasks/789', + }), ); }); - - it('should throw when eu-central-1 is selected without workspaceId', () => { - expect(() => getBaseUrl({ region: 'eu-central-1' })).toThrow('Workspace ID'); - }); }); describe('pollTaskResult', () => { @@ -158,7 +149,7 @@ describe('AlicloudModelStudio Transport', () => { }; mockExecuteFunctions.getCredentials.mockResolvedValue({ apiKey: 'test-key', - region: 'ap-southeast-1', + url: 'https://dashscope-intl.aliyuncs.com', }); mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue( succeededResponse, diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/AlibabaCloud/transport/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/AlibabaCloud/transport/index.ts index a9c39117330..30808f01b15 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/AlibabaCloud/transport/index.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/AlibabaCloud/transport/index.ts @@ -4,9 +4,7 @@ import type { IHttpRequestMethods, ILoadOptionsFunctions, } from 'n8n-workflow'; -import { NodeOperationError, UserError, sleep } from 'n8n-workflow'; - -import { getBaseHost } from '../../../llms/LmChatAlibabaCloud/alibaba-cloud-base-url'; +import { NodeOperationError, sleep } from 'n8n-workflow'; type RequestParameters = { headers?: IDataObject; @@ -15,14 +13,6 @@ type RequestParameters = { option?: IDataObject; }; -export function getBaseUrl(credentials: IDataObject): string { - const region = (credentials.region as string) || 'ap-southeast-1'; - if (region === 'eu-central-1' && !credentials.workspaceId) { - throw new UserError('Workspace ID is required for the Germany (Frankfurt) region'); - } - return getBaseHost(region, credentials.workspaceId as string); -} - const TERMINAL_STATUSES = ['SUCCEEDED', 'FAILED', 'CANCELED']; const DEFAULT_POLL_INTERVAL_MS = 15_000; const MAX_POLL_ATTEMPTS = 60; // ~15 min with 15s interval @@ -37,8 +27,7 @@ export async function apiRequest( const credentials = await this.getCredentials('alibabaCloudApi'); - const baseUrl = getBaseUrl(credentials as IDataObject); - const uri = `${baseUrl}${endpoint}`; + const uri = `${credentials.url as string}${endpoint}`; const headers = parameters?.headers ?? {}; const options = {