mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat: Load Instance AI models dynamically (#35927)
This commit is contained in:
parent
868a532b74
commit
269a45d7dd
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user