mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 05:38:33 +08:00
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:
+15
-2
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+78
@@ -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');
|
||||
});
|
||||
});
|
||||
+14
-2
@@ -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,
|
||||
|
||||
+57
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user