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) <noreply@anthropic.com>
This commit is contained in:
Michael Drury
2026-06-29 12:37:03 +01:00
committed by GitHub
parent 2c9b5f94dd
commit 3955bcafd3
8 changed files with 277 additions and 9 deletions
@@ -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. <a href="https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/text-embeddings-api">Learn more</a>.',
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,
});
@@ -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<ISupplyDataFunctions>;
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<ISupplyDataFunctions>(
{},
mockNode,
) as Mocked<ISupplyDataFunctions>;
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');
});
});
@@ -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,
@@ -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;
@@ -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 <region>-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');
});
});
@@ -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 `<location>-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' },
],
};
@@ -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',
},
@@ -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(