feat: Load Instance AI models dynamically (#35927)

This commit is contained in:
oleg 2026-08-10 14:56:24 +02:00 committed by GitHub
parent 868a532b74
commit 269a45d7dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1033 additions and 49 deletions

View File

@ -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';

View File

@ -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 () => {

View File

@ -1,3 +1,5 @@
import { z } from 'zod';
const MODELS_DEV_URL = 'https://models.dev/api.json';
const MODELS_DEV_PROVIDER_ALIASES: Record<string, string> = {
@ -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<string, ProviderInfo>;
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<string, ModelsDevModel>;
}
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<string, ModelInfo>): void {
* console.log(catalog.anthropic.models['claude-sonnet-4-5'].reasoning); // true
* ```
*/
export async function fetchProviderCatalog(): Promise<ProviderCatalog> {
const response = await fetch(MODELS_DEV_URL);
export async function fetchProviderCatalog(options?: {
signal?: AbortSignal;
}): Promise<ProviderCatalog> {
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<string, ModelsDevProvider>;
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<string, ModelInfo> = {};
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<ProviderCatalog> {
...(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 = {

View File

@ -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 {

View File

@ -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<InstanceAiCatalogProvider, InstanceAiCatalogModel[]>;
}
export interface InstanceAiEnvManagedFields {
model: {
provider: boolean;

View File

@ -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<ProviderCatalog>>();
class TestInstanceAiModelCatalogService extends InstanceAiModelCatalogService {
constructor(
logger: Logger,
private readonly fetcher: (signal: AbortSignal) => Promise<ProviderCatalog>,
) {
super(logger);
}
protected override async fetchCatalog(signal: AbortSignal): Promise<ProviderCatalog> {
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>();
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<ProviderCatalog>((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<ProviderCatalog>((_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);
});
});

View File

@ -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<InstanceAiBrowserSessionService>();
const memoryService = mock<InstanceAiMemoryService>();
const settingsService = mock<InstanceAiSettingsService>();
const modelCatalogService = mock<InstanceAiModelCatalogService>();
const eventBus = mock<InProcessEventBus>();
const eventLog = mock<DurableEventLog>();
const durableLogMetrics = mock<DurableLogMetrics>();
@ -137,6 +139,7 @@ describe('InstanceAiController', () => {
browserSessionService,
memoryService,
settingsService,
modelCatalogService,
mock<EvalExecutionService>(),
mock<EvalAgentExecutionService>(),
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<InstanceAiBrowserSessionService>(),
memoryService,
settingsService,
mock<InstanceAiModelCatalogService>(),
mock<EvalExecutionService>(),
mock<EvalAgentExecutionService>(),
new EvalThreadCredentialAllowlistService(),

View File

@ -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<InstanceAiModelCatalogResponse>;
constructor(logger: Logger) {
this.logger = logger.scoped('instance-ai');
}
async getModels(): Promise<InstanceAiModelCatalogResponse> {
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<InstanceAiModelCatalogResponse> {
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<ProviderCatalog> {
const abortController = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_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<ProviderCatalog> {
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);
}
}

View File

@ -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(

View File

@ -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',
]);
});
});

View File

@ -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<typeof response>((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 });

View File

@ -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<InstanceAiModelCatalogResponse> {
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/models');
}
export async function verifyModel(
context: IRestApiContext,
body: InstanceAiVerifyModelRequest,

View File

@ -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<InstanceAiModelProvider, readonly string[]>;
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;
}>;

View File

@ -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<string, InstanceAiCatalogModel>();
for (const model of catalogModels) {
if (!catalogById.has(model.id)) catalogById.set(model.id, model);
}
const seen = new Set<string>();
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);
}

View File

@ -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<InstanceAiUserPreferencesResponse | null>(null);
const serviceCredentials = ref<InstanceAiProviderConnection[]>([]);
const instanceModelCredentials = ref<InstanceAiProviderConnection[]>([]);
const modelCatalog = ref<InstanceAiModelCatalogResponse['models'] | null>(null);
const isModelCatalogLoading = ref(false);
let modelCatalogFetchPromise: Promise<void> | null = null;
const draft = reactive<InstanceAiAdminSettingsUpdateRequest>({});
// ── Gateway / daemon state ──────────────────────────────────────────
@ -604,6 +609,27 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
} catch {}
}
async function loadModelCatalog(): Promise<void> {
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<void> {
const promises: Array<Promise<unknown>> = [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,

View File

@ -74,6 +74,7 @@ function setupStore(overrides: Record<string, unknown> = {}) {
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 });

View File

@ -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<InstanceAiVerificationResponse, { ok: true }>;
const VERIFICATION_FAILURE_COPY: Record<InstanceAiVerificationFailure, BaseTextKey> = {
unauthorized: 'instanceAi.onboarding.verification.unauthorized',
@ -95,7 +98,7 @@ const success = ref<VerificationSuccess | null>(null);
const modelProvider = ref<InstanceAiModelProvider>('anthropic');
const modelApiKey = ref('');
const modelBaseUrl = ref('');
const modelName = ref<string>(DEFAULT_MODEL_PROVIDER.models[0] ?? '');
const modelName = ref<string>(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<void> {
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<void> {
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) =>
>
<N8nOption
v-for="model in modelOptions"
:key="model"
:value="model"
:key="model.id"
:value="model.id"
:label="
model === modelConfig.models[0]
? `${model} · ${i18n.baseText('instanceAi.onboarding.recommended')}`
: model
model.recommended
? `${model.name} · ${i18n.baseText('instanceAi.onboarding.recommended')}`
: model.name
"
/>
</N8nSelect>