mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-16 21:19:43 +02:00
feat(core): Add Google Vertex AI support for agents (no-changelog) (#40545)
This commit is contained in:
parent
e16066c322
commit
bd22105646
|
|
@ -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:",
|
||||
|
|
|
|||
188
packages/@n8n/agents/src/runtime/__tests__/google-vertex.test.ts
Normal file
188
packages/@n8n/agents/src/runtime/__tests__/google-vertex.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
176
packages/@n8n/agents/src/runtime/model/google-vertex.ts
Normal file
176
packages/@n8n/agents/src/runtime/model/google-vertex.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export class GoogleRawErrorReader implements RawErrorReader {
|
|||
*/
|
||||
const RAW_ERROR_READERS: Partial<Record<ProviderId, () => RawErrorReader>> = {
|
||||
google: () => new GoogleRawErrorReader(),
|
||||
'google-vertex': () => new GoogleRawErrorReader(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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]?.();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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")',
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'>>,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user