feat(Azure OpenAI Chat Model Node): Add the deployment dropdown (#39643)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Thanasis G 2026-10-05 13:15:57 +00:00 committed by GitHub
parent 8fa89ee02f
commit 022f4a6477
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 203 additions and 11 deletions

View File

@ -41,6 +41,7 @@ export class LmChatAzureOpenAi implements INodeType {
icon: 'file:azure.svg',
group: ['transform'],
version: [1, 1.1],
defaultVersion: 1.1,
description: 'For advanced usage with an AI chain',
defaults: {
name: 'Azure AI Foundry Chat Model',
@ -96,7 +97,10 @@ export class LmChatAzureOpenAi implements INodeType {
'authentication',
itemIndex,
) as AuthenticationType;
const modelName = this.getNodeParameter('model', itemIndex) as string;
// Version 1 stores a plain string; `extractValue` returns it unchanged.
const modelName = this.getNodeParameter('model', itemIndex, '', {
extractValue: true,
}) as string;
const allOptions = this.getNodeParameter('options', itemIndex, {}) as AzureOpenAIOptions;
// Held back from the spread below: both clients take it as `modelKwargs`, and spreading the
// raw JSON string would put an `extraBody` field on the constructor.

View File

@ -1,7 +1,7 @@
import { AzureChatOpenAI, ChatOpenAI } from '@langchain/openai';
import { getProxyAgent, makeN8nLlmFailedAttemptHandler } from '@n8n/ai-utilities';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
import { NodeHelpers, type INode, type ISupplyDataFunctions } from 'n8n-workflow';
import { LmChatAzureOpenAi } from '../LmChatAzureOpenAi.node';
@ -392,4 +392,43 @@ describe('LmChatAzureOpenAi', () => {
await expect(new LmChatAzureOpenAi().supplyData.call(ctx, 0)).rejects.toThrow(message);
});
});
describe('model parameter', () => {
const { description } = new LmChatAzureOpenAi();
// `@n8n/ai-utilities` is mocked, so the connection-hint property is undefined here.
const modelPropertyAt = (typeVersion: number) =>
description.properties.filter(
(p) =>
p?.name === 'model' && NodeHelpers.displayParameter({}, p, { typeVersion }, description),
);
it('keeps the plain text field on version 1', () => {
const shown = modelPropertyAt(1);
expect(shown).toHaveLength(1);
expect(shown[0].type).toBe('string');
});
it('shows a deployment list backed by searchModels on version 1.1', () => {
const shown = modelPropertyAt(1.1);
expect(shown).toHaveLength(1);
expect(shown[0].type).toBe('resourceLocator');
expect(shown[0].modes?.map((m) => m.name)).toEqual(['list', 'id']);
// The list needs a Foundry credential, and new credentials default to classic.
expect(shown[0].default).toEqual({ mode: 'id', value: '' });
expect(shown[0].modes?.[0].typeOptions?.searchListMethod).toBe('searchModels');
expect(description.defaultVersion).toBe(1.1);
});
it('reads the deployment name with extractValue, so both version shapes resolve', async () => {
const ctx = setupMockContext('azureOpenAiApi', apiKeyCredential);
await new LmChatAzureOpenAi().supplyData.call(ctx, 0);
expect(ctx.getNodeParameter).toHaveBeenCalledWith('model', 0, '', { extractValue: true });
expect(vi.mocked(AzureChatOpenAI).mock.calls[0][0]).toMatchObject({
model: 'gpt-4o',
azureOpenAIApiDeploymentName: 'gpt-4o',
});
});
});
});

View File

@ -49,7 +49,7 @@ describe('LmChatAzureOpenAi -> searchModels', () => {
vi.clearAllMocks();
});
it('skips discovery for a classic api-key credential (no data-plane deployments API)', async () => {
it('rejects a classic api-key credential with a clear error (no data-plane deployments API)', async () => {
ctx.getNodeParameter = vi
.fn()
.mockReturnValueOnce(AuthenticationType.ApiKey)
@ -59,24 +59,62 @@ describe('LmChatAzureOpenAi -> searchModels', () => {
resourceName: 'my-resource',
});
const result = await searchModels.call(ctx);
await expect(searchModels.call(ctx)).rejects.toThrow(
'Only an Azure AI Foundry credential can list deployments',
);
expect(listAzureOpenAiModelsSpy).not.toHaveBeenCalled();
expect(result).toEqual({ results: [] });
});
it('skips discovery for a classic Entra ID credential', async () => {
it('rejects a classic Entra ID credential with a clear error', async () => {
ctx.getNodeParameter = vi
.fn()
.mockReturnValueOnce(AuthenticationType.EntraOAuth2)
.mockReturnValueOnce('');
ctx.getCredentials = vi.fn().mockResolvedValue({ resourceName: 'my-resource' });
const result = await searchModels.call(ctx);
await expect(searchModels.call(ctx)).rejects.toThrow(
'Only an Azure AI Foundry credential can list deployments',
);
expect(listAzureOpenAiModelsSpy).not.toHaveBeenCalled();
expect(n8nOAuth2TokenCredentialSpy).not.toHaveBeenCalled();
expect(result).toEqual({ results: [] });
});
it('asks for the project before it lists deployments', async () => {
ctx.getNodeParameter = vi
.fn()
.mockReturnValueOnce(AuthenticationType.ApiKey)
.mockReturnValueOnce('');
ctx.getCredentials = vi.fn().mockResolvedValue({
apiKey: 'secret-key',
endpointType: 'foundry',
foundryEndpoint: 'https://my-resource.services.ai.azure.com/openai/v1',
});
await expect(searchModels.call(ctx)).rejects.toThrow(
'Enter the Project to list its deployments',
);
expect(listAzureOpenAiModelsSpy).not.toHaveBeenCalled();
});
it('asks for the project before it requests an Entra token', async () => {
ctx.getNodeParameter = vi
.fn()
.mockReturnValueOnce(AuthenticationType.EntraOAuth2)
.mockReturnValueOnce('');
ctx.getCredentials = vi.fn().mockResolvedValue({
endpointType: 'foundry',
foundryEndpoint: 'https://my-resource.services.ai.azure.com/openai/v1',
});
await expect(searchModels.call(ctx)).rejects.toThrow(
'Enter the Project to list its deployments',
);
expect(getTokenSpy).not.toHaveBeenCalled();
expect(listAzureOpenAiModelsSpy).not.toHaveBeenCalled();
});
it('derives the base URL from the Foundry endpoint origin for an api-key credential', async () => {

View File

@ -23,6 +23,22 @@ function resolveFoundryBaseURL(node: INode, foundryEndpoint?: string): string {
return new URL(requireFoundryEndpoint(node, foundryEndpoint)).origin;
}
// The dropdown shows only the message, so it also says how to fix the problem.
function classicCredentialError(node: INode): NodeOperationError {
return new NodeOperationError(
node,
"Only an Azure AI Foundry credential can list deployments. Select By ID and enter the deployment name, or set the credential's Endpoint Type to Azure AI Foundry.",
);
}
// Without this, the list call reports `Project "" was not found`.
function missingProjectError(node: INode): NodeOperationError {
return new NodeOperationError(
node,
'Enter the Project to list its deployments. Or select By ID and enter the deployment name.',
);
}
export async function searchModels(
this: ILoadOptionsFunctions,
filter?: string,
@ -42,18 +58,21 @@ export async function searchModels(
'azureOpenAiApi',
);
if (credential.endpointType !== 'foundry') {
return { results: [] };
throw classicCredentialError(this.getNode());
}
baseURL = resolveFoundryBaseURL(this.getNode(), credential.foundryEndpoint);
if (!project) throw missingProjectError(this.getNode());
headers = { 'api-key': credential.apiKey };
} else {
const credential = await this.getCredentials<AzureEntraCognitiveServicesOAuth2ApiCredential>(
'azureEntraCognitiveServicesOAuth2Api',
);
if (credential.endpointType !== 'foundry') {
return { results: [] };
throw classicCredentialError(this.getNode());
}
baseURL = resolveFoundryBaseURL(this.getNode(), credential.foundryEndpoint);
// Checked before the token request, so an empty Project does not cost a call to Entra.
if (!project) throw missingProjectError(this.getNode());
// Mints a token for the Foundry audience, which this call needs.
const token = await new N8nOAuth2TokenCredential(
this.getNode(),

View File

@ -43,6 +43,11 @@ export const properties: INodeProperties[] = [
description: 'The name of the model(deployment) to use (e.g., gpt-4, gpt-35-turbo)',
required: true,
default: '',
displayOptions: {
show: {
'@version': [1],
},
},
},
{
displayName: 'Project',
@ -52,6 +57,42 @@ export const properties: INodeProperties[] = [
'The Azure AI Foundry project that owns the deployment. Required for an Azure AI Foundry resource; leave empty for a classic Azure OpenAI resource.',
default: '',
},
{
displayName: 'Model (Deployment)',
name: 'model',
type: 'resourceLocator',
default: { mode: 'id', value: '' },
required: true,
builderHint: {
propertyHint:
'Set the project parameter before you list deployments. The list needs an Azure AI Foundry credential. With a classic credential, use the id mode and enter the deployment name.',
},
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
placeholder: 'Select a deployment...',
typeOptions: {
searchListMethod: 'searchModels',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
placeholder: 'e.g. gpt-4o',
},
],
description:
'The deployment to use. Choose from the list (Azure AI Foundry credentials only), or enter the deployment name.',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
},
{
displayName: 'Use Responses API',
name: 'responsesApiEnabled',

View File

@ -1,8 +1,59 @@
import type { INode, INodeType } from 'n8n-workflow';
import type { INode, INodeProperties, INodeType } from 'n8n-workflow';
import { extractValue } from '../extract-value';
const node: INode = {
id: 'node-id',
name: 'Test Node',
type: 'test.node',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const nodeTypeWith = (property: INodeProperties): INodeType => ({
description: {
displayName: 'Test Node',
name: 'testNode',
group: ['transform'],
version: 1,
description: 'Test node',
defaults: { name: 'Test Node' },
inputs: [],
outputs: [],
properties: [property],
},
});
describe('extractValue', () => {
it('returns a plain value unchanged when the property has no extractValue', () => {
const nodeType = nodeTypeWith({
displayName: 'Field',
name: 'field',
type: 'string',
default: '',
});
expect(extractValue('some-value', 'field', node, nodeType)).toBe('some-value');
});
it('returns the inner value of a resource locator whose mode has no extractValue', () => {
const nodeType = nodeTypeWith({
displayName: 'Field',
name: 'field',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
modes: [
{ displayName: 'List', name: 'list', type: 'list' },
{ displayName: 'ID', name: 'id', type: 'string' },
],
});
expect(
extractValue({ __rl: true, mode: 'list', value: 'some-value' }, 'field', node, nodeType),
).toBe('some-value');
});
it('extracts resource locator values with regex metadata', () => {
const node: INode = {
id: 'node-id',