mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
8fa89ee02f
commit
022f4a6477
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user