✨ Allow using separate model for overview task

This commit is contained in:
Milorad FIlipović 2026-08-13 17:00:08 +02:00
parent 06ea4e7400
commit 03a148e270
6 changed files with 86 additions and 6 deletions

View File

@ -16,6 +16,15 @@ export class InstanceAiConfig {
@Env('N8N_INSTANCE_AI_MODEL_API_KEY')
modelApiKey: string = '';
/**
* Bare model name (no provider prefix) for lightweight workflow-overview
* generation, e.g. "claude-haiku-4-5". Inherits the main model's provider
* and credentials. Empty = a smaller Anthropic default when the main model
* is Anthropic, otherwise the main model.
*/
@Env('N8N_INSTANCE_AI_OVERVIEW_MODEL')
overviewModel: string = '';
/** Comma-separated name=url pairs for MCP servers (e.g. "github=https://mcp.github.com/sse"). */
@Env('N8N_INSTANCE_AI_MCP_SERVERS')
mcpServers: string = '';

View File

@ -14,6 +14,7 @@ persisted in settings takes precedence over `N8N_INSTANCE_AI_SANDBOX_PROVIDER`.
| `N8N_INSTANCE_AI_MODEL` | string | `anthropic/claude-opus-4-8` | LLM model in `provider/model` format for built-in providers, or a bare model name when `N8N_INSTANCE_AI_MODEL_URL` is set. Must be set for the module to enable. |
| `N8N_INSTANCE_AI_MODEL_URL` | string | `''` | Base URL for an OpenAI-compatible endpoint (e.g. `http://localhost:1234/v1` for LM Studio). When set, model requests go to this URL instead of the built-in provider. |
| `N8N_INSTANCE_AI_MODEL_API_KEY` | string | `''` | API key for the custom model endpoint. Optional — some local servers don't require one. |
| `N8N_INSTANCE_AI_OVERVIEW_MODEL` | string | `''` | Bare model name (no provider prefix) for workflow-overview generation, e.g. `claude-haiku-4-5`. Inherits the main model's provider and credentials. Empty = `claude-sonnet-4-6` when the main model is Anthropic, otherwise the main model. |
| `N8N_INSTANCE_AI_MCP_SERVERS` | string | `''` | Comma-separated MCP server configs. Format: `name=url,name=url` |
| `N8N_INSTANCE_AI_LOCAL_GATEWAY_DISABLED` | boolean | `false` | Disable the local gateway (filesystem, shell, browser) for all users |

View File

@ -1,5 +1,6 @@
import { UNLIMITED_CREDITS } from '@n8n/api-types';
import type { OutboundHttp } from '@n8n/backend-network';
import type { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
@ -34,13 +35,14 @@ describe('InstanceAiModelService', () => {
const settingsService = mock<InstanceAiSettingsService>();
const aiService = mock<AiService>();
const outboundHttp = mock<OutboundHttp>();
const globalConfig = mock<GlobalConfig>({ instanceAi: { overviewModel: '' } });
let service: InstanceAiModelService;
beforeEach(() => {
vi.clearAllMocks();
capturedTokenGetters.length = 0;
service = new InstanceAiModelService(settingsService, aiService, outboundHttp);
service = new InstanceAiModelService(settingsService, aiService, outboundHttp, globalConfig);
});
afterEach(() => {

View File

@ -1,5 +1,6 @@
import { UNLIMITED_CREDITS, buildProxyHeaders, type InstanceAiCredits } from '@n8n/api-types';
import { OutboundHttp } from '@n8n/backend-network';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { ModelConfig } from '@n8n/instance-ai';
@ -13,6 +14,15 @@ import { callAiServiceWithRetry } from '@/utils/ai-service-retry';
import { InstanceAiSettingsService } from './instance-ai-settings.service';
/**
* Bare model name used for workflow-overview generation when no
* `N8N_INSTANCE_AI_OVERVIEW_MODEL` override is set and the main model is
* Anthropic. Overviews are three schema-bound sentences — a smaller model is
* indistinguishable in quality and much cheaper, and the sidecar runs on
* every user turn.
*/
const DEFAULT_ANTHROPIC_OVERVIEW_MODEL = 'claude-sonnet-4-6';
/**
* Resolves the language model the Instance AI agent runs against and reports
* the credit balance for the current user.
@ -35,6 +45,7 @@ export class InstanceAiModelService {
private readonly settingsService: InstanceAiSettingsService,
private readonly aiService: AiService,
private readonly outboundHttp: OutboundHttp,
private readonly globalConfig: GlobalConfig,
) {}
/** Whether the AI service proxy is enabled for credit counting. */
@ -70,6 +81,59 @@ export class InstanceAiModelService {
return await this.settingsService.resolveModelConfig(user);
}
/**
* Model for lightweight workflow-overview generation (three schema-bound
* sentences): `N8N_INSTANCE_AI_OVERVIEW_MODEL` when set (bare model name,
* inherits the main model's provider + credentials), otherwise a smaller
* Anthropic default when the main model is Anthropic, otherwise the main
* agent model. Follows the same proxy resolution chain as
* {@link resolveAgentModelConfig}.
*/
async resolveOverviewModelConfig(user: User): Promise<ModelConfig> {
const override = this.globalConfig.instanceAi.overviewModel.trim();
const hasOverride = override !== '';
const overrideOrDefault = hasOverride ? override : DEFAULT_ANTHROPIC_OVERVIEW_MODEL;
if (this.aiService.isProxyEnabled()) {
const client = await this.aiService.getClient();
const proxyBaseUrl = client.getApiProxyBaseUrl();
const tokenManager = new ProxyTokenManager(async () => {
return await client.getInstanceAiApiProxyToken(
{ id: user.id },
{ userMessageId: nanoid() },
);
});
// The proxy transport is Anthropic-native, so the smaller default is safe here.
return await this.resolveProxyModel(user, proxyBaseUrl, tokenManager, {
modelNameOverride: overrideOrDefault,
});
}
// The HTTP-proxy path only ever builds Anthropic models (see the provider gate).
const httpProxyModel = await this.resolveHttpProxyModel(user, {
modelNameOverride: overrideOrDefault,
});
if (httpProxyModel) return httpProxyModel;
if (hasOverride) {
return await this.settingsService.resolveModelConfigForVerification(user, override);
}
if (await this.isMainModelAnthropic(user)) {
return await this.settingsService.resolveModelConfigForVerification(
user,
DEFAULT_ANTHROPIC_OVERVIEW_MODEL,
);
}
return await this.settingsService.resolveModelConfig(user);
}
/** Whether the resolved main model belongs to the Anthropic provider. */
private async isMainModelAnthropic(user: User): Promise<boolean> {
const config = await this.settingsService.resolveModelConfig(user);
const modelId = typeof config === 'string' ? config : 'id' in config ? config.id : null;
return typeof modelId === 'string' && modelId.startsWith('anthropic/');
}
/**
* Build model config. When the AI service proxy is enabled, returns a native
* Anthropic LanguageModelV2 instance pointing at the proxy.
@ -87,8 +151,9 @@ export class InstanceAiModelService {
user: User,
proxyBaseUrl: string,
tokenManager: ProxyTokenManager,
options?: { modelNameOverride?: string },
): Promise<ModelConfig> {
const modelName = this.settingsService.resolveModelName(user);
const modelName = options?.modelNameOverride ?? this.settingsService.resolveModelName(user);
const { createAnthropic } = await import('@ai-sdk/anthropic');
// Route through the proxy-aware transport so this path honours
// HTTP(S)_PROXY and the long AI timeout, same as the HTTP-proxy path.
@ -118,7 +183,10 @@ export class InstanceAiModelService {
* with a proxy-aware fetch so the AI SDK routes through the proxy.
* Returns undefined if no HTTP_PROXY is set or the model isn't anthropic.
*/
private async resolveHttpProxyModel(user: User): Promise<ModelConfig | undefined> {
private async resolveHttpProxyModel(
user: User,
options?: { modelNameOverride?: string },
): Promise<ModelConfig | undefined> {
// Only take over model construction when a proxy is configured; otherwise
// the regular model resolution path applies. Node's global `fetch` does
// not honour HTTP(S)_PROXY, hence the proxy-aware transport below.
@ -130,7 +198,7 @@ export class InstanceAiModelService {
if (!modelId) return undefined;
const [provider, ...rest] = modelId.split('/');
const modelName = rest.join('/');
const modelName = options?.modelNameOverride ?? rest.join('/');
const apiKey = typeof config === 'object' && 'apiKey' in config ? config.apiKey : undefined;
const baseURL = typeof config === 'object' && 'url' in config ? config.url : undefined;
if (provider !== 'anthropic') return undefined;

View File

@ -53,7 +53,7 @@ export class InstanceAiWorkflowOverviewService {
]);
if (!workflow) throw new NotFoundError(`Workflow ${workflowId} not found`);
const modelId = await this.modelService.resolveAgentModelConfig(user);
const modelId = await this.modelService.resolveOverviewModelConfig(user);
const structure = await summarizeWorkflowStructure(
workflow.name,
workflow.nodes,

View File

@ -6502,7 +6502,7 @@ export class InstanceAiService {
} = {},
): Promise<void> {
try {
const modelId = await this.modelService.resolveAgentModelConfig(user);
const modelId = await this.modelService.resolveOverviewModelConfig(user);
// Last N stored messages, oldest first — service-injected blocks stripped
// from user turns so they don't leak into the overview prompt.