feat(core): Add Google Vertex AI support for agents (no-changelog) (#40545)

This commit is contained in:
Robin Braumann 2026-10-12 12:05:53 +00:00 committed by GitHub
parent e16066c322
commit bd22105646
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1117 additions and 153 deletions

View File

@ -121,6 +121,7 @@
"@opentelemetry/sdk-trace-node": ">=1.0.0",
"ai": "catalog:",
"ajv": "^8.18.0",
"google-auth-library": "catalog:",
"langsmith": "catalog:",
"undici": "catalog:undici-v7",
"yaml": "catalog:",

View File

@ -0,0 +1,188 @@
import { generateText, streamText } from 'ai';
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { createModel } from '../model/model-factory';
const credentials = {
project: 'selected-project',
location: 'global',
clientEmail: 'agent@selected-project.iam.gserviceaccount.com',
privateKey: generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({
type: 'pkcs8',
format: 'pem',
}),
};
const chatResponse = {
id: 'completion-1',
object: 'chat.completion',
created: 1,
choices: [{ index: 0, message: { role: 'assistant', content: 'Hello' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 },
};
describe('Vertex model routing', () => {
beforeEach(() => {
nock.disableNetConnect();
nock('https://oauth2.googleapis.com').post('/token').reply(200, {
access_token: 'vertex-access-token',
token_type: 'Bearer',
expires_in: 3600,
});
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
const routingCases = [
{
model: 'gemini-3-flash-preview',
sdk: '@ai-sdk/google-vertex',
location: 'global',
host: 'aiplatform.googleapis.com',
apiVersion: 'v1beta1',
path: 'publishers/google/models/gemini-3-flash-preview:generateContent',
response: {
candidates: [
{ content: { role: 'model', parts: [{ text: 'Hello' }] }, finishReason: 'STOP' },
],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 2, totalTokenCount: 12 },
},
request: { contents: [{ role: 'user', parts: [{ text: 'Hello' }] }] },
},
{
model: 'claude-opus-5@default',
sdk: '@ai-sdk/google-vertex/anthropic',
location: 'global',
host: 'aiplatform.googleapis.com',
path: 'publishers/anthropic/models/claude-opus-5@default:rawPredict',
response: {
id: 'message-1',
type: 'message',
role: 'assistant',
model: 'claude-opus-5@default',
content: [{ type: 'text', text: 'Hello' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: { input_tokens: 10, output_tokens: 2 },
},
request: { anthropic_version: 'vertex-2023-10-16' },
},
{
model: 'mistral-medium-3',
sdk: '@ai-sdk/mistral',
location: 'europe-west4',
host: 'europe-west4-aiplatform.googleapis.com',
path: 'publishers/mistralai/models/mistral-medium-3:rawPredict',
response: { ...chatResponse, model: 'mistral-medium-3' },
request: {
model: 'mistral-medium-3',
messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
},
},
...[
'deepseek-ai/deepseek-v3.2-maas',
'google/gemma-4-26b-a4b-it-maas',
'meta/llama-4-maverick-17b-128e-instruct-maas',
'minimax/minimax-m2-maas',
'moonshotai/kimi-k2-thinking-maas',
'openai/gpt-oss-120b-maas',
'qwen/qwen3-235b-a22b-instruct-2507-maas',
'zai-org/glm-5-maas',
].map((model) => ({
model,
sdk: '@ai-sdk/openai-compatible',
location: 'europe-west4',
host: 'europe-west4-aiplatform.googleapis.com',
apiVersion: 'v1',
path: 'endpoints/openapi/chat/completions',
response: chatResponse,
request: { model },
})),
{
model: 'xai/grok-4.20-reasoning',
sdk: '@ai-sdk/openai-compatible',
location: 'global',
host: 'aiplatform.googleapis.com',
path: 'endpoints/openapi/chat/completions',
response: chatResponse,
request: { model: 'xai/grok-4.20-reasoning' },
},
];
it.each(routingCases.flatMap((testCase) => [testCase, { ...testCase, sdk: undefined }]))(
'sends $model through its Vertex API with SDK $sdk and service-account auth',
async (testCase) => {
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValue(Response.json(testCase.response));
const model = createModel(
{
...credentials,
id: `google-vertex/${testCase.model}`,
sdk: testCase.sdk,
location: testCase.location,
},
fetch,
);
const result = await generateText({ model, prompt: 'Hello', maxRetries: 0 });
expect(result.text).toBe('Hello');
const [url, init] = fetch.mock.calls[0];
expect(url).toBe(
`https://${testCase.host}/${testCase.apiVersion ?? 'v1'}/projects/selected-project/locations/${testCase.location}/${testCase.path}`,
);
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer vertex-access-token');
expect(JSON.parse(init?.body as string)).toMatchObject(testCase.request);
expect(nock.isDone()).toBe(true);
},
);
it.each(['@ai-sdk/mistral', undefined])(
'streams Mistral responses through streamRawPredict with SDK %s',
async (sdk) => {
const chunks = [
{
...chatResponse,
choices: [
{ index: 0, delta: { role: 'assistant', content: 'Hello' }, finish_reason: null },
],
},
{ ...chatResponse, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
];
const response = chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join('');
const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
new Response(`${response}data: [DONE]\n\n`, {
headers: { 'content-type': 'text/event-stream' },
}),
);
const model = createModel({ ...credentials, id: 'google-vertex/codestral-2', sdk }, fetch);
const result = streamText({ model, prompt: 'Hello', maxRetries: 0 });
await expect(result.text).resolves.toBe('Hello');
const [url, init] = fetch.mock.calls[0];
expect(url).toBe(
'https://aiplatform.googleapis.com/v1/projects/selected-project/locations/global/publishers/mistralai/models/codestral-2:streamRawPredict',
);
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer vertex-access-token');
expect(JSON.parse(init?.body as string)).toMatchObject({
model: 'codestral-2',
stream: true,
});
},
);
it.each([
{ id: 'google-vertex/unknown-model' },
{ id: 'google-vertex/unknown/llama-4-maas' },
{ id: 'google-vertex/meta/unknown-model-maas' },
{ id: 'google-vertex/claude-opus-5@default', sdk: '@ai-sdk/unknown' },
])('rejects unresolved Vertex routing for $id', (config) => {
expect(() => createModel({ ...credentials, ...config })).toThrow(/Vertex.*SDK/);
});
});

View File

@ -1,6 +1,11 @@
import { createGoogleVertex } from '@ai-sdk/google-vertex';
import type { LanguageModel } from 'ai';
import { createEmbeddingModel, createModel } from '../model/model-factory';
import {
createEmbeddingModel,
createModel,
supportsSplitSystemMessages,
} from '../model/model-factory';
import { forgetEndpointApiStyles } from '../model/openai-api-style';
type ProviderOpts = {
@ -85,6 +90,15 @@ vi.mock('@ai-sdk/google', () => ({
}),
}));
vi.mock('@ai-sdk/google-vertex', () => ({
createGoogleVertex: vi.fn((opts: Record<string, unknown>) => (model: string) => ({
...opts,
provider: 'google-vertex',
modelId: model,
specificationVersion: 'v4',
})),
}));
vi.mock('@ai-sdk/xai', () => ({
createXai: (opts?: ProviderOpts) => ({
chat: (model: string) => ({
@ -1054,6 +1068,74 @@ describe('createModel', () => {
});
});
describe('google-vertex', () => {
const credentials = {
id: 'google-vertex/gemini-3-flash-preview',
project: 'selected-project',
location: 'europe-west4',
clientEmail: 'agent@selected-project.iam.gserviceaccount.com',
privateKey: '-----BEGIN PRIVATE KEY-----\\nABC\\n-----END PRIVATE KEY-----\\n',
};
it('uses the selected service account, project, region, and guarded transport', () => {
vi.stubEnv('GOOGLE_VERTEX_API_KEY', 'ambient-key');
vi.stubEnv('GOOGLE_VERTEX_PROJECT', 'ambient-project');
vi.stubEnv('GOOGLE_VERTEX_LOCATION', 'ambient-region');
const guardedFetch = vi.fn<typeof globalThis.fetch>();
try {
const model = createModel(credentials, guardedFetch);
expect(model).toMatchObject({
modelId: 'gemini-3-flash-preview',
apiKey: '',
project: 'selected-project',
location: 'europe-west4',
fetch: guardedFetch,
googleAuthOptions: {
credentials: {
client_email: credentials.clientEmail,
private_key: '-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----\n',
},
},
});
} finally {
vi.unstubAllEnvs();
}
});
it.each(['project', 'location', 'clientEmail', 'privateKey'])(
'requires an explicit %s before model execution',
(field) => {
expect(() => createModel({ ...credentials, [field]: '' })).toThrow(
'Invalid credentials for provider "google-vertex"',
);
},
);
it.each([
'global',
'eu',
'us',
'us-central1',
'europe-west12',
'northamerica-northeast1',
'me-central2',
])('accepts location %s', (location) => {
expect(createModel({ ...credentials, location })).toMatchObject({ location });
});
it.each(['us-central', 'US-central1', 'us-central1-a', 'us.central1', 'us/central1'])(
'rejects invalid location %s before provider creation',
(location) => {
vi.mocked(createGoogleVertex).mockClear();
expect(() => createModel({ ...credentials, location })).toThrow(
/Invalid credentials for provider "google-vertex"[\s\S]*location/,
);
expect(createGoogleVertex).not.toHaveBeenCalled();
},
);
});
describe('google-vertex-anthropic', () => {
it('should create model with project, location, and service-account JSON', () => {
const model = createModel({
@ -1125,6 +1207,20 @@ describe('createModel', () => {
});
});
describe('supportsSplitSystemMessages', () => {
it.each([
['anthropic/claude-sonnet-4-5', true],
['google-vertex-anthropic/claude-opus-4-8', true],
['google-vertex/claude-sonnet-4-5', true],
['google-vertex/claude-opus-5@default', true],
['google-vertex/gemini-3-flash-preview', false],
['google-vertex/mistral-large-2411', false],
['google-vertex/xai/grok-4', false],
] as const)('checks split system messages for %s', (model, expected) => {
expect(supportsSplitSystemMessages(model)).toBe(expected);
});
});
describe('createEmbeddingModel', () => {
it('should accept a legacy api key string', () => {
const model = createEmbeddingModel(

View File

@ -1,5 +1,6 @@
import type { ModelConfig } from '../../../types/sdk/agent';
import { getModelIdString } from '../../../utils/model';
import { RuntimeContextBuilder } from '../runtime-context';
describe('getModelIdString', () => {
it('returns a plain string model id unchanged', () => {
@ -34,3 +35,64 @@ describe('getModelIdString', () => {
expect(getModelIdString(model)).toBe('unknown');
});
});
describe('Vertex thinking options', () => {
it.each([
{
modelId: 'google-vertex/claude-sonnet-4-5',
thinking: { budgetTokens: 4096 },
expected: { anthropic: { thinking: { type: 'enabled', budgetTokens: 4096 } } },
},
{
modelId: 'google-vertex/claude-opus-5@default',
thinking: { budgetTokens: 4096 },
expected: {
anthropic: {
thinking: { type: 'adaptive', display: 'summarized' },
effort: 'medium',
},
},
},
{
modelId: 'google-vertex/gemini-2.5-flash',
thinking: { thinkingBudget: 2048 },
expected: { google: { thinkingConfig: { thinkingBudget: 2048 } } },
},
{
modelId: 'google-vertex/gemini-3-flash-preview',
thinking: { thinkingLevel: 'high' as const },
expected: { google: { thinkingConfig: { thinkingLevel: 'high' } } },
},
{
modelId: 'google-vertex/endpoints/123456789',
sdk: '@ai-sdk/google-vertex',
thinking: { thinkingBudget: 2048 },
expected: { google: { thinkingConfig: { thinkingBudget: 2048 } } },
},
{
modelId: 'google-vertex/mistral-medium-3',
thinking: { thinkingBudget: 2048 },
expected: undefined,
},
])('maps thinking options for $modelId', ({ modelId, sdk, thinking, expected }) => {
const builder = new RuntimeContextBuilder(
{
name: 'test',
model: {
id: modelId,
sdk,
project: 'test-project',
location: 'global',
clientEmail: 'test@example.com',
privateKey: 'test-key',
},
instructions: '',
thinking,
},
undefined,
);
expect(builder.buildStaticLoopContext().providerOptions).toEqual(expected);
expect(builder.modelId).toBe(modelId);
});
});

View File

@ -1,5 +1,5 @@
import type { ProviderOptions } from '@ai-sdk/provider-utils';
import { getProviderPrefix } from '@n8n/ai-utilities/agent-config';
import { splitModelId } from '@n8n/ai-utilities/agent-config';
import type { LanguageModel, Output } from 'ai';
import type { AgentRuntimeConfig } from '../../types/runtime/agent-runtime';
@ -21,6 +21,7 @@ import {
FLAG_MEMORY_TOOL_NAME,
resolveEpisodicMemoryCapture,
} from '../memory/episodic-memory-capture';
import { getGoogleVertexQuirksProvider } from '../model/google-vertex';
import { loadAi } from '../model/lazy-ai';
import type { AgentMessageList } from '../model/message-list';
import { createModel, supportsSplitSystemMessages } from '../model/model-factory';
@ -374,7 +375,15 @@ export class RuntimeContextBuilder {
private buildThinkingProviderOptions(): Record<string, Record<string, unknown>> | undefined {
if (!this.config.thinking) return undefined;
const quirks = getProviderQuirks(getProviderPrefix(this.modelId));
const { provider, model } = splitModelId(this.modelId);
const modelConfig = this.config.model;
const sdk =
typeof modelConfig === 'object' && 'sdk' in modelConfig && typeof modelConfig.sdk === 'string'
? modelConfig.sdk
: undefined;
const quirksProvider =
provider === 'google-vertex' ? getGoogleVertexQuirksProvider(model, sdk) : provider;
const quirks = getProviderQuirks(quirksProvider);
return quirks.thinkingToProviderOptions?.(this.config.thinking, this.modelId);
}

View File

@ -87,10 +87,12 @@ describe('buildSkillInstructionCacheOptions', () => {
});
describe('buildInstructionPromptCacheOptions — Anthropic', () => {
it('defaults to a 1h cache breakpoint when enabled with no ttl override', () => {
expect(
buildInstructionPromptCacheOptions({ enabled: true }, 'anthropic/claude-sonnet-4-5'),
).toEqual({
it.each([
'anthropic/claude-sonnet-4-5',
'google-vertex-anthropic/claude-opus-4-8',
'google-vertex/claude-opus-5@default',
])('defaults to a 1h cache breakpoint for %s', (modelId) => {
expect(buildInstructionPromptCacheOptions({ enabled: true }, modelId)).toEqual({
anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } },
});
});
@ -106,9 +108,12 @@ describe('buildInstructionPromptCacheOptions — Anthropic', () => {
});
});
it('returns undefined for a non-Anthropic model', () => {
expect(buildInstructionPromptCacheOptions({ enabled: true }, 'openai/gpt-5.1')).toBeUndefined();
});
it.each(['openai/gpt-5.1', 'google-vertex/gemini-3-flash-preview'])(
'returns undefined for %s without Anthropic caching',
(modelId) => {
expect(buildInstructionPromptCacheOptions({ enabled: true }, modelId)).toBeUndefined();
},
);
it('returns undefined when config is disabled', () => {
expect(
@ -212,11 +217,12 @@ describe('createOpenAIPromptCacheKey (via buildCallPromptCacheOptions)', () => {
});
describe('getEffectiveAnthropicCacheTtl', () => {
it('returns the configured TTL (default 1h) for an Anthropic model with caching enabled', () => {
expect(getEffectiveAnthropicCacheTtl({ enabled: true }, 'anthropic/claude-sonnet-4-5')).toBe(
'1h',
);
});
it.each(['anthropic/claude-sonnet-4-5', 'google-vertex/claude-opus-5@default'])(
'returns the default 1h TTL for %s with caching enabled',
(modelId) => {
expect(getEffectiveAnthropicCacheTtl({ enabled: true }, modelId)).toBe('1h');
},
);
it('returns an explicit 5m override for an Anthropic model', () => {
expect(
@ -269,23 +275,26 @@ describe('mergeProviderOptions', () => {
});
describe('applyRuntimeCacheBreakpoints', () => {
it('marks the last conversation message with an Anthropic cache breakpoint', () => {
const messages = [makeUserMessage('hi'), makeUserMessage('there')];
it.each(['anthropic/claude-sonnet-4-5', 'google-vertex/claude-opus-5@default'])(
'marks the last conversation message with an Anthropic cache breakpoint for %s',
(modelId) => {
const messages = [makeUserMessage('hi'), makeUserMessage('there')];
const { messages: result } = applyRuntimeCacheBreakpoints({
system: anthropicSystem,
messages,
aiTools: {},
promptCaching: { enabled: true },
modelId: 'anthropic/claude-sonnet-4-5',
staticToolCacheName: undefined,
});
const { messages: result } = applyRuntimeCacheBreakpoints({
system: anthropicSystem,
messages,
aiTools: {},
promptCaching: { enabled: true },
modelId,
staticToolCacheName: undefined,
});
expect(result[0].providerOptions).toBeUndefined();
expect(result[1].providerOptions).toEqual({
anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } },
});
});
expect(result[0].providerOptions).toBeUndefined();
expect(result[1].providerOptions).toEqual({
anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } },
});
},
);
it('marks the static tool with a cache breakpoint when staticToolCacheName is provided', () => {
const aiTools = { tool_a: makeTool(), tool_b: makeTool() };

View File

@ -62,15 +62,20 @@ describe('AnthropicRawUsageReader', () => {
});
describe('createRawUsageReader', () => {
it('returns an Anthropic reader for an anthropic model id', () => {
expect(createRawUsageReader('anthropic/claude-sonnet-4-6')).toBeInstanceOf(
AnthropicRawUsageReader,
);
it.each([
'anthropic/claude-sonnet-4-6',
'google-vertex-anthropic/claude-opus-4-8',
'google-vertex/claude-opus-5@default',
])('returns an Anthropic reader for %s', (modelId) => {
expect(createRawUsageReader(modelId)).toBeInstanceOf(AnthropicRawUsageReader);
});
it('returns undefined for a provider without a raw-usage reader', () => {
expect(createRawUsageReader('openai/gpt-4o-mini')).toBeUndefined();
});
it.each(['openai/gpt-4o-mini', 'google-vertex/gemini-3-flash-preview'])(
'returns undefined for %s without a raw-usage reader',
(modelId) => {
expect(createRawUsageReader(modelId)).toBeUndefined();
},
);
it('returns undefined for a malformed model id', () => {
expect(createRawUsageReader('no-provider')).toBeUndefined();

View File

@ -0,0 +1,176 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
/* eslint-disable @typescript-eslint/no-require-imports */
import type { GoogleVertexProviderSettings } from '@ai-sdk/google-vertex';
import { splitModelId } from '@n8n/ai-utilities/agent-config';
import { formatPemBlock } from '@n8n/utils/format-pem-block';
import { isRecord } from '@n8n/utils/is-record';
import type { LanguageModel } from 'ai';
import type { FetchFn } from './model-factory';
import type { ProviderCredentials } from './provider-credentials';
type VertexSettings = GoogleVertexProviderSettings & { project: string; location: string };
const VERTEX_MAAS_MODEL_PREFIXES = [
'deepseek-ai/deepseek-',
'google/gemma-',
'meta/llama-',
'minimax/minimax-',
'moonshotai/kimi-',
'openai/gpt-oss-',
'qwen/qwen',
'zai-org/glm-',
];
/** Keep saved models usable when catalog metadata is unavailable. */
export function resolveGoogleVertexSdk(model: string, sdk?: string): string | undefined {
if (sdk !== undefined) return sdk;
if (model.startsWith('gemini-')) return '@ai-sdk/google-vertex';
if (model.startsWith('claude-')) return '@ai-sdk/google-vertex/anthropic';
if (model.startsWith('mistral-') || model.startsWith('codestral-')) return '@ai-sdk/mistral';
if (
model.startsWith('xai/grok-') ||
(model.endsWith('-maas') &&
VERTEX_MAAS_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix)))
) {
return '@ai-sdk/openai-compatible';
}
return undefined;
}
/** Vertex models need the thinking options of their underlying API. */
export function getGoogleVertexQuirksProvider(model: string, sdk?: string): string {
switch (resolveGoogleVertexSdk(model, sdk)) {
case '@ai-sdk/google-vertex':
return 'google';
case '@ai-sdk/google-vertex/anthropic':
return 'google-vertex-anthropic';
default:
return 'google-vertex';
}
}
/** Identify Claude models that use the shared Vertex provider ID. */
export function isGoogleVertexAnthropicModel(modelId: string): boolean {
const { provider, model } = splitModelId(modelId);
return (
provider === 'google-vertex' &&
resolveGoogleVertexSdk(model) === '@ai-sdk/google-vertex/anthropic'
);
}
/** Select the Vertex API for the model. */
export function createGoogleVertexModel(
creds: ProviderCredentials<'google-vertex'>,
model: string,
fetch?: FetchFn,
): LanguageModel {
const settings: VertexSettings = {
project: creds.project,
location: creds.location,
googleAuthOptions: {
credentials: {
client_email: creds.clientEmail,
private_key: formatPemBlock(creds.privateKey),
},
},
fetch,
};
const sdk = resolveGoogleVertexSdk(model, creds.sdk);
switch (sdk) {
case '@ai-sdk/google-vertex': {
const { createGoogleVertex } =
require('@ai-sdk/google-vertex') as typeof import('@ai-sdk/google-vertex');
// An empty key prevents GOOGLE_VERTEX_API_KEY from selecting Express mode.
return createGoogleVertex({ ...settings, apiKey: '' })(model);
}
case '@ai-sdk/google-vertex/anthropic': {
const { createVertexAnthropic } =
require('@ai-sdk/google-vertex/anthropic') as typeof import('@ai-sdk/google-vertex/anthropic');
return createVertexAnthropic(settings)(model);
}
case '@ai-sdk/mistral':
return createVertexMistralModel(settings, model);
case '@ai-sdk/openai-compatible': {
if (model.startsWith('xai/')) {
// The xAI adapter handles Grok's reasoning-token accounting.
const { createGoogleVertexXai } =
require('@ai-sdk/google-vertex/xai') as typeof import('@ai-sdk/google-vertex/xai');
return createGoogleVertexXai(settings)(model);
}
const { createVertexMaas } =
require('@ai-sdk/google-vertex/maas') as typeof import('@ai-sdk/google-vertex/maas');
return createVertexMaas(settings)(model);
}
default:
throw new Error(`No supported Vertex SDK is configured for model "${model}".`);
}
}
function getVertexBaseUrl(project: string, location: string): string {
let host = `${location}-aiplatform.googleapis.com`;
if (location === 'global') {
host = 'aiplatform.googleapis.com';
} else if (location === 'eu' || location === 'us') {
host = `aiplatform.${location}.rep.googleapis.com`;
}
return `https://${host}/v1/projects/${project}/locations/${location}`;
}
/** Mistral uses its native payload with Vertex authentication and prediction methods. */
function createVertexMistralModel(settings: VertexSettings, model: string): LanguageModel {
const { createMistral } = require('@ai-sdk/mistral') as typeof import('@ai-sdk/mistral');
const { GoogleAuth } = require('google-auth-library') as typeof import('google-auth-library');
const { parseJSON } =
require('@ai-sdk/provider-utils') as typeof import('@ai-sdk/provider-utils');
const auth = new GoogleAuth({
...settings.googleAuthOptions,
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
});
const baseURL = `${getVertexBaseUrl(settings.project, settings.location)}/publishers/mistralai/models`;
const fetch: FetchFn = async (_url, init) => {
if (typeof init?.body !== 'string') {
throw new Error('Vertex Mistral requires a JSON request body.');
}
const body = await parseJSON({ text: init.body });
if (!isRecord(body)) {
throw new Error('Vertex Mistral requires a JSON object.');
}
const token = await auth.getAccessToken();
if (!token) throw new Error('Could not get a Google Vertex access token.');
const headers = new Headers(init.headers);
headers.set('Authorization', `Bearer ${token}`);
const method = body.stream === true ? 'streamRawPredict' : 'rawPredict';
return await (settings.fetch ?? globalThis.fetch)(`${baseURL}/${model}:${method}`, {
...init,
headers,
});
};
return createMistral({ apiKey: '', baseURL, fetch })(model);
}
/** Parse service-account JSON for the standalone Vertex Anthropic provider. */
export function parseGoogleVertexAuthOptions(
googleCredentials: string | undefined,
): { credentials: Record<string, unknown> } | undefined {
if (!googleCredentials?.trim()) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(googleCredentials);
} catch {
throw new Error(
'Invalid credentials for provider "google-vertex-anthropic": googleCredentials must be valid JSON',
);
}
if (!isRecord(parsed)) {
throw new Error(
'Invalid credentials for provider "google-vertex-anthropic": googleCredentials must be a JSON object',
);
}
const credentials = { ...parsed };
// Environment files can contain literal newline escapes.
if (typeof credentials.private_key === 'string') {
credentials.private_key = credentials.private_key.replace(/\\n/g, '\n');
}
return { credentials };
}

View File

@ -4,6 +4,11 @@ import { ensureUrlPathSuffix, isOpenAiCustomEndpoint } from '@n8n/ai-utilities/m
import type { EmbeddingModel, LanguageModel } from 'ai';
import type * as Undici from 'undici';
import {
createGoogleVertexModel,
isGoogleVertexAnthropicModel,
parseGoogleVertexAuthOptions,
} from './google-vertex';
import {
endpointRouteKey,
guardOpenAiRoutes,
@ -85,36 +90,6 @@ type OpenAiCompatibleCreds = {
headers?: Record<string, string>;
};
/**
* Parse a Vertex service-account JSON string into `googleAuthOptions`.
* Accepts either a full SA JSON blob or the subset `{ client_email, private_key }`.
* Returns undefined when unset so ADC can take over.
*/
function parseGoogleVertexAuthOptions(
googleCredentials: string | undefined,
): { credentials: Record<string, unknown> } | undefined {
if (!googleCredentials?.trim()) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(googleCredentials);
} catch {
throw new Error(
'Invalid credentials for provider "google-vertex-anthropic": googleCredentials must be valid JSON',
);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(
'Invalid credentials for provider "google-vertex-anthropic": googleCredentials must be a JSON object',
);
}
const credentials = { ...(parsed as Record<string, unknown>) };
// SA keys often arrive with literal `\n` escapes when pasted into env files.
if (typeof credentials.private_key === 'string') {
credentials.private_key = credentials.private_key.replace(/\\n/g, '\n');
}
return { credentials };
}
/**
* Azure OpenAI scope for Entra Bearer auth. Mirrors the LangChain Azure node's
* `AZURE_OPENAI_SCOPE` and the `resource` claim the reference impl sends in the
@ -221,7 +196,9 @@ export function isOfficialOpenAiBaseUrl(baseURL: string | undefined): boolean {
/** Whether a model accepts the stable and volatile prompt sections as separate system messages. */
export function supportsSplitSystemMessages(model: ModelConfig): boolean {
switch (getModelIdString(model).split('/')[0]) {
const modelId = getModelIdString(model);
if (isGoogleVertexAnthropicModel(modelId)) return true;
switch (modelId.split('/')[0]) {
case 'anthropic':
case 'google-vertex-anthropic':
case 'openrouter':
@ -346,6 +323,9 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = {
return createGoogle({ ...creds, baseURL: normalizedBaseURL, fetch })(model);
},
},
'google-vertex': {
build: createGoogleVertexModel,
},
xai: {
build: (creds, model, fetch) => {
const { createXai } = require('@ai-sdk/xai') as typeof import('@ai-sdk/xai');

View File

@ -4,6 +4,7 @@ import { isRecord } from '@n8n/utils/is-record';
import type { ModelMessage, SystemModelMessage, ToolSet } from 'ai';
import { createHash } from 'crypto';
import { isGoogleVertexAnthropicModel } from './google-vertex';
import type { PromptCachingConfig } from '../../types/sdk/agent';
import type { JSONObject } from '../../types/utils/json';
@ -58,7 +59,11 @@ function isEnabledForProvider(
/** Providers that speak the Anthropic Messages API (including Vertex Claude). */
export function isAnthropicMessagesProvider(modelId: string): boolean {
const provider = getProviderPrefix(modelId);
return provider === 'anthropic' || provider === 'google-vertex-anthropic';
return (
provider === 'anthropic' ||
provider === 'google-vertex-anthropic' ||
isGoogleVertexAnthropicModel(modelId)
);
}
/**

View File

@ -36,6 +36,20 @@ export const PROVIDER_CREDENTIAL_SCHEMAS = {
headers: z.record(z.string(), z.string()).optional(),
}),
google: apiKeyCreds,
'google-vertex': z.object({
sdk: z.string().optional(),
project: z.string().trim().min(1, 'Google Cloud project ID is required'),
location: z
.string()
.trim()
.min(1, 'Google Cloud region is required')
.regex(
/^(?:global|eu|us|[a-z]+-[a-z]+\d+)$/,
'Use a Google Cloud region such as us-central1, global, eu, or us',
),
clientEmail: z.string().trim().min(1, 'Service account email is required'),
privateKey: z.string().trim().min(1, 'Service account private key is required'),
}),
xai: apiKeyCreds,
groq: apiKeyCreds,
deepseek: apiKeyCreds,

View File

@ -53,6 +53,7 @@ export class GoogleRawErrorReader implements RawErrorReader {
*/
const RAW_ERROR_READERS: Partial<Record<ProviderId, () => RawErrorReader>> = {
google: () => new GoogleRawErrorReader(),
'google-vertex': () => new GoogleRawErrorReader(),
};
/**

View File

@ -1,6 +1,7 @@
import { getProviderPrefix } from '@n8n/ai-utilities/agent-config';
import { isRecord } from '@n8n/utils/is-record';
import { isGoogleVertexAnthropicModel } from './google-vertex';
import { type ProviderId } from './provider-credentials';
import type { TokenUsage } from '../../types';
@ -79,10 +80,10 @@ const RAW_USAGE_READERS: Partial<Record<ProviderId, () => RawUsageReader>> = {
/**
* Create a fresh raw-usage reader for the run's model, or undefined when the
* provider has no reader. The provider is the prefix of the `provider/model` id
* (same convention as `createModel`).
* provider has no reader. Vertex Claude uses the Anthropic event format.
*/
export function createRawUsageReader(modelId: string): RawUsageReader | undefined {
if (isGoogleVertexAnthropicModel(modelId)) return new AnthropicRawUsageReader();
const provider = getProviderPrefix(modelId) as ProviderId;
return RAW_USAGE_READERS[provider]?.();
}

View File

@ -17,6 +17,7 @@ describe('fetchProviderCatalog', () => {
openai: {
id: 'openai',
name: 'OpenAI',
npm: '@ai-sdk/openai',
models: {
'gpt-5': {
id: 'gpt-5',
@ -31,6 +32,18 @@ describe('fetchProviderCatalog', () => {
},
},
},
'google-vertex': {
id: 'google-vertex',
name: 'Vertex',
npm: '@ai-sdk/google-vertex',
models: {
'claude-opus-5@default': {
id: 'claude-opus-5@default',
name: 'Claude Opus 5',
provider: { npm: '@ai-sdk/google-vertex/anthropic' },
},
},
},
'amazon-bedrock': {
id: 'amazon-bedrock',
name: 'Amazon Bedrock',
@ -68,6 +81,10 @@ describe('fetchProviderCatalog', () => {
const catalog = await fetchProviderCatalog({ signal: abortController.signal });
expect(catalog.openai.models['gpt-5'].toolCall).toBe(true);
expect(catalog.openai.models['gpt-5'].sdk).toBe('@ai-sdk/openai');
expect(catalog['google-vertex'].models['claude-opus-5@default'].sdk).toBe(
'@ai-sdk/google-vertex/anthropic',
);
expect(catalog.openai.models['gpt-5'].toolCallKnown).toBe(true);
expect(catalog.openai.models['gpt-5'].modalities).toEqual({
input: ['text', 'image'],

View File

@ -49,6 +49,8 @@ export interface ModelInfo {
id: string;
/** Human-readable name (e.g. 'Claude Sonnet 4.5'). */
name: string;
/** AI SDK package selected by the model or its parent provider. */
sdk?: string;
/** Release date in ISO date format when available from models.dev. */
releaseDate?: string;
/** Lifecycle status reported by the catalog, such as alpha or beta. */
@ -94,6 +96,7 @@ export type ProviderCatalog = Record<string, ProviderInfo>;
const modelsDevModelSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
provider: z.object({ npm: z.string().optional() }).optional(),
release_date: z.string().optional(),
reasoning: z.boolean().optional(),
temperature: z.boolean().optional(),
@ -124,6 +127,7 @@ const modelsDevModelSchema = z.object({
const modelsDevProviderSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
npm: z.string().optional(),
models: z.record(z.string(), z.unknown()).optional(),
});
@ -207,9 +211,11 @@ export async function fetchProviderCatalog(options?: {
deprecatedModelIds.add(model.id);
continue;
}
const sdk = model.provider?.npm ?? provider.npm;
const info: ModelInfo = {
id: model.id,
name: model.name,
...(sdk !== undefined && { sdk }),
...(model.release_date !== undefined && { releaseDate: model.release_date }),
...(model.status !== undefined && { status: model.status }),
...(model.reasoning !== undefined && { reasoning: model.reasoning }),
@ -278,7 +284,7 @@ export async function getCachedCatalog(): Promise<ProviderCatalog | undefined> {
// Clear so subsequent calls retry
catalogFetchPromise = undefined;
console.warn(
'[agents] Failed to fetch model catalog from models.dev — cost data will be unavailable:',
'[agents] Failed to fetch model catalog from models.dev — model metadata will be unavailable:',
error instanceof Error ? error.message : error,
);
return undefined;

View File

@ -18,6 +18,7 @@ import { configDefaults } from 'vitest/config';
const REWRITE_REQUIRE_TARGETS = [
'/src/runtime/model/lazy-ai.ts',
'/src/runtime/model/model-factory.ts',
'/src/runtime/model/google-vertex.ts',
];
function rewriteSourceRequire(): Plugin {

View File

@ -48,14 +48,17 @@ describe('AgentJsonConfigSchema — capability activation', () => {
});
describe('AgentJsonConfigSchema — model', () => {
it('accepts AWS Bedrock model names containing a version colon', () => {
const result = AgentJsonConfigSchema.safeParse({
...minimalConfig,
model: 'aws-bedrock/anthropic.claude-sonnet-4-5-v1:0',
});
it.each(['aws-bedrock/anthropic.claude-sonnet-4-5-v1:0', 'google-vertex/claude-fable-5@default'])(
'accepts the versioned model ID %s',
(model) => {
const result = AgentJsonConfigSchema.safeParse({
...minimalConfig,
model,
});
expect(result.success).toBe(true);
});
expect(result.success).toBe(true);
},
);
});
describe('AgentJsonConfigSchema — reasoning', () => {

View File

@ -21,8 +21,8 @@ export const MANAGED_CREDENTIAL_TOKEN = 'managed' as const;
export const AgentModelSchema = z.string().min(1).regex(
/**
* [a-z0-9-]+: Provider name (e.g. "anthropic")
* (?:[a-z0-9._:-]+\/)*: Zero or more sub-providers (e.g. "openrouter/amazon/nova-micro-v1")
* [a-z0-9._:-]+: Model name (e.g. "claude-sonnet-4-5")
* (?:[a-z0-9._:@-]+\/)*: Zero or more sub-providers (e.g. "openrouter/amazon/nova-micro-v1")
* [a-z0-9._:@-]+: Model name (e.g. "claude-fable-5@default")
*/
AGENT_MODEL_STRING_REGEX,
'Model must be "provider/model-name" format (e.g. "anthropic/claude-sonnet-4-5" or "openrouter/amazon/nova-micro-v1")',

View File

@ -2,6 +2,7 @@ export const AGENT_MODEL_PROVIDERS = [
'openai',
'anthropic',
'google',
'google-vertex',
'azure-openai',
'aws-bedrock',
'xai',
@ -18,7 +19,7 @@ export const AGENT_MODEL_PROVIDERS = [
] as const;
/** Canonical `provider/model-name` validation shared by API schemas and editor parsing. */
export const AGENT_MODEL_STRING_REGEX = /^[a-z0-9-]+\/(?:[a-z0-9._:-]+\/)*[a-z0-9._:-]+$/i;
export const AGENT_MODEL_STRING_REGEX = /^[a-z0-9-]+\/(?:[a-z0-9._:@-]+\/)*[a-z0-9._:@-]+$/i;
export type AgentModelProvider = (typeof AGENT_MODEL_PROVIDERS)[number];
@ -39,6 +40,7 @@ export const AGENT_MODEL_PROVIDER_CREDENTIAL_TYPES = {
openai: ['openAiApi'],
anthropic: ['anthropicApi'],
google: ['googlePalmApi'],
'google-vertex': ['googleVertexAiApi'],
'azure-openai': ['azureOpenAiApi', 'azureEntraCognitiveServicesOAuth2Api'],
'aws-bedrock': ['aws'],
xai: ['xAiApi'],

View File

@ -307,6 +307,85 @@ describe('AgentValidationService — structured issues', () => {
expect(bedrockResult).toEqual({ status: 'valid', issues: [] });
});
it.each(['runtime', 'publish'] as const)(
'validates each Vertex model slot before %s',
async (scope) => {
const { service, agentRepository } = makeService();
const model = {
model: 'google-vertex/gemini-2.5-pro',
credential: 'vertex',
};
const config: AgentJsonConfig = {
...runnableConfig,
...model,
memory: {
enabled: true,
storage: 'n8n',
observationalMemory: { observerModel: model, reflectorModel: model },
episodicMemory: {
enabled: true,
credential: 'embedding-credential',
reflectorModel: model,
},
},
subAgents: { modelsByDifficulty: { low: model, medium: model, high: model } },
};
const paths = [
'',
'memory.observationalMemory.observerModel.',
'memory.observationalMemory.reflectorModel.',
'memory.episodicMemory.reflectorModel.',
'subAgents.modelsByDifficulty.low.',
'subAgents.modelsByDifficulty.medium.',
'subAgents.modelsByDifficulty.high.',
];
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent(config));
const invalid = await service.validateAgentConfiguration(
agentId,
projectId,
makeCredentialProvider([{ id: 'vertex', type: 'googleApi' }]),
scope,
);
for (const path of paths) {
expect(invalid.issues).toContainEqual(
expect.objectContaining({ code: 'incompatible_credential', path: `${path}credential` }),
);
}
await expect(
service.validateAgentConfiguration(
agentId,
projectId,
makeCredentialProvider([{ id: 'vertex', type: 'googleVertexAiApi' }]),
scope,
),
).resolves.toEqual({ status: 'valid', issues: [] });
},
);
it('does not require a Vertex credential for disabled memory workers', async () => {
const { service, agentRepository } = makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({
...runnableConfig,
memory: {
enabled: false,
storage: 'n8n',
observationalMemory: {
observerModel: { model: 'google-vertex/gemini-3-flash-preview', credential: '' },
},
},
}),
);
await expect(
service.validateAgentConfiguration(
agentId,
projectId,
makeCredentialProvider([{ id: 'openai-main', type: 'openAiApi' }]),
),
).resolves.toEqual({ status: 'valid', issues: [] });
});
it('flags a classic Azure OpenAI credential without a deployment name', async () => {
const { service, agentRepository } = makeService();
const azureConfig: AgentJsonConfig = {
@ -401,56 +480,80 @@ describe('AgentValidationService — structured issues', () => {
]);
});
it('accepts the n8n Connect managed tag on the main model when the gateway serves the provider, else flags it', async () => {
const { service, agentRepository, aiGatewayService } = makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({ ...runnableConfig, credential: AI_GATEWAY_MANAGED_TAG }),
);
it.each([
['openai/gpt-4o', 'openAiApi'],
['google-vertex/gemini-2.5-pro', 'googleVertexAiApi'],
])(
'validates the managed main credential against cached gateway support for %s',
async (model, credentialType) => {
const { service, agentRepository, aiGatewayService } = makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({ ...runnableConfig, model, credential: AI_GATEWAY_MANAGED_TAG }),
);
// Gateway serves the model's provider → the managed credential is valid.
aiGatewayService.getCredentialTypeForProviderCached.mockReturnValue('openAiApi');
const served = await service.validateAgentConfiguration(
agentId,
projectId,
makeCredentialProvider([]),
);
expect(served.issues).not.toEqual(
expect.arrayContaining([expect.objectContaining({ path: 'credential' })]),
);
aiGatewayService.getCredentialTypeForProviderCached.mockReturnValue(credentialType);
const served = await service.validateAgentConfiguration(
agentId,
projectId,
makeCredentialProvider([]),
);
expect(served).toEqual({ status: 'valid', issues: [] });
// Cached config present but does not serve the provider → incompatible_credential.
aiGatewayService.getCredentialTypeForProviderCached.mockReturnValue(null);
const unserved = await service.validateAgentConfiguration(
agentId,
projectId,
makeCredentialProvider([]),
);
expect(unserved.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'incompatible_credential', path: 'credential' }),
]),
);
});
aiGatewayService.getCredentialTypeForProviderCached.mockReturnValue(null);
const unserved = await service.validateAgentConfiguration(
agentId,
projectId,
makeCredentialProvider([]),
);
expect(unserved).toEqual({
status: 'invalid',
issues: [
{
code: 'incompatible_credential',
path: 'credential',
capability: { kind: 'agent' },
},
],
});
},
);
it('does not flag the managed tag when gateway support cannot be determined (no cached config)', async () => {
const { service, agentRepository, aiGatewayService } = makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({ ...runnableConfig, credential: AI_GATEWAY_MANAGED_TAG }),
);
// No cached config (e.g. cold start or a throttled/failed fetch) → support is
// unknown. The static validator must fail open rather than block a working agent.
aiGatewayService.getCredentialTypeForProviderCached.mockReturnValue(undefined);
it.each(['openai/gpt-4o', 'google-vertex/gemini-2.5-pro'])(
'validates Vertex child credentials when gateway support is unknown for %s',
async (model) => {
const { service, agentRepository, aiGatewayService } = makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({
...runnableConfig,
model,
credential: AI_GATEWAY_MANAGED_TAG,
subAgents: {
modelsByDifficulty: {
low: { model: 'google-vertex/gemini-2.5-pro', credential: 'missing-vertex' },
},
},
}),
);
aiGatewayService.getCredentialTypeForProviderCached.mockReturnValue(undefined);
const result = await service.validateAgentConfiguration(
agentId,
projectId,
makeCredentialProvider([]),
);
const result = await service.validateAgentConfiguration(
agentId,
projectId,
makeCredentialProvider([]),
);
expect(result.issues).not.toEqual(
expect.arrayContaining([expect.objectContaining({ path: 'credential' })]),
);
});
expect(result).toEqual({
status: 'invalid',
issues: [
{
code: 'invalid_credential',
path: 'subAgents.modelsByDifficulty.low.credential',
capability: { kind: 'agent' },
},
],
});
},
);
it('flags a node tool missing a required credential slot but accepts one with the slot configured and accessible', async () => {
const { service, agentRepository, nodeTypes } = makeService();

View File

@ -60,6 +60,9 @@ import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
import { SubAgentRunner } from '../sub-agents/sub-agent-runner';
import { storedRunSource } from './test-utils/stored-agent-config';
const { getCachedCatalog } = vi.hoisted(() => ({ getCachedCatalog: vi.fn() }));
vi.mock('@n8n/agents/catalog', () => ({ getCachedCatalog }));
// Mock buildFromJson so reconstruction doesn't try to actually build an agent.
const builtAgent = mock<agents.Agent>();
builtAgent.hasCheckpointStorage.mockReturnValue(true); // skip checkpoint injection branch
@ -682,8 +685,26 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
});
it('resolves subAgents.modelsByDifficulty into delegate tool metadata', async () => {
getCachedCatalog.mockResolvedValue({
'google-vertex': {
models: {
'gemini-3-flash-preview': { sdk: '@ai-sdk/google-vertex' },
},
},
});
const credentialProvider = mock<CredentialProvider>();
credentialProvider.list.mockResolvedValue([
{ id: 'vertex-cred', name: 'Vertex', type: 'googleVertexAiApi' },
]);
credentialProvider.resolve.mockImplementation(async (credentialId: string) => {
if (credentialId === 'vertex-cred') {
return {
email: 'agent@example.test',
privateKey: 'private-key',
region: 'europe-west1',
project: 'child-project',
};
}
if (credentialId === 'low-cred') {
return { apiKey: 'low-key', url: 'https://low.example/v1' };
}
@ -697,6 +718,10 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
subAgents: {
modelsByDifficulty: {
low: { model: 'openai/gpt-4o-mini', credential: 'low-cred' },
medium: {
model: 'google-vertex/gemini-3-flash-preview',
credential: 'vertex-cred',
},
high: { model: 'anthropic/claude-sonnet-4-5', credential: 'high-cred' },
},
},
@ -714,6 +739,14 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
apiKey: 'low-key',
baseURL: 'https://low.example/v1',
},
medium: {
id: 'google-vertex/gemini-3-flash-preview',
sdk: '@ai-sdk/google-vertex',
project: 'child-project',
location: 'europe-west1',
clientEmail: 'agent@example.test',
privateKey: 'private-key',
},
high: {
id: 'anthropic/claude-sonnet-4-5',
apiKey: 'high-key',

View File

@ -13,6 +13,9 @@ import { buildFromJson, buildProviderToolsForModel } from '../json-config/from-j
import type { ToolExecutor } from '../json-config/from-json-config';
import { buildVectorStore } from '../json-config/vector-store-factory';
const { getCachedCatalog } = vi.hoisted(() => ({ getCachedCatalog: vi.fn() }));
vi.mock('@n8n/agents/catalog', () => ({ getCachedCatalog }));
vi.mock('../json-config/vector-store-factory', () => ({
buildVectorStore: vi.fn(),
}));
@ -1206,22 +1209,43 @@ describe('buildFromJson()', () => {
expect(getMemoryConfig(agent)?.titleGeneration?.sync).toBe(true);
});
it('configures observational memory worker models with their own credentials', async () => {
it('configures agent and memory worker models with their own credentials and projects', async () => {
getCachedCatalog.mockResolvedValue({
'google-vertex': {
models: {
'gemini-3.1-pro-preview': { sdk: '@ai-sdk/google-vertex' },
'gemini-3-flash-preview': { sdk: '@ai-sdk/google-vertex' },
},
},
});
const modelSpy = vi.spyOn(AgentsRuntime.Agent.prototype, 'model');
const observeSpy = vi.spyOn(AgentsRuntime, 'createObservationLogObserveFn');
const reflectSpy = vi.spyOn(AgentsRuntime, 'createObservationLogReflectFn');
const credentialProvider = {
resolve: vi.fn(async (credentialId: string) => ({
apiKey: `${credentialId}-api-key`,
url: `https://${credentialId}.example/v1`,
email: `${credentialId}@example.iam.gserviceaccount.com`,
privateKey: `${credentialId}-private-key`,
region: 'europe-west4',
projectId: `${credentialId}-project`,
})),
list: vi.fn().mockResolvedValue([]),
list: vi.fn().mockResolvedValue([
{ id: 'main-key', name: 'Main', type: 'googleVertexAiApi' },
{ id: 'observer-key', name: 'Observer', type: 'googleVertexAiApi' },
]),
};
const config = makeConfig({
model: 'google-vertex/gemini-3.1-pro-preview',
credential: 'main-key',
memory: {
enabled: true,
storage: 'n8n',
observationalMemory: {
observerModel: { model: 'openai/gpt-4o-mini', credential: 'observer-key' },
observerModel: {
model: 'google-vertex/gemini-3-flash-preview',
credential: 'observer-key',
},
reflectorModel: {
model: 'anthropic/claude-sonnet-4-5',
credential: 'reflector-key',
@ -1240,10 +1264,21 @@ describe('buildFromJson()', () => {
},
);
expect(modelSpy).toHaveBeenCalledWith({
id: 'google-vertex/gemini-3.1-pro-preview',
sdk: '@ai-sdk/google-vertex',
project: 'main-key-project',
clientEmail: 'main-key@example.iam.gserviceaccount.com',
privateKey: 'main-key-private-key',
location: 'europe-west4',
});
expect(observeSpy).toHaveBeenCalledWith({
id: 'openai/gpt-4o-mini',
apiKey: 'observer-key-api-key',
baseURL: 'https://observer-key.example/v1',
id: 'google-vertex/gemini-3-flash-preview',
sdk: '@ai-sdk/google-vertex',
project: 'observer-key-project',
clientEmail: 'observer-key@example.iam.gserviceaccount.com',
privateKey: 'observer-key-private-key',
location: 'europe-west4',
});
expect(reflectSpy).toHaveBeenCalledWith({
id: 'anthropic/claude-sonnet-4-5',

View File

@ -5,23 +5,33 @@ import { filterOfferedAgentModelProviders } from '../model-catalog';
describe('filterOfferedAgentModelProviders', () => {
it('keeps only providers offered by the agents UI', () => {
const catalog = {
openai: { id: 'openai' },
groq: { id: 'groq' },
unsupported: { id: 'unsupported' },
'another-provider': { id: 'another-provider' },
openai: { id: 'openai', name: 'OpenAI', models: {} },
groq: { id: 'groq', name: 'Groq', models: {} },
'google-vertex': {
id: 'google-vertex',
name: 'Google Vertex AI',
models: {
'gemini-2.5-pro': { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', toolCall: false },
},
},
unsupported: { id: 'unsupported', name: 'Unsupported', models: {} },
'another-provider': { id: 'another-provider', name: 'Another', models: {} },
};
const filtered = filterOfferedAgentModelProviders(catalog);
expect(filtered).toEqual({
openai: { id: 'openai' },
groq: { id: 'groq' },
openai: catalog.openai,
groq: catalog.groq,
'google-vertex': catalog['google-vertex'],
});
});
it('preserves the shared provider order', () => {
const catalog = Object.fromEntries(
[...AGENT_MODEL_PROVIDERS].reverse().map((provider) => [provider, { id: provider }]),
[...AGENT_MODEL_PROVIDERS]
.reverse()
.map((provider) => [provider, { id: provider, name: provider, models: {} }]),
);
expect(Object.keys(filterOfferedAgentModelProviders(catalog))).toEqual(AGENT_MODEL_PROVIDERS);

View File

@ -301,6 +301,7 @@ export class AgentValidationService {
this.collectCoreIssues(config, issues);
this.collectVectorStoreIssues(config, issues);
await this.collectMainCredentialIssues(config, findCredential, ctx.credentialProvider, issues);
await this.collectVertexModelIssues(config, findCredential, issues);
this.collectSubAgentRefIssues(ctx, agentsById, issues);
this.collectSkillIssues(config, ctx.skills, issues);
if (scope === 'publish') {
@ -422,6 +423,48 @@ export class AgentValidationService {
await this.collectAzureDeploymentIssues(config, credentialId, credentialProvider, issues);
}
private async collectVertexModelIssues(
config: AgentJsonConfig,
findCredential: FindCredential,
issues: AgentConfigValidationIssue[],
): Promise<void> {
type ModelSlot = Pick<AgentJsonConfig, 'model' | 'credential'>;
const slots: Array<[string, ModelSlot | undefined]> = [
...Object.entries(config.subAgents?.modelsByDifficulty ?? {}).map(
([difficulty, model]): [string, ModelSlot] => [
`subAgents.modelsByDifficulty.${difficulty}.`,
model,
],
),
];
if (config.memory?.enabled) {
const observational = config.memory.observationalMemory;
if (observational?.enabled !== false) {
slots.push(
['memory.observationalMemory.observerModel.', observational?.observerModel],
['memory.observationalMemory.reflectorModel.', observational?.reflectorModel],
);
}
const episodic = config.memory.episodicMemory;
if (episodic?.enabled) {
slots.push(['memory.episodicMemory.reflectorModel.', episodic.reflectorModel]);
}
}
for (const [path, model] of slots) {
if (!model || getProviderPrefix(model.model) !== 'google-vertex') continue;
if (!model.credential?.trim()) {
issues.push(agentIssue('missing_credential', `${path}credential`));
continue;
}
const credential = await this.findCredentialSafe(findCredential, model.credential.trim());
if (!credential) {
issues.push(agentIssue('invalid_credential', `${path}credential`));
} else if (!this.credentialSupportsModel(credential.type, model.model)) {
issues.push(agentIssue('incompatible_credential', `${path}credential`));
}
}
}
private collectSubAgentRefIssues(
ctx: ConfigurationValidationContext,
agentsById: Map<string, Pick<Agent, 'id'>>,

View File

@ -7,7 +7,98 @@ import {
type AiGatewayModelCredentialResolver,
} from '../model-config';
const { getCachedCatalog } = vi.hoisted(() => ({ getCachedCatalog: vi.fn() }));
vi.mock('@n8n/agents/catalog', () => ({ getCachedCatalog }));
describe('resolveCredentialAwareModelConfig', () => {
it.each([
{
model: 'google-vertex/gemini-2.5-pro',
sdk: '@ai-sdk/google-vertex',
project: undefined,
projectId: 'custom-project',
expectedProject: 'custom-project',
},
{
model: 'google-vertex/claude-opus-5@default',
sdk: '@ai-sdk/google-vertex/anthropic',
project: '__custom__',
projectId: 'custom-project',
expectedProject: 'custom-project',
},
{
model: 'google-vertex/mistral-medium-3',
sdk: '@ai-sdk/mistral',
project: 'listed-project',
projectId: 'old-custom-project',
expectedProject: 'listed-project',
},
{
model: 'google-vertex/xai/grok-4.20-reasoning',
sdk: '@ai-sdk/openai-compatible',
project: 'listed-project',
projectId: undefined,
expectedProject: 'listed-project',
},
{
model: 'google-vertex/gemini-flash-latest',
sdk: undefined,
project: 'listed-project',
projectId: undefined,
expectedProject: 'listed-project',
},
])(
'uses the catalog SDK and Vertex credential for $model',
async ({ model, sdk, project, projectId, expectedProject }) => {
getCachedCatalog.mockResolvedValue(
sdk
? {
'google-vertex': {
models: { [model.slice('google-vertex/'.length)]: { sdk } },
},
}
: undefined,
);
const credentialProvider = mock<CredentialProvider>();
credentialProvider.list.mockResolvedValue([
{ id: 'vertex', name: 'Vertex', type: 'googleVertexAiApi' },
]);
credentialProvider.resolve.mockResolvedValue({
email: 'agent@example.iam.gserviceaccount.com',
privateKey: 'private-key',
region: 'europe-west4',
project,
projectId,
});
await expect(
resolveCredentialAwareModelConfig(model, 'vertex', credentialProvider),
).resolves.toEqual({
id: model,
project: expectedProject,
location: 'europe-west4',
clientEmail: 'agent@example.iam.gserviceaccount.com',
privateKey: 'private-key',
...(sdk !== undefined && { sdk }),
});
},
);
it.each(['googleApi', 'googlePalmApi'])(
'rejects an incompatible Vertex credential: %s',
async (type) => {
const credentialProvider = mock<CredentialProvider>();
credentialProvider.list.mockResolvedValue([{ id: 'gcp', name: 'GCP', type }]);
await expect(
resolveCredentialAwareModelConfig(
'google-vertex/gemini-2.5-pro',
'gcp',
credentialProvider,
),
).rejects.toThrow('Google Vertex AI credential');
expect(credentialProvider.resolve).not.toHaveBeenCalled();
},
);
// AGENT-1182: the credential URL serves a different API from the native gateway SDK.
it.each(['https://ai-gateway.vercel.sh/v1', 'https://ai-gateway.vercel.sh/v1/'])(
'uses the native Vercel SDK endpoint for the standard credential URL %s',

View File

@ -20,6 +20,12 @@ const PROVIDER_CREDENTIAL_MAPPERS: Record<string, CredMapper> = {
anthropic: (c) => ({ apiKey: c.apiKey, baseURL: c.url }),
// GooglePalmApi.credentials.ts → apiKey, host (base URL)
google: (c) => ({ apiKey: c.apiKey, baseURL: c.host }),
'google-vertex': (c) => ({
project: (c.project ?? '__custom__') === '__custom__' ? c.projectId : c.project,
clientEmail: c.email,
privateKey: c.privateKey,
location: c.region,
}),
// XAiApi.credentials.ts → apiKey, url (hidden, base URL)
xai: (c) => ({ apiKey: c.apiKey, baseURL: c.url }),
// GroqApi.credentials.ts → apiKey only

View File

@ -1,5 +1,5 @@
import type { CredentialProvider, ModelConfig, ResolvedCredential } from '@n8n/agents';
import { getProviderPrefix } from '@n8n/ai-utilities/agent-config';
import { splitModelId } from '@n8n/ai-utilities/agent-config';
import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
import { UserError } from 'n8n-workflow';
@ -27,7 +27,15 @@ export async function resolveCredentialAwareModelConfig(
*/
deploymentName?: string,
): Promise<ModelConfig> {
const provider = getProviderPrefix(model);
const { provider, model: modelName } = splitModelId(model);
if (provider === 'google-vertex') {
const selected = (await credentialProvider.list()).find((entry) => entry.id === credential);
if (selected?.type !== 'googleVertexAiApi') {
throw new UserError(
'This model requires a Google Vertex AI credential. Select a compatible credential.',
);
}
}
if (credential === AI_GATEWAY_MANAGED_TAG) {
if (!credentialProvider.resolveAiGatewayModelCredential) {
@ -49,6 +57,12 @@ export async function resolveCredentialAwareModelConfig(
const raw = await credentialProvider.resolve(credential);
const mapped = mapCredentialForProvider(provider, raw);
if (provider === 'google-vertex') {
const { getCachedCatalog } = await import('@n8n/agents/catalog');
const catalog = await getCachedCatalog();
const sdk = catalog?.[provider]?.models[modelName]?.sdk;
return { id: model, ...mapped, ...(sdk !== undefined && { sdk }) };
}
return {
id: model,
...mapped,

View File

@ -234,6 +234,18 @@ vi.mock('../composables/useModelCatalog', () => ({
}));
const modelsByProvider: AgentModelsByProvider = {
'google-vertex': {
models: [
{
provider: 'google-vertex',
model: 'gemini-3-flash-preview',
name: 'Gemini 3 Flash Preview',
description: null,
createdAt: null,
metadata: { functionCalling: true, available: true },
},
],
},
anthropic: {
models: [
{
@ -817,6 +829,32 @@ describe('AgentModelSelector', () => {
expect(modelsHeader?.label).toBe('Models');
});
it('offers only Vertex AI credentials and selects a model without a separate project', async () => {
credentialsByType.value = {
googleApi: [{ id: 'service-account', name: 'Service account', type: 'googleApi' }],
googlePalmApi: [{ id: 'gemini', name: 'Gemini', type: 'googlePalmApi' }],
googleVertexAiApi: [{ id: 'vertex', name: 'Vertex', type: 'googleVertexAiApi' }],
};
const wrapper = await mountSelector({ 'google-vertex': 'vertex' }, { selectedModel: null });
const children = getProviderItem(wrapper, 'google-vertex')?.children ?? [];
expect(
children.filter((item) => item.id.includes('::select::')).map((item) => item.id),
).toEqual(['google-vertex::select::vertex']);
expect(children.filter((item) => item.header).map((item) => item.label)).toEqual([
'Connect to Google Vertex AI',
'Models',
]);
expect(children.some((item) => item.id === 'google-vertex::configure::googleVertexAiApi')).toBe(
true,
);
getDropdown(wrapper).vm.$emit('select', 'google-vertex::model::gemini-3-flash-preview');
expect(wrapper.emitted('change')).toEqual([
[{ provider: 'google-vertex', model: 'gemini-3-flash-preview' }],
]);
});
it('checks the active credential row', async () => {
const wrapper = await mountSelector({ anthropic: 'anthropic-cred' });
const credentialItem = getMenuItemByLabel(

View File

@ -17,11 +17,15 @@ describe('model-string utils', () => {
name: 'models/gemini-1.5-pro',
});
});
it('keeps colons in the model name', () => {
expect(parseModelString('aws-bedrock/anthropic.claude-sonnet-4-5-v1:0')).toEqual({
provider: 'aws-bedrock',
name: 'anthropic.claude-sonnet-4-5-v1:0',
});
it.each([
[
'aws-bedrock/anthropic.claude-sonnet-4-5-v1:0',
'aws-bedrock',
'anthropic.claude-sonnet-4-5-v1:0',
],
['google-vertex/claude-fable-5@default', 'google-vertex', 'claude-fable-5@default'],
])('preserves the version in %s', (model, provider, name) => {
expect(parseModelString(model)).toEqual({ provider, name });
});
it('returns null when no slash', () => {
expect(parseModelString('gpt-4o')).toBeNull();
@ -29,12 +33,15 @@ describe('model-string utils', () => {
it('returns null when slash is leading', () => {
expect(parseModelString('/name')).toBeNull();
});
it.each(['openai/', 'openai//gpt-4o', 'openai/gpt-4o/', 'openai/gpt 4o'])(
'returns null for invalid model string %s',
(model) => {
expect(parseModelString(model)).toBeNull();
},
);
it.each([
'openai/',
'openai//gpt-4o',
'openai/gpt-4o/',
'openai/gpt 4o',
'google@vertex/claude-fable-5',
])('returns null for invalid model string %s', (model) => {
expect(parseModelString(model)).toBeNull();
});
});
describe('parseProvider', () => {

View File

@ -41,6 +41,7 @@ export const AGENT_MODEL_PROVIDER_DEFINITIONS = {
openai: { displayName: 'OpenAI' },
anthropic: { displayName: 'Anthropic' },
google: { displayName: 'Google' },
'google-vertex': { displayName: 'Google Vertex AI' },
'azure-openai': { displayName: 'Microsoft Foundry' },
'aws-bedrock': { displayName: 'AWS Bedrock', isAggregator: true },
xai: { displayName: 'xAI' },

View File

@ -599,6 +599,9 @@ catalogs:
fflate:
specifier: 0.8.3
version: 0.8.3
google-auth-library:
specifier: 10.9.1
version: 10.9.1
http-proxy-agent:
specifier: 7.0.2
version: 7.0.2
@ -1258,6 +1261,9 @@ importers:
ajv:
specifier: 8.20.0
version: 8.20.0
google-auth-library:
specifier: 'catalog:'
version: 10.9.1(supports-color@8.1.1)
langsmith:
specifier: 0.6.0
version: 0.6.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(openai@6.46.0(@aws-sdk/credential-provider-node@3.972.80)(@smithy/signature-v4@5.7.2)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))

View File

@ -291,6 +291,7 @@ catalog:
zod-to-json-schema: 3.23.3
knip: ^6.35.1
openapi-types: 12.1.3
google-auth-library: 10.9.1
catalogMode: strict