diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts index a38aacb43e1..84e71ffc9d2 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts @@ -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. diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/__tests__/LmChatAzureOpenAi.node.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/__tests__/LmChatAzureOpenAi.node.test.ts index 71c99e0bc03..57be465f9d5 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/__tests__/LmChatAzureOpenAi.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/__tests__/LmChatAzureOpenAi.node.test.ts @@ -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', + }); + }); + }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/methods/searchModels.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/methods/searchModels.test.ts index a192b4885ab..bbbc033e9c1 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/methods/searchModels.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/methods/searchModels.test.ts @@ -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 () => { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/methods/searchModels.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/methods/searchModels.ts index bead250f0b0..624821de03a 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/methods/searchModels.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/methods/searchModels.ts @@ -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( '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(), diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/properties.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/properties.ts index 27f6b5279be..3bac985db85 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/properties.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/properties.ts @@ -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', diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/extract-value.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/extract-value.test.ts index 8bf4b329217..e6de9db4003 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/extract-value.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/extract-value.test.ts @@ -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',