diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index c5369b44cfb..94291b170d0 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -248,6 +248,7 @@ export type { ModelInfo, ModelCost, ModelLimits, + ModelModalities, } from './sdk/catalog'; export { BaseMemory } from './storage/base-memory'; export { BaseVectorStore } from './storage/base-vector-store'; diff --git a/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts b/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts index dcde696c044..129138ce64c 100644 --- a/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts +++ b/packages/@n8n/agents/src/sdk/__tests__/catalog.test.ts @@ -9,6 +9,7 @@ describe('fetchProviderCatalog', () => { }); it('returns provider ids that match the agents runtime', async () => { + const abortController = new AbortController(); const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => @@ -21,6 +22,7 @@ describe('fetchProviderCatalog', () => { id: 'gpt-5', name: 'GPT-5', tool_call: true, + modalities: { input: ['text', 'image'], output: ['text'] }, }, 'gpt-4.1-mini': { id: 'gpt-4.1-mini', @@ -63,9 +65,13 @@ describe('fetchProviderCatalog', () => { }); global.fetch = fetchMock as typeof fetch; - const catalog = await fetchProviderCatalog(); + const catalog = await fetchProviderCatalog({ signal: abortController.signal }); expect(catalog.openai.models['gpt-5'].toolCall).toBe(true); + expect(catalog.openai.models['gpt-5'].modalities).toEqual({ + input: ['text', 'image'], + output: ['text'], + }); expect(catalog.openai.models['gpt-5']).not.toHaveProperty('reasoning'); expect(catalog.openai.models['gpt-4.1-mini'].reasoning).toBe(false); expect(catalog['aws-bedrock'].models['anthropic.claude-sonnet-4-5-v1:0'].name).toBe( @@ -76,6 +82,63 @@ describe('fetchProviderCatalog', () => { expect(catalog['amazon-bedrock']).toBeUndefined(); expect(catalog.azure).toBeUndefined(); expect(catalog['azure-cognitive-services']).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith('https://models.dev/api.json', { + signal: abortController.signal, + }); + }); + + it('rejects a malformed catalog response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => await Promise.resolve([]), + }); + global.fetch = fetchMock as typeof fetch; + + await expect(fetchProviderCatalog()).rejects.toThrow(); + }); + + it('omits malformed providers and models while retaining valid entries', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => + await Promise.resolve({ + invalidProvider: { + id: 'invalid-provider', + models: {}, + }, + openai: { + id: 'openai', + name: 'OpenAI', + models: { + 'gpt-valid': { + id: 'gpt-valid', + name: 'GPT Valid', + tool_call: true, + }, + 'gpt-missing-name': { + id: 'gpt-missing-name', + }, + 'gpt-invalid-tool-call': { + id: 'gpt-invalid-tool-call', + name: 'GPT Invalid Tool Call', + tool_call: 'yes', + }, + }, + }, + }), + }); + global.fetch = fetchMock as typeof fetch; + + const catalog = await fetchProviderCatalog(); + + expect(catalog.invalidProvider).toBeUndefined(); + expect(catalog.openai.models).toEqual({ + 'gpt-valid': { + id: 'gpt-valid', + name: 'GPT Valid', + toolCall: true, + }, + }); }); it('drops models marked deprecated on models.dev', async () => { diff --git a/packages/@n8n/agents/src/sdk/catalog.ts b/packages/@n8n/agents/src/sdk/catalog.ts index 6842c5918e3..3ec77e0e42a 100644 --- a/packages/@n8n/agents/src/sdk/catalog.ts +++ b/packages/@n8n/agents/src/sdk/catalog.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + const MODELS_DEV_URL = 'https://models.dev/api.json'; const MODELS_DEV_PROVIDER_ALIASES: Record = { @@ -31,6 +33,14 @@ export interface ModelLimits { output?: number; } +/** Input and output types supported by a model. */ +export interface ModelModalities { + /** Supported input types. */ + input?: string[]; + /** Supported output types. */ + output?: string[]; +} + /** Information about a single model. */ export interface ModelInfo { /** Model ID (e.g. 'claude-sonnet-4-5'). */ @@ -43,6 +53,8 @@ export interface ModelInfo { reasoning?: boolean; /** Whether the model supports tool calling. */ toolCall: boolean; + /** Input and output types supported by the model. */ + modalities?: ModelModalities; /** Cost per million tokens. */ cost?: ModelCost; /** Token limits. */ @@ -62,22 +74,42 @@ export interface ProviderInfo { /** The full catalog of providers and their models. */ export type ProviderCatalog = Record; -interface ModelsDevModel { - id: string; - name: string; - release_date?: string; - reasoning?: boolean; - tool_call?: boolean; - status?: string; - cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number }; - limit?: { context?: number; output?: number }; -} +const modelsDevModelSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + release_date: z.string().optional(), + reasoning: z.boolean().optional(), + tool_call: z.boolean().optional(), + status: z.string().optional(), + modalities: z + .object({ + input: z.array(z.string()).optional(), + output: z.array(z.string()).optional(), + }) + .optional(), + cost: z + .object({ + input: z.number().optional(), + output: z.number().optional(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + limit: z + .object({ + context: z.number().optional(), + output: z.number().optional(), + }) + .optional(), +}); -interface ModelsDevProvider { - id: string; - name: string; - models?: Record; -} +const modelsDevProviderSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + models: z.record(z.string(), z.unknown()).optional(), +}); + +const modelsDevCatalogSchema = z.record(z.string(), z.unknown()); function toAgentProviderId(modelsDevProviderId: string): string { return MODELS_DEV_PROVIDER_ALIASES[modelsDevProviderId] ?? modelsDevProviderId; @@ -125,20 +157,28 @@ function normalizeLatestModelNames(models: Record): void { * console.log(catalog.anthropic.models['claude-sonnet-4-5'].reasoning); // true * ``` */ -export async function fetchProviderCatalog(): Promise { - const response = await fetch(MODELS_DEV_URL); +export async function fetchProviderCatalog(options?: { + signal?: AbortSignal; +}): Promise { + const response = await fetch(MODELS_DEV_URL, { signal: options?.signal }); if (!response.ok) { throw new Error(`Failed to fetch provider catalog: ${response.statusText}`); } - const data = (await response.json()) as Record; + const data = modelsDevCatalogSchema.parse(await response.json()); const catalog: ProviderCatalog = {}; - for (const [key, provider] of Object.entries(data)) { + for (const [key, rawProvider] of Object.entries(data)) { + const providerResult = modelsDevProviderSchema.safeParse(rawProvider); + if (!providerResult.success) continue; + const provider = providerResult.data; if (!provider.models || Object.keys(provider.models).length === 0) continue; const models: Record = {}; - for (const [modelId, model] of Object.entries(provider.models)) { + for (const [modelId, rawModel] of Object.entries(provider.models)) { + const modelResult = modelsDevModelSchema.safeParse(rawModel); + if (!modelResult.success) continue; + const model = modelResult.data; // Deprecated models still 404 at call time when the provider retires // them, so never offer them. if (model.status === 'deprecated') continue; @@ -148,6 +188,7 @@ export async function fetchProviderCatalog(): Promise { ...(model.release_date !== undefined && { releaseDate: model.release_date }), ...(model.reasoning !== undefined && { reasoning: model.reasoning }), toolCall: model.tool_call ?? false, + ...(model.modalities !== undefined && { modalities: model.modalities }), }; if (model.cost?.input !== undefined && model.cost?.output !== undefined) { info.cost = { diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index f6944ea299a..8cebfb7896e 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -348,6 +348,7 @@ export { isInstanceAiSandboxProvider, INSTANCE_AI_MODEL_CREDENTIAL_TYPES, INSTANCE_AI_SEARCH_CREDENTIAL_TYPES, + INSTANCE_AI_CATALOG_PROVIDERS, GATEWAY_CONFIRMATION_REQUIRED_PREFIX, InstanceAiSendMessageRequest, InstanceAiEvalExecutionRequest, @@ -388,6 +389,9 @@ export type { InstanceAiThreadSource, InstanceAiThreadSourcePersisted, InstanceAiThreadOrigin, + InstanceAiCatalogProvider, + InstanceAiCatalogModel, + InstanceAiModelCatalogResponse, } from './schemas/instance-ai.schema'; export type { diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index bda0d92e3e6..ff4cfc81e10 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -1505,6 +1505,19 @@ export const INSTANCE_AI_MODEL_CREDENTIAL_TYPES = [ export const INSTANCE_AI_SEARCH_CREDENTIAL_TYPES = ['braveSearchApi', 'searXngApi'] as const; +export const INSTANCE_AI_CATALOG_PROVIDERS = ['anthropic', 'openai', 'openrouter'] as const; +export type InstanceAiCatalogProvider = (typeof INSTANCE_AI_CATALOG_PROVIDERS)[number]; + +export interface InstanceAiCatalogModel { + id: string; + name: string; + releaseDate?: string; +} + +export interface InstanceAiModelCatalogResponse { + models: Record; +} + export interface InstanceAiEnvManagedFields { model: { provider: boolean; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-model-catalog.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-model-catalog.service.test.ts new file mode 100644 index 00000000000..97b2f606101 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-model-catalog.service.test.ts @@ -0,0 +1,286 @@ +import type { ProviderCatalog } from '@n8n/agents/catalog'; +import type { Logger } from '@n8n/backend-common'; +import { mock } from 'vitest-mock-extended'; + +import { InstanceAiModelCatalogService } from '../instance-ai-model-catalog.service'; + +const fetchProviderCatalog = vi.fn<(signal: AbortSignal) => Promise>(); + +class TestInstanceAiModelCatalogService extends InstanceAiModelCatalogService { + constructor( + logger: Logger, + private readonly fetcher: (signal: AbortSignal) => Promise, + ) { + super(logger); + } + + protected override async fetchCatalog(signal: AbortSignal): Promise { + return await this.fetcher(signal); + } +} + +const catalog: ProviderCatalog = { + anthropic: { + id: 'anthropic', + name: 'Anthropic', + models: { + zulu: { + id: 'zulu', + name: 'Zulu', + releaseDate: '2026-03-01', + toolCall: true, + modalities: { output: ['text'] }, + cost: { input: 1, output: 2 }, + }, + alpha: { + id: 'alpha', + name: 'Alpha', + releaseDate: '2026-01-01', + toolCall: true, + modalities: { output: ['text'] }, + }, + beta: { + id: 'beta', + name: 'Beta', + releaseDate: '2026-01-01', + toolCall: true, + modalities: { output: ['text'] }, + }, + invalidDate: { + id: 'invalid-date', + name: 'Invalid date', + releaseDate: 'unknown', + toolCall: true, + modalities: { output: ['text'] }, + }, + undated: { + id: 'undated', + name: 'Undated', + toolCall: true, + modalities: { output: ['text'] }, + }, + 'image-only': { + id: 'image-only', + name: 'Image only', + toolCall: true, + modalities: { output: ['image'] }, + }, + 'mixed-output': { + id: 'mixed-output', + name: 'Mixed output', + toolCall: true, + modalities: { output: ['text', 'audio'] }, + }, + 'missing-modalities': { + id: 'missing-modalities', + name: 'Missing modalities', + toolCall: true, + }, + 'no-tools': { + id: 'no-tools', + name: 'No tools', + toolCall: false, + modalities: { output: ['text'] }, + }, + }, + }, + openai: { + id: 'openai', + name: 'OpenAI', + models: { + 'gpt-tools': { + id: 'gpt-tools', + name: 'GPT Tools', + toolCall: true, + modalities: { output: ['text'] }, + }, + }, + }, + openrouter: { + id: 'openrouter', + name: 'OpenRouter', + models: { + 'provider/model': { + id: 'provider/model', + name: 'Provider Model', + toolCall: true, + modalities: { output: ['text'] }, + }, + }, + }, + google: { + id: 'google', + name: 'Google', + models: { + gemini: { + id: 'gemini', + name: 'Gemini', + toolCall: true, + modalities: { output: ['text'] }, + }, + }, + }, +}; + +function makeService() { + const logger = mock(); + logger.scoped.mockReturnValue(logger); + return { + service: new TestInstanceAiModelCatalogService(logger, fetchProviderCatalog), + logger, + }; +} + +describe('InstanceAiModelCatalogService', () => { + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('returns newest compatible models first for supported providers only', async () => { + fetchProviderCatalog.mockResolvedValue(catalog); + const { service, logger } = makeService(); + + const result = await service.getModels(); + expect(fetchProviderCatalog).toHaveBeenCalledOnce(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(result).toEqual({ + models: { + anthropic: [ + { id: 'zulu', name: 'Zulu', releaseDate: '2026-03-01' }, + { id: 'alpha', name: 'Alpha', releaseDate: '2026-01-01' }, + { id: 'beta', name: 'Beta', releaseDate: '2026-01-01' }, + { id: 'invalid-date', name: 'Invalid date', releaseDate: 'unknown' }, + { id: 'undated', name: 'Undated' }, + ], + openai: [{ id: 'gpt-tools', name: 'GPT Tools' }], + openrouter: [{ id: 'provider/model', name: 'Provider Model' }], + }, + }); + }); + + it('omits malformed catalog entries', async () => { + fetchProviderCatalog.mockResolvedValue({ + anthropic: { + id: 'anthropic', + name: 'Anthropic', + models: { + missingName: { + id: 'missing-name', + toolCall: true, + modalities: { output: ['text'] }, + }, + null: null, + valid: { + id: 'valid', + name: 'Valid', + toolCall: true, + modalities: { output: ['text'] }, + }, + }, + }, + } as unknown as ProviderCatalog); + const { service } = makeService(); + + await expect(service.getModels()).resolves.toEqual({ + models: { + anthropic: [{ id: 'valid', name: 'Valid' }], + openai: [], + openrouter: [], + }, + }); + }); + + it('de-duplicates concurrent fetches and caches successful results for one hour', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-06T10:00:00Z')); + let resolveFetch: (value: ProviderCatalog) => void = () => {}; + fetchProviderCatalog.mockImplementation( + async () => + await new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const { service } = makeService(); + + const first = service.getModels(); + const second = service.getModels(); + expect(fetchProviderCatalog).toHaveBeenCalledOnce(); + resolveFetch(catalog); + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + + await service.getModels(); + expect(fetchProviderCatalog).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(60 * 60 * 1000 + 1); + fetchProviderCatalog.mockResolvedValue(catalog); + await service.getModels(); + expect(fetchProviderCatalog).toHaveBeenCalledTimes(2); + }); + + it('falls back to stale data or empty provider arrays when refresh fails', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-06T10:00:00Z')); + fetchProviderCatalog.mockResolvedValueOnce(catalog).mockRejectedValue(new Error('offline')); + const { service, logger } = makeService(); + const initial = await service.getModels(); + + vi.advanceTimersByTime(60 * 60 * 1000 + 1); + await expect(service.getModels()).resolves.toEqual(initial); + expect(logger.warn).toHaveBeenCalled(); + + fetchProviderCatalog.mockRejectedValue(new Error('still offline')); + const { service: emptyService } = makeService(); + await expect(emptyService.getModels()).resolves.toEqual({ + models: { anthropic: [], openai: [], openrouter: [] }, + }); + }); + + it('aborts a timed-out fetch before allowing a retry', async () => { + vi.useFakeTimers(); + let activeRequests = 0; + let maxActiveRequests = 0; + let firstSignal: AbortSignal | undefined; + fetchProviderCatalog + .mockImplementationOnce( + async (signal) => + await new Promise((_resolve, reject) => { + firstSignal = signal; + activeRequests++; + maxActiveRequests = Math.max(maxActiveRequests, activeRequests); + signal.addEventListener( + 'abort', + () => { + activeRequests--; + reject(new Error('aborted')); + }, + { once: true }, + ); + }), + ) + .mockImplementationOnce(async () => { + activeRequests++; + maxActiveRequests = Math.max(maxActiveRequests, activeRequests); + activeRequests--; + return catalog; + }); + const { service, logger } = makeService(); + + const request = service.getModels(); + await vi.advanceTimersByTimeAsync(5000); + + await expect(request).resolves.toEqual({ + models: { anthropic: [], openai: [], openrouter: [] }, + }); + expect(logger.warn).toHaveBeenCalledWith('Failed to load the Instance AI model catalog', { + error: 'Model catalog request timed out', + }); + expect(firstSignal?.aborted).toBe(true); + + await expect(service.getModels()).resolves.toEqual( + expect.objectContaining({ models: expect.objectContaining({ openai: expect.any(Array) }) }), + ); + expect(fetchProviderCatalog).toHaveBeenCalledTimes(2); + expect(maxActiveRequests).toBe(1); + }); +}); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts index 9ad4813f400..ee7f167ffe2 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts @@ -85,6 +85,7 @@ import type { InProcessEventBus } from '../event-bus/in-process-event-bus'; import type { LocalGateway } from '../filesystem/local-gateway'; import type { InstanceAiGatewayService } from '../instance-ai-gateway.service'; import type { InstanceAiMemoryService } from '../instance-ai-memory.service'; +import type { InstanceAiModelCatalogService } from '../instance-ai-model-catalog.service'; import type { InstanceAiSettingsService } from '../instance-ai-settings.service'; import { InstanceAiController } from '../instance-ai.controller'; import type { InstanceAiService } from '../instance-ai.service'; @@ -110,6 +111,7 @@ describe('InstanceAiController', () => { const browserSessionService = mock(); const memoryService = mock(); const settingsService = mock(); + const modelCatalogService = mock(); const eventBus = mock(); const eventLog = mock(); const durableLogMetrics = mock(); @@ -137,6 +139,7 @@ describe('InstanceAiController', () => { browserSessionService, memoryService, settingsService, + modelCatalogService, mock(), mock(), evalCredentialAllowlists, @@ -1044,6 +1047,22 @@ describe('InstanceAiController', () => { }); }); + describe('getModelCatalog', () => { + it('should require instanceAi:manage scope', () => { + expect(scopeOf('getModelCatalog')).toEqual({ + scope: 'instanceAi:manage', + globalOnly: true, + }); + }); + + it('should return the model catalog service response', async () => { + const response = { models: { anthropic: [], openai: [], openrouter: [] } }; + modelCatalogService.getModels.mockResolvedValue(response); + + await expect(controller.getModelCatalog(req)).resolves.toEqual(response); + }); + }); + describe('updateAdminSettings', () => { it('should require instanceAi:manage scope', () => { expect(scopeOf('updateAdminSettings')).toEqual({ @@ -1893,6 +1912,7 @@ describe('InstanceAiController — durable-log SSE replay (flag on)', () => { mock(), memoryService, settingsService, + mock(), mock(), mock(), new EvalThreadCredentialAllowlistService(), diff --git a/packages/cli/src/modules/instance-ai/instance-ai-model-catalog.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-model-catalog.service.ts new file mode 100644 index 00000000000..60015105900 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/instance-ai-model-catalog.service.ts @@ -0,0 +1,151 @@ +import { + INSTANCE_AI_CATALOG_PROVIDERS, + type InstanceAiCatalogModel, + type InstanceAiCatalogProvider, + type InstanceAiModelCatalogResponse, +} from '@n8n/api-types'; +import type { ProviderCatalog } from '@n8n/agents/catalog'; +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import { ensureError } from '@n8n/utils/errors/ensure-error'; + +const CATALOG_CACHE_TTL_MS = 60 * 60 * 1000; +const CATALOG_FETCH_TIMEOUT_MS = 5000; + +function emptyCatalogResponse(): InstanceAiModelCatalogResponse { + return { + models: { + anthropic: [], + openai: [], + openrouter: [], + }, + }; +} + +function compareModels(a: InstanceAiCatalogModel, b: InstanceAiCatalogModel): number { + const aReleaseTime = a.releaseDate ? Date.parse(a.releaseDate) : Number.NaN; + const bReleaseTime = b.releaseDate ? Date.parse(b.releaseDate) : Number.NaN; + const aHasReleaseDate = !Number.isNaN(aReleaseTime); + const bHasReleaseDate = !Number.isNaN(bReleaseTime); + + if (aHasReleaseDate && bHasReleaseDate && aReleaseTime !== bReleaseTime) { + return bReleaseTime - aReleaseTime; + } + if (aHasReleaseDate !== bHasReleaseDate) return aHasReleaseDate ? -1 : 1; + + return a.name.localeCompare(b.name) || a.id.localeCompare(b.id); +} + +function isEligibleModel(model: unknown): model is { + id: string; + name: string; + toolCall: true; + modalities: { output: ['text'] }; + releaseDate?: unknown; +} { + const id = typeof model === 'object' && model !== null ? Reflect.get(model, 'id') : undefined; + const name = typeof model === 'object' && model !== null ? Reflect.get(model, 'name') : undefined; + const modalities = + typeof model === 'object' && model !== null ? Reflect.get(model, 'modalities') : undefined; + const output = + typeof modalities === 'object' && modalities !== null + ? Reflect.get(modalities, 'output') + : undefined; + return ( + typeof model === 'object' && + model !== null && + Reflect.get(model, 'toolCall') === true && + Array.isArray(output) && + output.length === 1 && + output[0] === 'text' && + typeof id === 'string' && + id.length > 0 && + typeof name === 'string' && + name.length > 0 + ); +} + +@Service() +export class InstanceAiModelCatalogService { + private readonly logger: Logger; + + private cached?: { value: InstanceAiModelCatalogResponse; expiresAt: number }; + + private fetchPromise?: Promise; + + constructor(logger: Logger) { + this.logger = logger.scoped('instance-ai'); + } + + async getModels(): Promise { + if (this.cached && Date.now() < this.cached.expiresAt) return this.cached.value; + + this.fetchPromise ??= this.refresh().finally(() => { + this.fetchPromise = undefined; + }); + + return await this.fetchPromise; + } + + private async refresh(): Promise { + try { + const catalog = await this.fetchCatalogWithTimeout(); + const value = this.toResponse(catalog); + this.cached = { value, expiresAt: Date.now() + CATALOG_CACHE_TTL_MS }; + return value; + } catch (error) { + this.logger.warn('Failed to load the Instance AI model catalog', { + error: ensureError(error).message, + }); + return this.cached?.value ?? emptyCatalogResponse(); + } + } + + private async fetchCatalogWithTimeout(): Promise { + const abortController = new AbortController(); + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error('Model catalog request timed out')); + abortController.abort(); + }, CATALOG_FETCH_TIMEOUT_MS); + }); + const catalogPromise = this.fetchCatalog(abortController.signal); + void catalogPromise.catch(() => undefined); + + try { + return await Promise.race([catalogPromise, timeoutPromise]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + + protected async fetchCatalog(signal: AbortSignal): Promise { + const { fetchProviderCatalog } = await import('@n8n/agents/catalog'); + return await fetchProviderCatalog({ signal }); + } + + private toResponse(catalog: ProviderCatalog): InstanceAiModelCatalogResponse { + const response = emptyCatalogResponse(); + + for (const provider of INSTANCE_AI_CATALOG_PROVIDERS) { + response.models[provider] = this.getProviderModels(catalog, provider); + } + + return response; + } + + private getProviderModels( + catalog: ProviderCatalog, + provider: InstanceAiCatalogProvider, + ): InstanceAiCatalogModel[] { + return Object.values(catalog[provider]?.models ?? {}) + .filter(isEligibleModel) + .map((model) => ({ + id: model.id, + name: model.name, + ...(typeof model.releaseDate === 'string' ? { releaseDate: model.releaseDate } : {}), + })) + .sort(compareModels); + } +} diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts index 90fa9fcf1d6..b52bba4555d 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -66,6 +66,7 @@ import { InProcessEventBus } from './event-bus/in-process-event-bus'; import { InstanceAiErrorReporterService } from './instance-ai-error-reporter.service'; import { InstanceAiGatewayService } from './instance-ai-gateway.service'; import { InstanceAiMemoryService } from './instance-ai-memory.service'; +import { InstanceAiModelCatalogService } from './instance-ai-model-catalog.service'; import { InstanceAiSettingsService } from './instance-ai-settings.service'; import { InstanceAiVerificationService } from './instance-ai-verification.service'; import { InstanceAiService } from './instance-ai.service'; @@ -128,6 +129,7 @@ export class InstanceAiController { private readonly browserSessionService: InstanceAiBrowserSessionService, private readonly memoryService: InstanceAiMemoryService, private readonly settingsService: InstanceAiSettingsService, + private readonly modelCatalogService: InstanceAiModelCatalogService, private readonly evalExecutionService: EvalExecutionService, private readonly evalAgentExecutionService: EvalAgentExecutionService, private readonly evalCredentialAllowlists: EvalThreadCredentialAllowlistService, @@ -681,6 +683,12 @@ export class InstanceAiController { return await this.settingsService.getAdminSettings(); } + @Get('/settings/models') + @GlobalScope('instanceAi:manage') + async getModelCatalog(_req: AuthenticatedRequest) { + return await this.modelCatalogService.getModels(); + } + @Put('/settings') @GlobalScope('instanceAi:manage') async updateAdminSettings( diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiModelCatalog.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiModelCatalog.test.ts new file mode 100644 index 00000000000..90b28d4d4d8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiModelCatalog.test.ts @@ -0,0 +1,101 @@ +import type { InstanceAiModelCatalogResponse } from '@n8n/api-types'; + +import { INSTANCE_AI_CURATED_MODELS } from '../instanceAiConnection.constants'; +import { getAllInstanceAiModelOptions, getInstanceAiModelOptions } from '../instanceAiModelCatalog'; + +const catalog: InstanceAiModelCatalogResponse['models'] = { + anthropic: [ + { id: 'claude-opus-5', name: 'Claude Opus 5' }, + { id: 'claude-zeta', name: 'Claude Zeta', releaseDate: '2026-05-01' }, + { id: 'claude-beta', name: 'Claude Beta', releaseDate: '2026-03-01' }, + { id: 'claude-delta', name: 'Claude Delta', releaseDate: '2026-03-01' }, + { id: 'claude-alpha', name: 'Claude Alpha' }, + { id: 'claude-invalid', name: 'Claude Invalid', releaseDate: 'unknown' }, + ], + openai: [], + openrouter: [], +}; + +describe('getInstanceAiModelOptions', () => { + it('uses curated recommendations immediately without a catalog', () => { + const options = getInstanceAiModelOptions('anthropic', null, ''); + + expect(options.map(({ id }) => id)).toEqual(INSTANCE_AI_CURATED_MODELS.anthropic); + expect(options.map(({ recommended }) => recommended)).toEqual([true, false]); + }); + + it('enriches and de-duplicates recommendations before newest dynamic models', () => { + const options = getInstanceAiModelOptions('anthropic', catalog, ''); + + expect(options).toEqual([ + { id: 'claude-opus-5', name: 'Claude Opus 5', recommended: true }, + { id: 'claude-sonnet-5', name: 'claude-sonnet-5', recommended: false }, + { id: 'claude-zeta', name: 'Claude Zeta', recommended: false }, + { id: 'claude-beta', name: 'Claude Beta', recommended: false }, + { id: 'claude-delta', name: 'Claude Delta', recommended: false }, + { id: 'claude-alpha', name: 'Claude Alpha', recommended: false }, + { id: 'claude-invalid', name: 'Claude Invalid', recommended: false }, + ]); + }); + + it('keeps an unknown saved model selectable', () => { + const options = getInstanceAiModelOptions('openai', catalog, 'retired-model'); + + expect(options.map(({ id }) => id)).toEqual([ + ...INSTANCE_AI_CURATED_MODELS.openai, + 'retired-model', + ]); + }); + + it('sorts a saved catalog model by its release date', () => { + const options = getInstanceAiModelOptions('anthropic', catalog, 'claude-beta'); + + expect(options.map(({ id }) => id)).toEqual([ + ...INSTANCE_AI_CURATED_MODELS.anthropic, + 'claude-zeta', + 'claude-beta', + 'claude-delta', + 'claude-alpha', + 'claude-invalid', + ]); + }); + + it('does not provide catalog options for custom endpoints', () => { + expect(getInstanceAiModelOptions('custom', catalog, 'custom-model')).toEqual([]); + }); + + it('combines every provider when the configured provider is masked', () => { + const options = getAllInstanceAiModelOptions( + { + ...catalog, + openai: [{ id: 'gpt-dynamic', name: 'GPT Dynamic', releaseDate: '2026-04-01' }], + openrouter: [ + { + id: 'openrouter/dynamic', + name: 'OpenRouter Dynamic', + releaseDate: '2026-02-01', + }, + ], + }, + 'saved-env-model', + ); + const curatedIds = [ + ...INSTANCE_AI_CURATED_MODELS.anthropic, + ...INSTANCE_AI_CURATED_MODELS.openai, + ...INSTANCE_AI_CURATED_MODELS.openrouter, + ]; + + expect(options.slice(0, curatedIds.length).map(({ id }) => id)).toEqual(curatedIds); + expect(options.map(({ id }) => id)).toEqual( + expect.arrayContaining([ + 'claude-zeta', + 'gpt-dynamic', + 'openrouter/dynamic', + 'saved-env-model', + ]), + ); + expect(options.filter(({ recommended }) => recommended).map(({ id }) => id)).toEqual([ + 'claude-opus-5', + ]); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts index 5039536740e..9e363623ebb 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts @@ -36,6 +36,7 @@ const mockFetchPreferences = vi.fn(); const mockUpdatePreferences = vi.fn(); const mockFetchServiceCredentials = vi.fn().mockResolvedValue([]); const mockFetchInstanceModelCredentials = vi.fn().mockResolvedValue([]); +const mockFetchModelCatalog = vi.fn(); const mockVerifyModel = vi.fn(); const mockVerifySandbox = vi.fn(); const mockVerifySearch = vi.fn(); @@ -49,6 +50,7 @@ vi.mock('../instanceAi.settings.api', () => ({ updatePreferences: (...args: unknown[]) => mockUpdatePreferences(...args), fetchServiceCredentials: (...args: unknown[]) => mockFetchServiceCredentials(...args), fetchInstanceModelCredentials: (...args: unknown[]) => mockFetchInstanceModelCredentials(...args), + fetchModelCatalog: (...args: unknown[]) => mockFetchModelCatalog(...args), verifyModel: (...args: unknown[]) => mockVerifyModel(...args), verifySandbox: (...args: unknown[]) => mockVerifySandbox(...args), verifySearch: (...args: unknown[]) => mockVerifySearch(...args), @@ -417,6 +419,55 @@ describe('useInstanceAiSettingsStore', () => { }); }); + describe('model catalog', () => { + const response = { + models: { + anthropic: [{ id: 'claude-opus-5', name: 'Claude Opus 5' }], + openai: [], + openrouter: [], + }, + }; + + it('de-duplicates in-flight requests and keeps the successful catalog', async () => { + let resolveFetch: (value: typeof response) => void = () => {}; + mockFetchModelCatalog.mockImplementation( + async () => + await new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const first = store.loadModelCatalog(); + const second = store.loadModelCatalog(); + expect(store.isModelCatalogLoading).toBe(true); + expect(mockFetchModelCatalog).toHaveBeenCalledOnce(); + + resolveFetch(response); + await Promise.all([first, second]); + expect(store.modelCatalog).toEqual(response.models); + expect(store.isModelCatalogLoading).toBe(false); + + await store.loadModelCatalog(); + expect(mockFetchModelCatalog).toHaveBeenCalledOnce(); + }); + + it('allows a retry after a failed or empty response', async () => { + mockFetchModelCatalog + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce({ models: { anthropic: [], openai: [], openrouter: [] } }) + .mockResolvedValueOnce(response); + + await store.loadModelCatalog(); + expect(store.modelCatalog).toBeNull(); + await store.loadModelCatalog(); + expect(store.modelCatalog).toBeNull(); + await store.loadModelCatalog(); + + expect(store.modelCatalog).toEqual(response.models); + expect(mockFetchModelCatalog).toHaveBeenCalledTimes(3); + }); + }); + describe('provider credentials', () => { it('refreshes n8n Sandbox credentials when the assistant proxy is enabled', async () => { setModuleSettings(settingsStore, { proxyEnabled: true, cloudManaged: false }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts index 6b1bf113851..b8793919e1e 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts @@ -3,6 +3,7 @@ import type { IRestApiContext } from '@n8n/rest-api-client'; import type { InstanceAiAdminSettingsResponse, InstanceAiAdminSettingsUpdateRequest, + InstanceAiModelCatalogResponse, InstanceAiUserPreferencesResponse, InstanceAiUserPreferencesUpdateRequest, InstanceAiProviderConnection, @@ -50,6 +51,12 @@ export async function fetchInstanceModelCredentials( return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/model-credentials'); } +export async function fetchModelCatalog( + context: IRestApiContext, +): Promise { + return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/models'); +} + export async function verifyModel( context: IRestApiContext, body: InstanceAiVerifyModelRequest, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts index d2d7c007336..4424beba2df 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiConnection.constants.ts @@ -9,46 +9,48 @@ import { SANDBOX_PROVIDER_LABELS } from './constants'; export type InstanceAiModelProvider = 'anthropic' | 'openai' | 'openrouter' | 'custom'; export type InstanceAiSearchProvider = 'searxng' | 'brave' | 'disabled'; +export const INSTANCE_AI_CURATED_MODELS = { + anthropic: ['claude-opus-5', 'claude-sonnet-5'], + openai: ['gpt-5.6-sol', 'gpt-5.6-terra'], + openrouter: [ + 'anthropic/claude-opus-5', + 'anthropic/claude-sonnet-5', + 'openai/gpt-5.6-sol', + 'openai/gpt-5.6-terra', + 'moonshotai/kimi-k3', + ], + custom: [], +} as const satisfies Record; + export const INSTANCE_AI_MODEL_PROVIDERS = [ { id: 'anthropic', credentialType: 'anthropicApi', label: 'Anthropic', - models: ['claude-opus-5', 'claude-sonnet-5'], placeholder: 'sk-ant-…', }, { id: 'openai', credentialType: 'openAiApi', label: 'OpenAI', - models: ['gpt-5.6-sol', 'gpt-5.6-terra'], placeholder: 'sk-…', }, { id: 'openrouter', credentialType: 'openRouterApi', label: 'OpenRouter', - models: [ - 'anthropic/claude-opus-5', - 'anthropic/claude-sonnet-5', - 'openai/gpt-5.6-sol', - 'openai/gpt-5.6-terra', - 'moonshotai/kimi-k3', - ], placeholder: 'sk-or-…', }, { id: 'custom', credentialType: 'openAiApi', label: null, - models: [], placeholder: 'Leave empty for Ollama', }, ] as const satisfies ReadonlyArray<{ id: InstanceAiModelProvider; credentialType: (typeof INSTANCE_AI_MODEL_CREDENTIAL_TYPES)[number]; label: string | null; - models: readonly string[]; placeholder: string; }>; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiModelCatalog.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiModelCatalog.ts new file mode 100644 index 00000000000..3590f965702 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiModelCatalog.ts @@ -0,0 +1,89 @@ +import { + INSTANCE_AI_CATALOG_PROVIDERS, + type InstanceAiCatalogModel, + type InstanceAiCatalogProvider, + type InstanceAiModelCatalogResponse, +} from '@n8n/api-types'; + +import { + INSTANCE_AI_CURATED_MODELS, + type InstanceAiModelProvider, +} from './instanceAiConnection.constants'; + +export interface InstanceAiModelOption { + id: string; + name: string; + recommended: boolean; +} + +function compareModels(a: InstanceAiCatalogModel, b: InstanceAiCatalogModel): number { + const aReleaseTime = a.releaseDate ? Date.parse(a.releaseDate) : Number.NaN; + const bReleaseTime = b.releaseDate ? Date.parse(b.releaseDate) : Number.NaN; + const aHasReleaseDate = !Number.isNaN(aReleaseTime); + const bHasReleaseDate = !Number.isNaN(bReleaseTime); + + if (aHasReleaseDate && bHasReleaseDate && aReleaseTime !== bReleaseTime) { + return bReleaseTime - aReleaseTime; + } + if (aHasReleaseDate !== bHasReleaseDate) return aHasReleaseDate ? -1 : 1; + + return a.name.localeCompare(b.name) || a.id.localeCompare(b.id); +} + +function getOptionsForProviders( + providers: readonly InstanceAiCatalogProvider[], + catalog: InstanceAiModelCatalogResponse['models'] | null, + currentModel: string, +): InstanceAiModelOption[] { + const catalogModels = providers.flatMap((provider) => catalog?.[provider] ?? []); + const catalogById = new Map(); + for (const model of catalogModels) { + if (!catalogById.has(model.id)) catalogById.set(model.id, model); + } + + const seen = new Set(); + const recommended: InstanceAiModelOption[] = []; + for (const [providerIndex, provider] of providers.entries()) { + for (const [modelIndex, id] of INSTANCE_AI_CURATED_MODELS[provider].entries()) { + if (seen.has(id)) continue; + seen.add(id); + recommended.push({ + id, + name: catalogById.get(id)?.name ?? id, + recommended: providerIndex === 0 && modelIndex === 0, + }); + } + } + + const dynamicModels: InstanceAiCatalogModel[] = catalogModels + .filter((model) => { + if (!model.id || !model.name || seen.has(model.id)) return false; + seen.add(model.id); + return true; + }) + .map((model) => ({ ...model })); + const current = currentModel.trim(); + if (current && !seen.has(current)) dynamicModels.push({ id: current, name: current }); + + const dynamic = dynamicModels + .sort(compareModels) + .map((model) => ({ id: model.id, name: model.name, recommended: false })); + + return [...recommended, ...dynamic]; +} + +export function getInstanceAiModelOptions( + provider: InstanceAiModelProvider, + catalog: InstanceAiModelCatalogResponse['models'] | null, + currentModel: string, +): InstanceAiModelOption[] { + if (provider === 'custom') return []; + return getOptionsForProviders([provider], catalog, currentModel); +} + +export function getAllInstanceAiModelOptions( + catalog: InstanceAiModelCatalogResponse['models'] | null, + currentModel: string, +): InstanceAiModelOption[] { + return getOptionsForProviders(INSTANCE_AI_CATALOG_PROVIDERS, catalog, currentModel); +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts index 9532260edc1..56eb23516c2 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts @@ -11,6 +11,7 @@ import { updatePreferences, fetchServiceCredentials, fetchInstanceModelCredentials, + fetchModelCatalog, verifyModel as verifyModelRequest, verifySandbox as verifySandboxRequest, verifySearch as verifySearchRequest, @@ -32,6 +33,7 @@ import type { InstanceAiProviderConnection, InstanceAiPermissions, InstanceAiPermissionMode, + InstanceAiModelCatalogResponse, ToolCategory, InstanceAiVerifyModelRequest, InstanceAiVerifySandboxRequest, @@ -58,6 +60,9 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = const preferences = ref(null); const serviceCredentials = ref([]); const instanceModelCredentials = ref([]); + const modelCatalog = ref(null); + const isModelCatalogLoading = ref(false); + let modelCatalogFetchPromise: Promise | null = null; const draft = reactive({}); // ── Gateway / daemon state ────────────────────────────────────────── @@ -604,6 +609,27 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = } catch {} } + async function loadModelCatalog(): Promise { + if (modelCatalog.value) return; + if (modelCatalogFetchPromise) return await modelCatalogFetchPromise; + + isModelCatalogLoading.value = true; + const request = fetchModelCatalog(rootStore.restApiContext) + .then((response) => { + if (Object.values(response.models).some((models) => models.length > 0)) { + modelCatalog.value = response.models; + } + }) + .catch(() => {}) + .finally(() => { + isModelCatalogLoading.value = false; + modelCatalogFetchPromise = null; + }); + modelCatalogFetchPromise = request; + + await request; + } + async function refreshModuleSettings(): Promise { const promises: Array> = [settingsStore.getModuleSettings()]; if (!preferences.value) { @@ -642,9 +668,11 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = preferences, serviceCredentials, instanceModelCredentials, + modelCatalog, draft, isLoading, isSaving, + isModelCatalogLoading, fetch, save, persistEnabled, @@ -681,6 +709,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () = clearSetupCommand, refreshCredentials, refreshInstanceModelCredentials, + loadModelCatalog, refreshModuleSettings, verifyModel, verifySandbox, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts index b86c139e186..255477a0bdd 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.test.ts @@ -74,6 +74,7 @@ function setupStore(overrides: Record = {}) { vi.mocked(store.save).mockResolvedValue(true); vi.mocked(store.refreshCredentials).mockResolvedValue(undefined); vi.mocked(store.refreshInstanceModelCredentials).mockResolvedValue(undefined); + vi.mocked(store.loadModelCatalog).mockResolvedValue(undefined); return { pinia, store, credentialsStore: useCredentialsStore() }; } @@ -82,6 +83,89 @@ describe('InstanceAiOnboardingWizard', () => { vi.clearAllMocks(); }); + it('loads and merges the model catalog without changing the selected model', async () => { + const { pinia, store } = setupStore(); + const { findByTestId, findByText } = renderWizard({ pinia }); + + await waitFor(() => expect(store.loadModelCatalog).toHaveBeenCalledOnce()); + await fireEvent.click(inputFor(await findByTestId('assistant-model-name'))); + expect(await findByText('claude-opus-5 · instanceAi.onboarding.recommended')).toBeVisible(); + + store.$patch({ + modelCatalog: { + anthropic: [ + { id: 'claude-opus-5', name: 'Claude Opus 5' }, + { id: 'claude-haiku-5', name: 'Claude Haiku 5' }, + ], + openai: [], + openrouter: [], + }, + }); + + expect(await findByText('Claude Opus 5 · instanceAi.onboarding.recommended')).toBeVisible(); + expect(await findByText('Claude Haiku 5')).toBeVisible(); + + await fireEvent.update(inputFor(await findByTestId('assistant-model-api-key')), 'model-key'); + vi.mocked(store.verifyModel).mockResolvedValue({ ok: true }); + await fireEvent.click(await findByTestId('wizard-primary')); + await waitFor(() => + expect(store.verifyModel).toHaveBeenCalledWith( + expect.objectContaining({ modelName: 'claude-opus-5' }), + ), + ); + }); + + it('keeps a user-selected model when catalog data arrives after a provider switch', async () => { + const { pinia, store } = setupStore(); + vi.mocked(store.verifyModel).mockResolvedValue({ ok: true }); + const { findByTestId, findByText } = renderWizard({ pinia }); + + await fireEvent.click(inputFor(await findByTestId('assistant-model-provider'))); + await fireEvent.click(await findByText('OpenAI')); + await fireEvent.click(inputFor(await findByTestId('assistant-model-name'))); + await fireEvent.click(await findByText('gpt-5.6-terra')); + + store.$patch({ + modelCatalog: { + anthropic: [], + openai: [ + { id: 'gpt-5.6-terra', name: 'GPT 5.6 Terra' }, + { id: 'gpt-dynamic', name: 'GPT Dynamic' }, + ], + openrouter: [], + }, + }); + await fireEvent.update(inputFor(await findByTestId('assistant-model-api-key')), 'model-key'); + await fireEvent.click(await findByTestId('wizard-primary')); + + await waitFor(() => + expect(store.verifyModel).toHaveBeenCalledWith( + expect.objectContaining({ modelName: 'gpt-5.6-terra' }), + ), + ); + }); + + it('does not load the catalog for a saved custom endpoint', async () => { + const { pinia, store, credentialsStore } = setupStore({ + modelCredentialId: 'custom-model-credential', + modelName: 'qwen3-coder', + }); + store.$patch({ + instanceModelCredentials: [ + { id: 'custom-model-credential', name: 'Custom model', type: 'openAiApi' }, + ], + }); + vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({ + data: { url: 'http://ollama.internal:11434/v1' }, + } as never); + + const { findByTestId } = renderWizard({ pinia }); + expect(inputFor(await findByTestId('assistant-model-base-url')).value).toBe( + 'http://ollama.internal:11434/v1', + ); + expect(store.loadModelCatalog).not.toHaveBeenCalled(); + }); + it('verifies and saves a model connection before advancing', async () => { const { pinia, store } = setupStore(); vi.mocked(store.verifyModel).mockResolvedValue({ ok: true, latencyMs: 125 }); @@ -151,6 +235,33 @@ describe('InstanceAiOnboardingWizard', () => { await waitFor(() => expect(emitted().advance).toEqual([[]]), { timeout: 2500 }); }); + it('offers models from every provider when the environment-managed provider is masked', async () => { + const { pinia, store } = setupStore({ + modelName: 'saved-env-model', + modelEnvConfigured: true, + envManaged: { + model: { provider: true, apiKey: true, baseUrl: false, model: false }, + sandbox: { provider: false, serviceUrl: false, apiKey: false }, + search: { provider: false, apiKey: false, url: false }, + }, + }); + store.$patch({ + modelCatalog: { + anthropic: [{ id: 'claude-dynamic', name: 'Claude Dynamic' }], + openai: [{ id: 'gpt-dynamic', name: 'GPT Dynamic' }], + openrouter: [{ id: 'openrouter/dynamic', name: 'OpenRouter Dynamic' }], + }, + }); + const { findByTestId, findByText } = renderWizard({ pinia }); + + await fireEvent.click(inputFor(await findByTestId('assistant-model-name'))); + + expect(await findByText('Claude Dynamic')).toBeVisible(); + expect(await findByText('GPT Dynamic')).toBeVisible(); + expect(await findByText('OpenRouter Dynamic')).toBeVisible(); + expect(await findByText('saved-env-model')).toBeVisible(); + }); + it('supports a custom OpenAI-compatible model without an API key', async () => { const { pinia, store } = setupStore(); vi.mocked(store.verifyModel).mockResolvedValue({ ok: true }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue index cc84af9e4cb..53603038769 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/onboarding/InstanceAiOnboardingWizard.vue @@ -30,11 +30,13 @@ import { SANDBOX_PROVIDER_LABELS } from '../constants'; import { useInstanceCredentialTest } from '../composables/useInstanceCredentialTest'; import { INSTANCE_AI_MODEL_PROVIDERS, + INSTANCE_AI_CURATED_MODELS, INSTANCE_AI_SANDBOX_PROVIDERS, INSTANCE_AI_SEARCH_PROVIDERS, type InstanceAiModelProvider, type InstanceAiSearchProvider, } from '../instanceAiConnection.constants'; +import { getAllInstanceAiModelOptions, getInstanceAiModelOptions } from '../instanceAiModelCatalog'; import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; import type { InstanceAiOnboardingStep } from './useInstanceAiOnboarding'; @@ -48,6 +50,7 @@ const BRAVE_SEARCH_KEYS_URL = 'https://api-dashboard.search.brave.com/app/keys'; const ENV_DOCS_URL = 'https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-ai-assistant'; const SUCCESS_PAUSE_MS = TIME.SECOND * 1.5; const DEFAULT_MODEL_PROVIDER = INSTANCE_AI_MODEL_PROVIDERS[0]!; +const DEFAULT_MODEL_NAME = INSTANCE_AI_CURATED_MODELS[DEFAULT_MODEL_PROVIDER.id][0] ?? ''; type VerificationSuccess = Extract; const VERIFICATION_FAILURE_COPY: Record = { unauthorized: 'instanceAi.onboarding.verification.unauthorized', @@ -95,7 +98,7 @@ const success = ref(null); const modelProvider = ref('anthropic'); const modelApiKey = ref(''); const modelBaseUrl = ref(''); -const modelName = ref(DEFAULT_MODEL_PROVIDER.models[0] ?? ''); +const modelName = ref(DEFAULT_MODEL_NAME); const sandboxProvider = ref<'n8n-sandbox' | 'daytona' | null>(null); const sandboxServiceUrl = ref(''); const sandboxApiKey = ref(''); @@ -114,13 +117,11 @@ const modelConfig = computed( ); const modelConnectionLocked = computed(() => store.settings?.envManaged?.model?.provider === true); const modelNameLocked = computed(() => store.settings?.envManaged?.model?.model === true); -const modelOptions = computed(() => { - const options: string[] = modelConnectionLocked.value - ? INSTANCE_AI_MODEL_PROVIDERS.flatMap(({ models }) => models) - : [...modelConfig.value.models]; - if (modelName.value && !options.includes(modelName.value)) options.unshift(modelName.value); - return [...new Set(options)]; -}); +const modelOptions = computed(() => + modelConnectionLocked.value + ? getAllInstanceAiModelOptions(store.modelCatalog, modelName.value) + : getInstanceAiModelOptions(modelProvider.value, store.modelCatalog, modelName.value), +); const sandboxEnvManaged = computed(() => store.settings?.sandboxEnvConfigured === true); const searchEnvManaged = computed(() => store.settings?.searchEnvConfigured === true); const readOnly = computed(() => !store.canManageInstanceCredentials); @@ -312,7 +313,7 @@ function applyExistingCredential(credential: InstanceAiProviderConnection): void modelProvider.value = modelProviderForCredentialType(credential.type); modelName.value = (credential.id === assignedCredentialId() ? store.settings?.modelName : null) || - INSTANCE_AI_MODEL_PROVIDERS.find(({ id }) => id === modelProvider.value)?.models[0] || + INSTANCE_AI_CURATED_MODELS[modelProvider.value][0] || ''; } else if (props.step === 'sandbox') { sandboxProvider.value = credential.type === 'daytonaApi' ? 'daytona' : 'n8n-sandbox'; @@ -340,7 +341,7 @@ async function hydrateModel(generation: number, rememberProvider = true): Promis modelProvider.value = 'anthropic'; modelApiKey.value = ''; modelBaseUrl.value = ''; - modelName.value = store.settings?.modelName || DEFAULT_MODEL_PROVIDER.models[0] || ''; + modelName.value = store.settings?.modelName || DEFAULT_MODEL_NAME; if (modelConnectionLocked.value) { if (modelNameLocked.value) modelName.value = ''; return; @@ -423,7 +424,11 @@ async function hydrate(): Promise { watch( () => [props.open, props.step] as const, async ([open]) => { - if (open) await hydrate(); + if (!open) return; + await hydrate(); + if (props.step === 'model' && modelProvider.value !== 'custom') { + void store.loadModelCatalog(); + } }, { immediate: true }, ); @@ -463,7 +468,8 @@ async function selectModelProvider(provider: unknown): Promise { modelProvider.value = next.id; modelApiKey.value = ''; modelBaseUrl.value = ''; - modelName.value = next.models[0] ?? ''; + modelName.value = INSTANCE_AI_CURATED_MODELS[next.id][0] ?? ''; + if (next.id !== 'custom') void store.loadModelCatalog(); } function selectSandboxProvider(provider: unknown): void { @@ -846,6 +852,7 @@ const existingCredentialLabel = (credential: InstanceAiProviderConnection) => id="assistant-model-name" :model-value="modelName" :teleported="true" + filterable :data-test-id=" surface === 'settings' ? 'n8n-agent-model-name-input' : 'assistant-model-name' " @@ -853,12 +860,12 @@ const existingCredentialLabel = (credential: InstanceAiProviderConnection) => >