fix(Alibaba Cloud Chat Model Node): Add credential-level url field for AI gateway compatibility (#28697)

This commit is contained in:
Dawid Myslak 2026-04-20 21:40:12 +02:00 committed by GitHub
parent d14f2546a1
commit dd6c28c6d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 62 additions and 67 deletions

View file

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

View file

@ -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<SupplyData> {
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,

View file

@ -1,28 +1,8 @@
const REGION_BASE_HOSTS: Record<string, string> = {
export const REGION_BASE_HOSTS: Record<string, string> = {
'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';

View file

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

View file

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

View file

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