diff --git a/packages/@n8n/agents/package.json b/packages/@n8n/agents/package.json index d8dc37ed500..3498ea34bfc 100644 --- a/packages/@n8n/agents/package.json +++ b/packages/@n8n/agents/package.json @@ -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:", diff --git a/packages/@n8n/agents/src/runtime/__tests__/google-vertex.test.ts b/packages/@n8n/agents/src/runtime/__tests__/google-vertex.test.ts new file mode 100644 index 00000000000..d14f9fb52b6 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/__tests__/google-vertex.test.ts @@ -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() + .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().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/); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts b/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts index 2d7960ae0c6..92c4d3e5f94 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/model-factory.test.ts @@ -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) => (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(); + 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( diff --git a/packages/@n8n/agents/src/runtime/loop/__tests__/runtime-context.test.ts b/packages/@n8n/agents/src/runtime/loop/__tests__/runtime-context.test.ts index 301c89bd10e..790b4e2e0e5 100644 --- a/packages/@n8n/agents/src/runtime/loop/__tests__/runtime-context.test.ts +++ b/packages/@n8n/agents/src/runtime/loop/__tests__/runtime-context.test.ts @@ -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); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/loop/runtime-context.ts b/packages/@n8n/agents/src/runtime/loop/runtime-context.ts index 62e3102026f..0f37eaef52d 100644 --- a/packages/@n8n/agents/src/runtime/loop/runtime-context.ts +++ b/packages/@n8n/agents/src/runtime/loop/runtime-context.ts @@ -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> | 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); } diff --git a/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts b/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts index 04f35753546..75a7b4d58e2 100644 --- a/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts +++ b/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts @@ -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() }; diff --git a/packages/@n8n/agents/src/runtime/model/__tests__/raw-usage.test.ts b/packages/@n8n/agents/src/runtime/model/__tests__/raw-usage.test.ts index fd54ec6e5aa..5ecb1236ae5 100644 --- a/packages/@n8n/agents/src/runtime/model/__tests__/raw-usage.test.ts +++ b/packages/@n8n/agents/src/runtime/model/__tests__/raw-usage.test.ts @@ -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(); diff --git a/packages/@n8n/agents/src/runtime/model/google-vertex.ts b/packages/@n8n/agents/src/runtime/model/google-vertex.ts new file mode 100644 index 00000000000..6334b8e0e80 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/model/google-vertex.ts @@ -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 } | 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 }; +} diff --git a/packages/@n8n/agents/src/runtime/model/model-factory.ts b/packages/@n8n/agents/src/runtime/model/model-factory.ts index 0f460104cb4..ad2fa65fea2 100644 --- a/packages/@n8n/agents/src/runtime/model/model-factory.ts +++ b/packages/@n8n/agents/src/runtime/model/model-factory.ts @@ -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; }; -/** - * 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 } | 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) }; - // 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'); diff --git a/packages/@n8n/agents/src/runtime/model/prompt-cache.ts b/packages/@n8n/agents/src/runtime/model/prompt-cache.ts index a61524ec697..008a2d3ac17 100644 --- a/packages/@n8n/agents/src/runtime/model/prompt-cache.ts +++ b/packages/@n8n/agents/src/runtime/model/prompt-cache.ts @@ -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) + ); } /** diff --git a/packages/@n8n/agents/src/runtime/model/provider-credentials.ts b/packages/@n8n/agents/src/runtime/model/provider-credentials.ts index d2f118e3e16..7de7586c8fa 100644 --- a/packages/@n8n/agents/src/runtime/model/provider-credentials.ts +++ b/packages/@n8n/agents/src/runtime/model/provider-credentials.ts @@ -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, diff --git a/packages/@n8n/agents/src/runtime/model/raw-error.ts b/packages/@n8n/agents/src/runtime/model/raw-error.ts index 4a60e4eb114..03a620bfcd2 100644 --- a/packages/@n8n/agents/src/runtime/model/raw-error.ts +++ b/packages/@n8n/agents/src/runtime/model/raw-error.ts @@ -53,6 +53,7 @@ export class GoogleRawErrorReader implements RawErrorReader { */ const RAW_ERROR_READERS: Partial RawErrorReader>> = { google: () => new GoogleRawErrorReader(), + 'google-vertex': () => new GoogleRawErrorReader(), }; /** diff --git a/packages/@n8n/agents/src/runtime/model/raw-usage.ts b/packages/@n8n/agents/src/runtime/model/raw-usage.ts index 80af11fc853..e13d8848647 100644 --- a/packages/@n8n/agents/src/runtime/model/raw-usage.ts +++ b/packages/@n8n/agents/src/runtime/model/raw-usage.ts @@ -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 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]?.(); } diff --git a/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts b/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts index b65390367e7..1dc8e65465d 100644 --- a/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts +++ b/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts @@ -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'], diff --git a/packages/@n8n/agents/src/sdk/catalog.ts b/packages/@n8n/agents/src/sdk/catalog.ts index 14aa91adadb..b2410db3657 100644 --- a/packages/@n8n/agents/src/sdk/catalog.ts +++ b/packages/@n8n/agents/src/sdk/catalog.ts @@ -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; 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 { // 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; diff --git a/packages/@n8n/agents/vite.config.ts b/packages/@n8n/agents/vite.config.ts index 214d24a885f..27a6abe2922 100644 --- a/packages/@n8n/agents/vite.config.ts +++ b/packages/@n8n/agents/vite.config.ts @@ -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 { diff --git a/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts index c6eee32461d..c7251b52cdf 100644 --- a/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts @@ -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', () => { diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index 9149059f32e..02841edfca2 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -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")', diff --git a/packages/@n8n/api-types/src/agents/model-providers.ts b/packages/@n8n/api-types/src/agents/model-providers.ts index cc900cdeb36..490fda8fb18 100644 --- a/packages/@n8n/api-types/src/agents/model-providers.ts +++ b/packages/@n8n/api-types/src/agents/model-providers.ts @@ -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'], diff --git a/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts index 4b092df83b0..3ddf90329c2 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts @@ -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(); diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts index edda11e9f6b..36f50dea0cf 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts @@ -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(); 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.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', diff --git a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts index b5949772ee3..a7524e0097b 100644 --- a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts @@ -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', diff --git a/packages/cli/src/modules/agents/__tests__/model-catalog.test.ts b/packages/cli/src/modules/agents/__tests__/model-catalog.test.ts index 68e873e44b0..13220bc35b8 100644 --- a/packages/cli/src/modules/agents/__tests__/model-catalog.test.ts +++ b/packages/cli/src/modules/agents/__tests__/model-catalog.test.ts @@ -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); diff --git a/packages/cli/src/modules/agents/agent-validation.service.ts b/packages/cli/src/modules/agents/agent-validation.service.ts index 5ab8f3abe37..5134d2c087d 100644 --- a/packages/cli/src/modules/agents/agent-validation.service.ts +++ b/packages/cli/src/modules/agents/agent-validation.service.ts @@ -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 { + type ModelSlot = Pick; + 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>, diff --git a/packages/cli/src/modules/agents/json-config/__tests__/model-config.test.ts b/packages/cli/src/modules/agents/json-config/__tests__/model-config.test.ts index 7db6240c4c4..219b9caa92a 100644 --- a/packages/cli/src/modules/agents/json-config/__tests__/model-config.test.ts +++ b/packages/cli/src/modules/agents/json-config/__tests__/model-config.test.ts @@ -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.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.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', diff --git a/packages/cli/src/modules/agents/json-config/credential-field-mapping.ts b/packages/cli/src/modules/agents/json-config/credential-field-mapping.ts index ed390e70bb8..94b3863cd5a 100644 --- a/packages/cli/src/modules/agents/json-config/credential-field-mapping.ts +++ b/packages/cli/src/modules/agents/json-config/credential-field-mapping.ts @@ -20,6 +20,12 @@ const PROVIDER_CREDENTIAL_MAPPERS: Record = { 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 diff --git a/packages/cli/src/modules/agents/json-config/model-config.ts b/packages/cli/src/modules/agents/json-config/model-config.ts index 8397877aa7f..1d887a783b9 100644 --- a/packages/cli/src/modules/agents/json-config/model-config.ts +++ b/packages/cli/src/modules/agents/json-config/model-config.ts @@ -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 { - 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, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts index 714e7e668e3..d014529a225 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts @@ -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( diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/model-string.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/model-string.test.ts index 15d5e08e2b1..2152eaa8334 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/model-string.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/model-string.test.ts @@ -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', () => { diff --git a/packages/frontend/editor-ui/src/features/agents/model-providers.ts b/packages/frontend/editor-ui/src/features/agents/model-providers.ts index 31e541803b0..6c8a2976142 100644 --- a/packages/frontend/editor-ui/src/features/agents/model-providers.ts +++ b/packages/frontend/editor-ui/src/features/agents/model-providers.ts @@ -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' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 331d5ce6831..05f2cb90b96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ec19b76ac62..87e5f6b6311 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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