From 3955bcafd3b867d6a783d066a3520cfd3aec062e Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Mon, 29 Jun 2026 12:37:03 +0100 Subject: [PATCH] fix(Google Vertex Chat Model Node): Support newer Gemini models and EU/US data residency (#33135) Co-authored-by: Claude Opus 4.8 (1M context) --- .../EmbeddingsGoogleVertex.node.ts | 17 +++- .../test/EmbeddingsGoogleVertex.test.ts | 78 +++++++++++++++++++ .../LmChatGoogleVertex.node.ts | 16 +++- .../test/LmChatGoogleVertex.test.ts | 57 ++++++++++++++ .../test/vertex-location.test.ts | 32 ++++++++ .../llms/gemini-common/vertex-location.ts | 44 +++++++++++ .../credentials/GoogleApi.credentials.ts | 18 +++-- .../test/GoogleApi.credentials.test.ts | 24 ++++++ 8 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/test/EmbeddingsGoogleVertex.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/gemini-common/test/vertex-location.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/gemini-common/vertex-location.ts diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/EmbeddingsGoogleVertex.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/EmbeddingsGoogleVertex.node.ts index defd110f758..c3316721840 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/EmbeddingsGoogleVertex.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/EmbeddingsGoogleVertex.node.ts @@ -11,6 +11,12 @@ import type { SupplyData, } from 'n8n-workflow'; +import { + getVertexEndpoint, + resolveVertexLocation, + vertexLocationField, +} from '../../llms/gemini-common/vertex-location'; + export class EmbeddingsGoogleVertex implements INodeType { methods = { listSearch: { @@ -123,6 +129,7 @@ export class EmbeddingsGoogleVertex implements INodeType { 'The model which will generate the embeddings. Learn more.', default: 'text-embedding-005', }, + vertexLocationField, ], }; @@ -130,7 +137,12 @@ export class EmbeddingsGoogleVertex implements INodeType { const credentials = await this.getCredentials('googleApi'); const privateKey = formatPemBlock(credentials.privateKey as string); const email = (credentials.email as string).trim(); - const region = credentials.region as string; + + // A node-level location overrides the credential region; multi-region + // locations (eu/us) need a dedicated host the SDK doesn't build itself. + const locationOverride = this.getNodeParameter('location', itemIndex, '') as string; + const location = resolveVertexLocation(locationOverride, credentials.region as string); + const endpoint = getVertexEndpoint(location); const modelName = this.getNodeParameter('modelName', itemIndex) as string; @@ -146,7 +158,8 @@ export class EmbeddingsGoogleVertex implements INodeType { private_key: privateKey, }, }, - location: region, + location, + ...(endpoint ? { endpoint } : {}), model: modelName, }); diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/test/EmbeddingsGoogleVertex.test.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/test/EmbeddingsGoogleVertex.test.ts new file mode 100644 index 00000000000..e729133ae24 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/test/EmbeddingsGoogleVertex.test.ts @@ -0,0 +1,78 @@ +import { VertexAIEmbeddings } from '@langchain/google-vertexai'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; +import type { Mocked } from 'vitest'; + +import { EmbeddingsGoogleVertex } from '../EmbeddingsGoogleVertex.node'; + +vi.mock('@langchain/google-vertexai'); +vi.mock('@n8n/ai-utilities', () => ({ + logWrapper: vi.fn((val: unknown) => val), + getConnectionHintNoticeField: vi.fn(() => ({})), +})); +vi.mock('@n8n/utils', () => ({ + formatPemBlock: vi.fn().mockImplementation((key: string) => key), +})); + +const MockedVertexAIEmbeddings = vi.mocked(VertexAIEmbeddings); + +describe('EmbeddingsGoogleVertex - location resolution', () => { + let node: EmbeddingsGoogleVertex; + let mockContext: Mocked; + + const mockNode: INode = { + id: '1', + name: 'Embeddings Google Vertex', + typeVersion: 1, + type: 'n8n-nodes-langchain.embeddingsGoogleVertex', + position: [0, 0], + parameters: {}, + }; + + const setupMockContext = (location: string | undefined) => { + mockContext = createMockExecuteFunction( + {}, + mockNode, + ) as Mocked; + + mockContext.getCredentials = vi.fn().mockResolvedValue({ + privateKey: 'test-private-key', + email: 'test@n8n.io', + region: 'us-central1', + }); + mockContext.getNode = vi.fn().mockReturnValue(mockNode); + mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'modelName') return 'text-embedding-005'; + if (paramName === 'projectId') return 'test-project'; + if (paramName === 'location') return location; + return undefined; + }); + + return mockContext; + }; + + beforeEach(() => { + node = new EmbeddingsGoogleVertex(); + vi.clearAllMocks(); + }); + + it('routes the EU multi-region location through the .rep. data-residency endpoint', async () => { + const mockContext = setupMockContext('eu'); + + await node.supplyData.call(mockContext, 0); + + const callArgs = MockedVertexAIEmbeddings.mock.calls[0][0]; + expect(callArgs.location).toBe('eu'); + expect(callArgs.endpoint).toBe('aiplatform.eu.rep.googleapis.com'); + }); + + it('falls back to the credential region with no endpoint override', async () => { + const mockContext = setupMockContext(''); + + await node.supplyData.call(mockContext, 0); + + const callArgs = MockedVertexAIEmbeddings.mock.calls[0][0]; + expect(callArgs.location).toBe('us-central1'); + expect(callArgs).not.toHaveProperty('endpoint'); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts index f70ac0fea37..9d92ceac61c 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts @@ -21,6 +21,11 @@ import { import { makeErrorFromStatus } from './error-handling'; import { getAdditionalOptions } from '../gemini-common/additional-options'; +import { + getVertexEndpoint, + resolveVertexLocation, + vertexLocationField, +} from '../gemini-common/vertex-location'; export class LmChatGoogleVertex implements INodeType { description: INodeTypeDescription = { @@ -96,6 +101,7 @@ export class LmChatGoogleVertex implements INodeType { 'Default to the latest flagship Gemini on Vertex (gemini-3.1-pro). Use gemini-3.1-flash-lite for cost-efficient builds. Avoid Gemini 2.x, 1.x, and earlier.', }, }, + vertexLocationField, getAdditionalOptions({ supportsThinkingBudget: true }), ], }; @@ -136,7 +142,12 @@ export class LmChatGoogleVertex implements INodeType { const credentials = await this.getCredentials('googleApi'); const privateKey = formatPemBlock(credentials.privateKey as string); const email = (credentials.email as string).trim(); - const region = credentials.region as string; + + // A node-level location overrides the credential region; multi-region + // locations (eu/us) need a dedicated host the SDK doesn't build itself. + const locationOverride = this.getNodeParameter('location', itemIndex, '') as string; + const location = resolveVertexLocation(locationOverride, credentials.region as string); + const endpoint = getVertexEndpoint(location); const modelName = this.getNodeParameter('modelName', itemIndex) as string; @@ -179,7 +190,8 @@ export class LmChatGoogleVertex implements INodeType { private_key: privateKey, }, }, - location: region, + location, + ...(endpoint ? { endpoint } : {}), model: modelName, topK: options.topK, topP: options.topP, diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts index 43005d1210b..69ede61c0e2 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts @@ -98,6 +98,63 @@ describe('LmChatGoogleVertex - Thinking Budget', () => { }); }); + it('uses the node-level location override, with no endpoint override for global', async () => { + const mockContext = setupMockContext(); + + mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'modelName') return 'gemini-3.1-flash-lite'; + if (paramName === 'projectId') return 'test-project'; + if (paramName === 'location') return 'global'; + if (paramName === 'options') return {}; + if (paramName === 'options.safetySettings.values') return null; + return undefined; + }); + + await lmChatGoogleVertex.supplyData.call(mockContext, 0); + + const callArgs = MockedChatVertexAI.mock.calls[0][0]; + expect(callArgs?.location).toBe('global'); + expect(callArgs).not.toHaveProperty('endpoint'); + }); + + it('routes the EU multi-region location through the .rep. data-residency endpoint', async () => { + const mockContext = setupMockContext(); + + mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'modelName') return 'gemini-3.1-flash-lite'; + if (paramName === 'projectId') return 'test-project'; + if (paramName === 'location') return 'eu'; + if (paramName === 'options') return {}; + if (paramName === 'options.safetySettings.values') return null; + return undefined; + }); + + await lmChatGoogleVertex.supplyData.call(mockContext, 0); + + const callArgs = MockedChatVertexAI.mock.calls[0][0]; + expect(callArgs?.location).toBe('eu'); + expect(callArgs?.endpoint).toBe('aiplatform.eu.rep.googleapis.com'); + }); + + it('falls back to the credential region when no location override is set', async () => { + const mockContext = setupMockContext(); + + mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'modelName') return 'gemini-2.5-flash'; + if (paramName === 'projectId') return 'test-project'; + if (paramName === 'location') return ''; + if (paramName === 'options') return {}; + if (paramName === 'options.safetySettings.values') return null; + return undefined; + }); + + await lmChatGoogleVertex.supplyData.call(mockContext, 0); + + const callArgs = MockedChatVertexAI.mock.calls[0][0]; + expect(callArgs?.location).toBe('us-central1'); + expect(callArgs).not.toHaveProperty('endpoint'); + }); + it('should include thinkingBudget in model config when specified', async () => { const mockContext = setupMockContext(); const expectedThinkingBudget = 1024; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/gemini-common/test/vertex-location.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/gemini-common/test/vertex-location.test.ts new file mode 100644 index 00000000000..0f3368f3bb6 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/gemini-common/test/vertex-location.test.ts @@ -0,0 +1,32 @@ +import { getVertexEndpoint, resolveVertexLocation } from '../vertex-location'; + +describe('getVertexEndpoint', () => { + it('returns the .rep. host for the EU multi-region location', () => { + expect(getVertexEndpoint('eu')).toBe('aiplatform.eu.rep.googleapis.com'); + }); + + it('returns the .rep. host for the US multi-region location', () => { + expect(getVertexEndpoint('us')).toBe('aiplatform.us.rep.googleapis.com'); + }); + + it('returns undefined for global so the SDK derives aiplatform.googleapis.com', () => { + expect(getVertexEndpoint('global')).toBeUndefined(); + }); + + it('returns undefined for a regional location so the SDK derives -aiplatform...', () => { + expect(getVertexEndpoint('europe-west4')).toBeUndefined(); + expect(getVertexEndpoint('us-central1')).toBeUndefined(); + }); +}); + +describe('resolveVertexLocation', () => { + it('prefers the node-level override over the credential region', () => { + expect(resolveVertexLocation('global', 'us-central1')).toBe('global'); + expect(resolveVertexLocation('eu', 'us-central1')).toBe('eu'); + }); + + it('falls back to the credential region when no override is set', () => { + expect(resolveVertexLocation('', 'us-central1')).toBe('us-central1'); + expect(resolveVertexLocation(undefined, 'europe-west4')).toBe('europe-west4'); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/gemini-common/vertex-location.ts b/packages/@n8n/nodes-langchain/nodes/llms/gemini-common/vertex-location.ts new file mode 100644 index 00000000000..519ad75b726 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/gemini-common/vertex-location.ts @@ -0,0 +1,44 @@ +import type { INodeProperties } from 'n8n-workflow'; + +// Vertex multi-region locations that guarantee data residency within a geography +// (e.g. for GDPR). Unlike `global` and individual regions, they are reached through a +// dedicated `.rep.` hostname instead of `-aiplatform.googleapis.com`. +const MULTI_REGION_LOCATIONS = ['eu', 'us']; + +/** + * Returns the API host for a Vertex location, or `undefined` to let the SDK derive it. + * Newer Gemini models (3.x) are only served from `global` or a multi-region location, + * and the multi-region ones require the `.rep.` host the SDK doesn't build on its own. + */ +export function getVertexEndpoint(location: string): string | undefined { + if (MULTI_REGION_LOCATIONS.includes(location)) { + return `aiplatform.${location}.rep.googleapis.com`; + } + return undefined; +} + +/** + * Node-level location override wins over the region set in the credential. + * An empty override (the field default) means "use the credential region". + */ +export function resolveVertexLocation(override: string | undefined, credentialRegion: string) { + if (override) return override; + return credentialRegion; +} + +// Optional per-node override so the location for newer Gemini models is selectable +// right where the model is configured, not only in the shared credential. +export const vertexLocationField: INodeProperties = { + displayName: 'Region', + name: 'location', + type: 'options', + default: '', + description: + 'Where the model runs. Newer Gemini models (3.x) are only available on the Global or the EU/US multi-region locations. Leave as Default to use the region set in the credential.', + options: [ + { name: 'Default (Use Credential Region)', value: '' }, + { name: 'Global', value: 'global' }, + { name: 'EU (Multi-Region)', value: 'eu' }, + { name: 'US (Multi-Region)', value: 'us' }, + ], +}; diff --git a/packages/nodes-base/credentials/GoogleApi.credentials.ts b/packages/nodes-base/credentials/GoogleApi.credentials.ts index e1dc1fcd0c5..319efe90be6 100644 --- a/packages/nodes-base/credentials/GoogleApi.credentials.ts +++ b/packages/nodes-base/credentials/GoogleApi.credentials.ts @@ -232,11 +232,19 @@ export class GoogleApi implements ICredentialType { displayName: 'Region', name: 'region', type: 'options', - options: regions.map((r) => ({ - name: `${r.displayName} (${r.location}) - ${r.name}`, - value: r.name, - })), - default: 'us-central1', + options: [ + // Newer Gemini models (e.g. Gemini 3.x) are only served from `global` or the + // `eu`/`us` multi-region locations, not individual regions, so list them first. + { name: 'Global (multi-region) - global', value: 'global' }, + { name: 'EU (multi-region) - eu', value: 'eu' }, + { name: 'US (multi-region) - us', value: 'us' }, + ...regions.map((r) => ({ + name: `${r.displayName} (${r.location}) - ${r.name}`, + value: r.name, + })), + ], + // Global is the only location that serves both Gemini 2.x and 3.x models. + default: 'global', description: 'The region where the Google Cloud service is located. This applies only to specific nodes, like the Google Vertex Chat Model', }, diff --git a/packages/nodes-base/credentials/test/GoogleApi.credentials.test.ts b/packages/nodes-base/credentials/test/GoogleApi.credentials.test.ts index 0819bcbed77..56669a7fd32 100644 --- a/packages/nodes-base/credentials/test/GoogleApi.credentials.test.ts +++ b/packages/nodes-base/credentials/test/GoogleApi.credentials.test.ts @@ -48,6 +48,30 @@ describe('GoogleApi Credential', () => { expect(credential.displayName).toBe('Google Service Account API'); }); + describe('region property', () => { + const regionProperty = credential.properties.find((p) => p.name === 'region'); + const regionOptions = (regionProperty?.options ?? []) as Array<{ name: string; value: string }>; + + it('offers the global and multi-region locations first', () => { + // Newer Gemini models (e.g. Gemini 3.x) are only served from these locations + expect(regionOptions.slice(0, 3)).toEqual([ + { name: 'Global (multi-region) - global', value: 'global' }, + { name: 'EU (multi-region) - eu', value: 'eu' }, + { name: 'US (multi-region) - us', value: 'us' }, + ]); + }); + + it('keeps the regional options alongside the multi-region ones', () => { + const values = regionOptions.map((o) => o.value); + expect(values).toContain('us-central1'); + expect(values).toContain('europe-west4'); + }); + + it('defaults to global, the only location serving both Gemini 2.x and 3.x', () => { + expect(regionProperty?.default).toBe('global'); + }); + }); + describe('authenticate', () => { it('returns the request unchanged when not set up for the HTTP Request node', async () => { const result = await credential.authenticate(