diff --git a/packages/@n8n/api-types/src/dto/ai/ai-gateway-config-response.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-gateway-config-response.dto.ts index a062ef50e73..642f1064295 100644 --- a/packages/@n8n/api-types/src/dto/ai/ai-gateway-config-response.dto.ts +++ b/packages/@n8n/api-types/src/dto/ai/ai-gateway-config-response.dto.ts @@ -19,4 +19,5 @@ export class AiGatewayConfigDto extends Z.class({ credentialTypes: z.array(z.string()), providerConfig: z.record(z.object(aiGatewayProviderConfigEntryShape)), supportedActions: z.record(z.record(z.array(z.string()))).optional(), + minNodeTypeVersion: z.record(z.number()).optional(), }) {} diff --git a/packages/frontend/editor-ui/src/app/composables/useAiGateway.ts b/packages/frontend/editor-ui/src/app/composables/useAiGateway.ts index 7d76c3f514c..ca156ff873c 100644 --- a/packages/frontend/editor-ui/src/app/composables/useAiGateway.ts +++ b/packages/frontend/editor-ui/src/app/composables/useAiGateway.ts @@ -30,6 +30,9 @@ export function useAiGateway() { operation: string, ): boolean => aiGatewayStore.isActionSupported(nodeName, resource, operation); + const isNodeTypeVersionSupported = (nodeName: string, typeVersion: number): boolean => + aiGatewayStore.isNodeTypeVersionSupported(nodeName, typeVersion); + async function fetchConfig(): Promise { if (!isEnabled.value) return; await aiGatewayStore.fetchConfig(); @@ -48,6 +51,7 @@ export function useAiGateway() { fetchWallet, isCredentialTypeSupported, isActionSupported, + isNodeTypeVersionSupported, saveAfterToggle, }; } diff --git a/packages/frontend/editor-ui/src/app/stores/aiGateway.store.test.ts b/packages/frontend/editor-ui/src/app/stores/aiGateway.store.test.ts index b37f5a8d0d2..ff5a9390001 100644 --- a/packages/frontend/editor-ui/src/app/stores/aiGateway.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/aiGateway.store.test.ts @@ -371,4 +371,64 @@ describe('aiGateway.store', () => { }); }); }); + + describe('isNodeTypeVersionSupported()', () => { + const CONFIG_WITH_VERSION_REQ = { + ...MOCK_CONFIG, + nodes: [...MOCK_CONFIG.nodes, 'some-package.SomeNode'], + credentialTypes: [...MOCK_CONFIG.credentialTypes, 'someApi'], + minNodeTypeVersion: { 'some-package.SomeNode': 1.1 }, + }; + + it('should return true when typeVersion meets the minimum', async () => { + mockGetGatewayConfig.mockResolvedValue(CONFIG_WITH_VERSION_REQ); + const store = useAiGatewayStore(); + await store.fetchConfig(); + + expect(store.isNodeTypeVersionSupported('some-package.SomeNode', 1.1)).toBe(true); + }); + + it('should return true when typeVersion exceeds the minimum', async () => { + mockGetGatewayConfig.mockResolvedValue(CONFIG_WITH_VERSION_REQ); + const store = useAiGatewayStore(); + await store.fetchConfig(); + + expect(store.isNodeTypeVersionSupported('some-package.SomeNode', 2)).toBe(true); + }); + + it('should return false when typeVersion is below the minimum', async () => { + mockGetGatewayConfig.mockResolvedValue(CONFIG_WITH_VERSION_REQ); + const store = useAiGatewayStore(); + await store.fetchConfig(); + + expect(store.isNodeTypeVersionSupported('some-package.SomeNode', 1.0)).toBe(false); + }); + + it('should return true when no minNodeTypeVersion entry exists for the node (no version gate)', async () => { + mockGetGatewayConfig.mockResolvedValue(CONFIG_WITH_VERSION_REQ); + const store = useAiGatewayStore(); + await store.fetchConfig(); + + expect( + store.isNodeTypeVersionSupported('@n8n/n8n-nodes-langchain.lmChatGoogleGemini', 1), + ).toBe(true); + }); + + it('should return true for a node with no version requirement (even if unknown)', async () => { + mockGetGatewayConfig.mockResolvedValue(CONFIG_WITH_VERSION_REQ); + const store = useAiGatewayStore(); + await store.fetchConfig(); + + // No minNodeTypeVersion entry = no version gate; node support is a separate concern + expect(store.isNodeTypeVersionSupported('unknown-package.UnknownNode', 2)).toBe(true); + }); + + it('should return true when config has not been loaded (no version gate defined)', () => { + const store = useAiGatewayStore(); + + // config not loaded → no minNodeTypeVersion entry → no version gate → pass through + // node support when config is unloaded is handled by isCredentialTypeSupported / isNodeSupported + expect(store.isNodeTypeVersionSupported('some-package.SomeNode', 1.1)).toBe(true); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/app/stores/aiGateway.store.ts b/packages/frontend/editor-ui/src/app/stores/aiGateway.store.ts index 4742819b65c..51cb545fbdf 100644 --- a/packages/frontend/editor-ui/src/app/stores/aiGateway.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/aiGateway.store.ts @@ -104,6 +104,12 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => { return ops.includes(operation); } + function isNodeTypeVersionSupported(nodeName: string, typeVersion: number): boolean { + const minVersion = config.value?.minNodeTypeVersion?.[nodeName]; + if (minVersion === undefined) return true; + return typeVersion >= minVersion; + } + return { config, balance, @@ -116,6 +122,7 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => { fetchUsage, fetchMoreUsage, isNodeSupported, + isNodeTypeVersionSupported, isCredentialTypeSupported, isActionSupported, }; diff --git a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts index d7a2b782e3c..59bc100fcdf 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts @@ -1110,6 +1110,7 @@ describe('NodeCredentials', () => { vi.mocked(useAiGateway).mockReturnValue({ isEnabled: computed(() => true), isCredentialTypeSupported: vi.fn((credType: string) => credType === 'googlePalmApi'), + isNodeTypeVersionSupported: vi.fn(() => true), isActionSupported: vi.fn(() => true), balance: computed(() => undefined), budget: computed(() => undefined), @@ -1183,6 +1184,7 @@ describe('NodeCredentials', () => { vi.mocked(useAiGateway).mockReturnValue({ isEnabled: computed(() => true), isCredentialTypeSupported: vi.fn(() => false), + isNodeTypeVersionSupported: vi.fn(() => true), isActionSupported: vi.fn(() => true), balance: computed(() => undefined), budget: computed(() => undefined), @@ -1211,6 +1213,7 @@ describe('NodeCredentials', () => { vi.mocked(useAiGateway).mockReturnValue({ isEnabled: computed(() => false), isCredentialTypeSupported: vi.fn(() => false), + isNodeTypeVersionSupported: vi.fn(() => true), isActionSupported: vi.fn(() => true), balance: computed(() => undefined), budget: computed(() => undefined), @@ -1264,6 +1267,170 @@ describe('NodeCredentials', () => { }); }); + describe('minNodeTypeVersion gate', () => { + const versionedNodeType: INodeTypeDescription = { + displayName: 'Some Node', + name: 'some-package.SomeNode', + group: ['transform'], + version: 1, + description: '', + defaults: { name: 'Some Node' }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + credentials: [{ name: 'someApi', required: true }], + properties: [], + }; + + const someApiCredType: ICredentialType = { + name: 'someApi', + displayName: 'Some API', + properties: [{ displayName: 'API Key', name: 'apiKey', type: 'string', default: '' }], + }; + + beforeEach(() => { + const nodeTypesStore = mockedStore(useNodeTypesStore); + nodeTypesStore.setNodeTypes([versionedNodeType]); + credentialsStore.state.credentialTypes = { someApi: someApiCredType }; + }); + + it('should hide AiGatewaySelector when typeVersion is below the minimum', () => { + vi.mocked(useAiGateway).mockReturnValue({ + isEnabled: computed(() => true), + isCredentialTypeSupported: vi.fn((credType: string) => credType === 'someApi'), + isNodeTypeVersionSupported: vi.fn(() => false), + isActionSupported: vi.fn(() => true), + balance: computed(() => undefined), + budget: computed(() => undefined), + fetchConfig: vi.fn().mockResolvedValue(undefined), + fetchWallet: vi.fn().mockResolvedValue(undefined), + saveAfterToggle: vi.fn().mockResolvedValue(undefined), + fetchError: computed(() => null), + }); + + const node: INodeUi = { + id: 'node-some', + name: 'Some Node', + type: 'some-package.SomeNode', + typeVersion: 1.0, + position: [0, 0], + parameters: {}, + credentials: {}, + }; + ndvStore.activeNode = node; + + renderComponent({ + props: { node, overrideCredType: 'someApi' }, + global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } }, + }); + + expect(screen.queryByTestId('ai-gateway-toggle')).not.toBeInTheDocument(); + }); + + it('should show AiGatewaySelector when typeVersion meets the minimum', () => { + vi.mocked(useAiGateway).mockReturnValue({ + isEnabled: computed(() => true), + isCredentialTypeSupported: vi.fn((credType: string) => credType === 'someApi'), + isNodeTypeVersionSupported: vi.fn(() => true), + isActionSupported: vi.fn(() => true), + balance: computed(() => undefined), + budget: computed(() => undefined), + fetchConfig: vi.fn().mockResolvedValue(undefined), + fetchWallet: vi.fn().mockResolvedValue(undefined), + saveAfterToggle: vi.fn().mockResolvedValue(undefined), + fetchError: computed(() => null), + }); + + const node: INodeUi = { + id: 'node-some', + name: 'Some Node', + type: 'some-package.SomeNode', + typeVersion: 1.1, + position: [0, 0], + parameters: {}, + credentials: {}, + }; + ndvStore.activeNode = node; + + renderComponent({ + props: { node, overrideCredType: 'someApi' }, + global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } }, + }); + + expect(screen.getByTestId('ai-gateway-toggle')).toBeInTheDocument(); + }); + + it('should emit credentialSelected clearing __aiGatewayManaged when version gate fails on mount', () => { + vi.mocked(useAiGateway).mockReturnValue({ + isEnabled: computed(() => true), + isCredentialTypeSupported: vi.fn((credType: string) => credType === 'someApi'), + isNodeTypeVersionSupported: vi.fn(() => false), + isActionSupported: vi.fn(() => true), + balance: computed(() => undefined), + budget: computed(() => undefined), + fetchConfig: vi.fn().mockResolvedValue(undefined), + fetchWallet: vi.fn().mockResolvedValue(undefined), + saveAfterToggle: vi.fn().mockResolvedValue(undefined), + fetchError: computed(() => null), + }); + + const node: INodeUi = { + id: 'node-some', + name: 'Some Node', + type: 'some-package.SomeNode', + typeVersion: 1.0, + position: [0, 0], + parameters: {}, + credentials: { someApi: { id: null, name: '', __aiGatewayManaged: true } }, + }; + ndvStore.activeNode = node; + + const { emitted } = renderComponent({ + props: { node, overrideCredType: 'someApi' }, + global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } }, + }); + + expect(emitted('credentialSelected')).toBeTruthy(); + const payload = ((emitted('credentialSelected')[0] as unknown[]) ?? [])[0] as { + properties: { credentials: Record }; + }; + // No available credentials in store → entry is deleted, not restored + expect(payload.properties.credentials['someApi']).toBeUndefined(); + }); + + it('should not emit credentialSelected on mount when version gate fails but no managed credential exists', () => { + vi.mocked(useAiGateway).mockReturnValue({ + isEnabled: computed(() => true), + isCredentialTypeSupported: vi.fn((credType: string) => credType === 'someApi'), + isNodeTypeVersionSupported: vi.fn(() => false), + isActionSupported: vi.fn(() => true), + balance: computed(() => undefined), + budget: computed(() => undefined), + fetchConfig: vi.fn().mockResolvedValue(undefined), + fetchWallet: vi.fn().mockResolvedValue(undefined), + saveAfterToggle: vi.fn().mockResolvedValue(undefined), + fetchError: computed(() => null), + }); + + const node: INodeUi = { + id: 'node-some', + name: 'Some Node', + type: 'some-package.SomeNode', + typeVersion: 1.0, + position: [0, 0], + parameters: {}, + credentials: {}, + }; + ndvStore.activeNode = node; + + const { emitted } = renderComponent({ + props: { node, overrideCredType: 'someApi' }, + global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } }, + }); + + expect(emitted('credentialSelected')).toBeFalsy(); + }); + }); + it('should emit credentialSelected with __aiGatewayManaged:true when toggled ON', async () => { ndvStore.activeNode = googleAiNode; diff --git a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue index b62138a59f3..72cf034f79c 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue @@ -226,6 +226,18 @@ watch( credentialTypesNodeDescriptionDisplayed, (types) => { if (props.skipAutoSelect) return; + + if ( + aiGateway.isEnabled.value && + !aiGateway.isNodeTypeVersionSupported(node.value.type, node.value.typeVersion) + ) { + for (const { type } of types) { + if (selected.value[type.name]?.__aiGatewayManaged) { + onAiGatewaySelector(type.name, false, false); + } + } + } + if (types.length === 0 || !isEmpty(selected.value)) return; const allOptions = types.map((type) => type.options).flat(); @@ -234,7 +246,10 @@ watch( // No credentials configured — auto-enable AI Gateway for supported types if (aiGateway.isEnabled.value) { for (const { type } of types) { - if (aiGateway.isCredentialTypeSupported(type.name)) { + if ( + aiGateway.isCredentialTypeSupported(type.name) && + aiGateway.isNodeTypeVersionSupported(node.value.type, node.value.typeVersion) + ) { onAiGatewaySelector(type.name, true, false); } } @@ -565,6 +580,7 @@ function isAiGatewayManagedCredentials(credentialType: string): boolean { function showAiGatewaySelector(credentialType: string): boolean { if (!aiGateway.isEnabled.value) return false; + if (!aiGateway.isNodeTypeVersionSupported(node.value.type, node.value.typeVersion)) return false; if (isAiGatewayManagedCredentials(credentialType)) return true; if (!aiGateway.isCredentialTypeSupported(credentialType)) return false; return true; diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputList.test.ts b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputList.test.ts index 886a8ed2355..7b4599276d4 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputList.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputList.test.ts @@ -1746,6 +1746,7 @@ describe('ParameterInputList', () => { vi.mocked(useAiGateway).mockReturnValue({ isEnabled: { value: true } as never, isCredentialTypeSupported: vi.fn(() => true), + isNodeTypeVersionSupported: vi.fn(() => true), isActionSupported: vi.fn(() => false), balance: { value: undefined } as never, budget: { value: undefined } as never, @@ -1779,6 +1780,7 @@ describe('ParameterInputList', () => { vi.mocked(useAiGateway).mockReturnValue({ isEnabled: { value: true } as never, isCredentialTypeSupported: vi.fn(() => true), + isNodeTypeVersionSupported: vi.fn(() => true), isActionSupported: vi.fn(() => true), balance: { value: undefined } as never, budget: { value: undefined } as never, @@ -1857,6 +1859,7 @@ describe('ParameterInputList', () => { vi.mocked(useAiGateway).mockReturnValue({ isEnabled: { value: true } as never, isCredentialTypeSupported: vi.fn(() => true), + isNodeTypeVersionSupported: vi.fn(() => true), isActionSupported: vi.fn(() => false), balance: { value: undefined } as never, budget: { value: undefined } as never, @@ -1889,6 +1892,7 @@ describe('ParameterInputList', () => { vi.mocked(useAiGateway).mockReturnValue({ isEnabled: { value: true } as never, isCredentialTypeSupported: vi.fn(() => true), + isNodeTypeVersionSupported: vi.fn(() => true), isActionSupported: vi.fn(() => true), balance: { value: undefined } as never, budget: { value: undefined } as never, diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts index 856f6ddb9fd..1504c7e1336 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts @@ -38,11 +38,28 @@ import { AI_CATEGORY_ROOT_NODES, AI_SUBCATEGORY, } from '@/app/constants'; +import { useAiGatewayStore } from '@/app/stores/aiGateway.store'; +import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; +import { useSettingsStore } from '@/app/stores/settings.store'; vi.mock('@/app/stores/settings.store', () => ({ useSettingsStore: vi.fn(() => ({ settings: {}, isAskAiEnabled: true })), })); +vi.mock('@/app/stores/aiGateway.store', () => ({ + useAiGatewayStore: vi.fn(() => ({ + isNodeSupported: vi.fn(() => false), + isNodeTypeVersionSupported: vi.fn(() => true), + })), +})); + +vi.mock('@/app/stores/nodeTypes.store', () => ({ + useNodeTypesStore: vi.fn(() => ({ + getNodeVersions: vi.fn(() => []), + communityNodeType: vi.fn(() => null), + })), +})); + describe('NodeCreator - utils', () => { describe('groupItemsInSections', () => { it('should handle multiple sections (with "other" section)', () => { @@ -738,6 +755,61 @@ describe('NodeCreator - utils', () => { }); }); + describe('finalizeItems - Free credits badge (minNodeTypeVersion gate)', () => { + const makeGatewayNode = (name = 'gatewayNode') => mockNodeCreateElement(undefined, { name }); + + beforeEach(() => { + vi.mocked(useSettingsStore).mockReturnValue({ + isAiGatewayEnabled: true, + } as unknown as ReturnType); + vi.mocked(useAiGatewayStore).mockReturnValue({ + isNodeSupported: vi.fn(() => true), + isNodeTypeVersionSupported: vi.fn(() => true), + } as unknown as ReturnType); + vi.mocked(useNodeTypesStore).mockReturnValue({ + getNodeVersions: vi.fn(() => [1, 1.1]), + } as unknown as ReturnType); + }); + + it('should show Free credits badge when latest version meets the minimum', () => { + const [result] = finalizeItems([makeGatewayNode()]) as NodeCreateElement[]; + expect(result.properties.tag).toEqual({ text: expect.any(String), pill: true }); + }); + + it('should suppress Free credits badge when latest version is below the minimum', () => { + vi.mocked(useAiGatewayStore).mockReturnValue({ + isNodeSupported: vi.fn(() => true), + isNodeTypeVersionSupported: vi.fn(() => false), + } as unknown as ReturnType); + + const [result] = finalizeItems([makeGatewayNode()]) as NodeCreateElement[]; + expect(result.properties.tag).toBeUndefined(); + }); + + it('should suppress Free credits badge when gateway is disabled', () => { + vi.mocked(useSettingsStore).mockReturnValue({ + isAiGatewayEnabled: false, + } as unknown as ReturnType); + + const [result] = finalizeItems([makeGatewayNode()]) as NodeCreateElement[]; + expect(result.properties.tag).toBeUndefined(); + }); + + it('should pass the latest (max) version to the version check', () => { + const isNodeTypeVersionSupported = vi.fn(() => true); + vi.mocked(useNodeTypesStore).mockReturnValue({ + getNodeVersions: vi.fn(() => [1, 1.1, 2]), + } as unknown as ReturnType); + vi.mocked(useAiGatewayStore).mockReturnValue({ + isNodeSupported: vi.fn(() => true), + isNodeTypeVersionSupported, + } as unknown as ReturnType); + + finalizeItems([makeGatewayNode('my-node')]); + expect(isNodeTypeVersionSupported).toHaveBeenCalledWith('my-node', 2); + }); + }); + describe('mapToolSubcategoryIcon', () => { it('should return "globe" for AI_CATEGORY_OTHER_TOOLS', () => { expect(mapToolSubcategoryIcon(AI_CATEGORY_OTHER_TOOLS)).toBe('globe'); diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts index 0fa4582072b..4b6b3920a6b 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts @@ -315,10 +315,14 @@ function applyNodeTags(element: INodeCreateElement): INodeCreateElement { useSettingsStore().isAiGatewayEnabled && useAiGatewayStore().isNodeSupported(element.properties.name) ) { - element.properties.tag = { - text: i18n.baseText('generic.freeCredits'), - pill: true, - }; + const versions = useNodeTypesStore().getNodeVersions(element.properties.name); + const latestVersion = versions.length > 0 ? Math.max(...versions) : 1; + if (useAiGatewayStore().isNodeTypeVersionSupported(element.properties.name, latestVersion)) { + element.properties.tag = { + text: i18n.baseText('generic.freeCredits'), + pill: true, + }; + } } return element;