feat(core): Route Instance AI and Agent Builder proxy traffic for moonshotai/kimi-k3 (no-changelog) (#36204)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Riqwan Thamir
2026-08-14 06:09:28 +00:00
committed by GitHub
parent c4b34352a7
commit 0ae3f72063
27 changed files with 629 additions and 120 deletions
@@ -397,6 +397,19 @@ describe('createModel', () => {
expect(model.supportsStructuredOutputs).toBeUndefined();
});
it('should enable usage and structured outputs for moonshotai', () => {
const model = createModel({
id: 'moonshotai/kimi-k3',
apiKey: 'ms-test',
}) as unknown as Record<string, unknown>;
expect(model.provider).toBe('moonshotai');
expect(model.modelId).toBe('kimi-k3');
expect(model.apiKey).toBe('ms-test');
expect(model.baseURL).toBe('https://api.moonshot.ai/v1');
expect(model.includeUsage).toBe(true);
expect(model.supportsStructuredOutputs).toBe(true);
});
it('should have undefined supportsStructuredOutputs for custom when unset', () => {
const model = createModel({
id: 'custom/Kimi-K3',
@@ -207,12 +207,26 @@ describe('thinkingToProviderOptions', () => {
{},
);
});
it('moonshotai: maps reasoningEffort to providerOptions.moonshotai', () => {
expect(
getProviderQuirks('moonshotai').thinkingToProviderOptions?.(
{
reasoningEffort: 'low',
},
'moonshotai/kimi-k3',
),
).toEqual({
moonshotai: { reasoningEffort: 'low' },
});
});
});
describe('resolveDefaultMaxOutputTokens', () => {
it.each([
'custom/accounts/fireworks/models/kimi-k3',
'openrouter/moonshotai/kimi-k3',
'moonshotai/kimi-k3',
'custom/Kimi-K3',
] as const)('raises the output cap to the Kimi K3 default for %s', (modelId) => {
expect(resolveDefaultMaxOutputTokens(modelId)).toBe(HIGH_REASONING_DEFAULT_MAX_OUTPUT_TOKENS);
@@ -130,7 +130,7 @@ function buildOpenAiCompatible(
})(model);
}
type OpenAiCompatibleProviderId = 'nvidia';
type OpenAiCompatibleProviderId = 'nvidia' | 'moonshotai';
function openAiCompatibleEntry<P extends OpenAiCompatibleProviderId>(
name: P,
@@ -252,6 +252,10 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = {
},
},
nvidia: openAiCompatibleEntry('nvidia', 'https://integrate.api.nvidia.com/v1', {}),
moonshotai: openAiCompatibleEntry('moonshotai', 'https://api.moonshot.ai/v1', {
includeUsage: true,
supportsStructuredOutputs: true,
}),
'azure-openai': {
build: (creds, model, fetch) => {
const { createAzure } = require('@ai-sdk/azure') as typeof import('@ai-sdk/azure');
@@ -40,6 +40,7 @@ export const PROVIDER_CREDENTIAL_SCHEMAS = {
deepseek: apiKeyCreds,
cohere: apiKeyCreds,
mistral: apiKeyCreds,
moonshotai: apiKeyCreds,
vercel: apiKeyCreds,
openrouter: apiKeyCreds,
nvidia: apiKeyCreds,
@@ -168,6 +168,7 @@ export const PROVIDER_QUIRKS: Partial<Record<ProviderId, ProviderQuirks>> = {
},
// custom/*: only forward an explicit effort — no provider-level default.
custom: reasoningEffortQuirk('custom'),
moonshotai: reasoningEffortQuirk('moonshotai'),
};
export function getProviderQuirks(providerId: string): ProviderQuirks {
@@ -145,6 +145,13 @@ export const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
providerTools: [],
attachments: NO_ATTACHMENTS,
},
moonshotai: {
thinking: 'reasoningEffort',
promptCaching: false,
webSearch: false,
providerTools: [],
attachments: NO_ATTACHMENTS,
},
cohere: {
thinking: false,
promptCaching: false,
@@ -0,0 +1,9 @@
export const MOONSHOTAI_KIMI_K3_PROVIDER = 'moonshotai' as const;
export const MOONSHOTAI_KIMI_K3_MODEL_NAME = 'kimi-k3' as const;
export const MOONSHOTAI_KIMI_K3_MODEL_ID =
`${MOONSHOTAI_KIMI_K3_PROVIDER}/${MOONSHOTAI_KIMI_K3_MODEL_NAME}` as const;
/** Exact-match guard for proxy routing and billing model ids (no wildcards). */
export function isMoonshotaiKimiK3ModelId(modelId: string): boolean {
return modelId === MOONSHOTAI_KIMI_K3_MODEL_ID;
}
+6
View File
@@ -682,4 +682,10 @@ export {
type N8nProxyFeature,
type ProxyHeaderInput,
} from './constants/proxy-feature';
export {
MOONSHOTAI_KIMI_K3_MODEL_ID,
MOONSHOTAI_KIMI_K3_MODEL_NAME,
MOONSHOTAI_KIMI_K3_PROVIDER,
isMoonshotaiKimiK3ModelId,
} from './constants/instance-ai-models';
export { BLOCK_ACCESS_ASSIGNMENT } from './constants/role-mapping';
@@ -0,0 +1,51 @@
import { resolveAIAPromptCaching, resolveAIAReasoning } from '../aia-model-defaults';
describe('resolveAIAPromptCaching', () => {
it('returns a 5m Anthropic TTL for Anthropic models', () => {
expect(resolveAIAPromptCaching('anthropic/claude-sonnet-4-6')).toEqual({
enabled: true,
anthropic: { ttl: '5m' },
});
});
it('returns a 5m Anthropic TTL for Vertex Anthropic models', () => {
expect(resolveAIAPromptCaching('google-vertex-anthropic/claude-opus-4-8')).toEqual({
enabled: true,
anthropic: { ttl: '5m' },
});
});
it('returns enabled caching for OpenAI models', () => {
expect(resolveAIAPromptCaching('openai/gpt-5.6-sol')).toEqual({ enabled: true });
});
it('returns undefined when the provider does not support prompt caching', () => {
expect(resolveAIAPromptCaching('moonshotai/kimi-k3')).toBeUndefined();
expect(resolveAIAPromptCaching('google/gemini-2.5-pro')).toBeUndefined();
expect(
resolveAIAPromptCaching({
provider: 'moonshotai',
modelId: 'kimi-k3',
} as never),
).toBeUndefined();
});
});
describe('resolveAIAReasoning', () => {
it('returns low for Kimi K3 model ids', () => {
expect(resolveAIAReasoning('moonshotai/kimi-k3')).toBe('low');
expect(resolveAIAReasoning('custom/Kimi-K3')).toBe('low');
expect(
resolveAIAReasoning({
provider: 'moonshotai',
modelId: 'kimi-k3',
} as never),
).toBe('low');
});
it('returns medium for models without a mapped effort', () => {
expect(resolveAIAReasoning('anthropic/claude-sonnet-4-6')).toBe('medium');
expect(resolveAIAReasoning('openai/gpt-5.6-sol')).toBe('medium');
expect(resolveAIAReasoning('google/gemini-2.5-pro')).toBe('medium');
});
});
@@ -88,6 +88,25 @@ describe('applyAgentThinking', () => {
});
});
it('enables mapped low reasoning effort for proxied moonshotai/kimi-k3', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'moonshotai/kimi-k3');
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('moonshotai', {
reasoningEffort: 'low',
});
});
it('enables mapped low reasoning effort for moonshotai LanguageModel objects', () => {
const agent = new Agent('test');
applyAgentThinking(agent, {
provider: 'moonshotai',
modelId: 'kimi-k3',
} as unknown as Parameters<typeof applyAgentThinking>[1]);
expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('moonshotai', {
reasoningEffort: 'low',
});
});
it('enables mapped low reasoning effort for Databricks AI Gateway Kimi-K3', () => {
const agent = new Agent('test');
applyAgentThinking(agent, 'custom/workspace.default.kimi-k3');
@@ -0,0 +1,28 @@
import type { ModelConfig, PromptCachingConfig, ReasoningLevel } from '@n8n/agents';
import { PROVIDER_CAPABILITIES, resolvePromptCaching } from '@n8n/api-types';
import { resolveModelIdString, resolveModelProvider } from './model-config-identity';
import { resolveCustomModelExperimentDefaults } from '../utils/custom-model-defaults';
const DEFAULT_AIA_REASONING: ReasoningLevel = 'medium';
const AIA_ANTHROPIC_PROMPT_CACHING = { enabled: true, anthropic: { ttl: '5m' as const } };
export function resolveAIAPromptCaching(model: ModelConfig): PromptCachingConfig | undefined {
const provider = resolveModelProvider(model);
if (!provider) return undefined;
return resolvePromptCaching(
AIA_ANTHROPIC_PROMPT_CACHING,
PROVIDER_CAPABILITIES[provider]?.promptCaching ?? false,
);
}
export function resolveAIAReasoning(model: ModelConfig): ReasoningLevel {
const modelId = resolveModelIdString(model) ?? '';
// Substring map (e.g. custom/Kimi-K3) — not the exact-match proxy gate in api-types.
const { reasoningEffort } = resolveCustomModelExperimentDefaults(modelId);
if (reasoningEffort === 'low' || reasoningEffort === 'medium' || reasoningEffort === 'high') {
return reasoningEffort;
}
return DEFAULT_AIA_REASONING;
}
@@ -1,52 +1,9 @@
import type { Agent, ModelConfig } from '@n8n/agents';
import { PROVIDER_CAPABILITIES } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { resolveModelIdString, resolveModelProvider } from './model-config-identity';
import { resolveCustomModelExperimentDefaultsFromEnv } from '../utils/custom-model-defaults';
function normalizeProvider(provider: string): string {
return provider.split('.')[0] ?? provider;
}
function getStringProperty(value: Record<string, unknown>, key: string): string | undefined {
const property = value[key];
return typeof property === 'string' ? property : undefined;
}
function getProviderFromConfig(value: Record<string, unknown>): string | undefined {
const config = value.config;
return isRecord(config) ? getStringProperty(config, 'provider') : undefined;
}
function getProviderFromId(id: string): string | undefined {
const slashIndex = id.indexOf('/');
return slashIndex > 0 ? normalizeProvider(id.slice(0, slashIndex)) : undefined;
}
function resolveModelProvider(modelId: ModelConfig): string | undefined {
if (typeof modelId === 'string') return getProviderFromId(modelId);
if (!isRecord(modelId)) return undefined;
const id = getStringProperty(modelId, 'id');
if (id) return getProviderFromId(id);
const provider = getStringProperty(modelId, 'provider') ?? getProviderFromConfig(modelId);
const model = getStringProperty(modelId, 'modelId');
return provider && model ? normalizeProvider(provider) : undefined;
}
function resolveModelIdString(modelId: ModelConfig): string | undefined {
if (typeof modelId === 'string') return modelId;
if (!isRecord(modelId)) return undefined;
const id = getStringProperty(modelId, 'id');
if (id) return id;
const provider = getStringProperty(modelId, 'provider') ?? getProviderFromConfig(modelId);
const model = getStringProperty(modelId, 'modelId');
return provider && model ? `${provider}/${model}` : undefined;
}
/** Grok 4.5 via xAI (`xai/grok-4.5`). */
function isGrok45Model(modelId: ModelConfig): boolean {
const id = resolveModelIdString(modelId)?.toLowerCase() ?? '';
@@ -65,7 +22,7 @@ export function applyAgentThinking(agent: Agent, modelId: ModelConfig): void {
if (!provider || !PROVIDER_CAPABILITIES[provider]?.thinking) return;
if (provider === 'custom') {
// No blanket custom default: env override → known-model map → omit.
// No blanket default: env override → known-model map → omit.
const resolvedModelId = resolveModelIdString(modelId) ?? '';
const { reasoningEffort } = resolveCustomModelExperimentDefaultsFromEnv(resolvedModelId);
if (reasoningEffort !== undefined) {
@@ -74,6 +31,16 @@ export function applyAgentThinking(agent: Agent, modelId: ModelConfig): void {
return;
}
if (provider === 'moonshotai') {
const resolvedModelId = resolveModelIdString(modelId) ?? '';
const { reasoningEffort } = resolveCustomModelExperimentDefaultsFromEnv(resolvedModelId);
if (reasoningEffort !== undefined) {
agent.thinking('moonshotai', { reasoningEffort });
}
return;
}
if (provider === 'openai') {
agent.thinking('openai', {
reasoningEffort: isGpt56Model(modelId) ? 'medium' : 'high',
@@ -0,0 +1,45 @@
import type { ModelConfig } from '@n8n/agents';
import { isRecord } from '@n8n/utils/is-record';
function normalizeProvider(provider: string): string {
return provider.split('.')[0] ?? provider;
}
function getStringProperty(value: Record<string, unknown>, key: string): string | undefined {
const property = value[key];
return typeof property === 'string' ? property : undefined;
}
function getProviderFromConfig(value: Record<string, unknown>): string | undefined {
const config = value.config;
return isRecord(config) ? getStringProperty(config, 'provider') : undefined;
}
function getProviderFromId(id: string): string | undefined {
const slashIndex = id.indexOf('/');
return slashIndex > 0 ? normalizeProvider(id.slice(0, slashIndex)) : undefined;
}
export function resolveModelProvider(modelId: ModelConfig): string | undefined {
if (typeof modelId === 'string') return getProviderFromId(modelId);
if (!isRecord(modelId)) return undefined;
const id = getStringProperty(modelId, 'id');
if (id) return getProviderFromId(id);
const provider = getStringProperty(modelId, 'provider') ?? getProviderFromConfig(modelId);
const model = getStringProperty(modelId, 'modelId');
return provider && model ? normalizeProvider(provider) : undefined;
}
export function resolveModelIdString(modelId: ModelConfig): string | undefined {
if (typeof modelId === 'string') return modelId;
if (!isRecord(modelId)) return undefined;
const id = getStringProperty(modelId, 'id');
if (id) return id;
const provider = getStringProperty(modelId, 'provider') ?? getProviderFromConfig(modelId);
const model = getStringProperty(modelId, 'modelId');
return provider && model ? `${provider}/${model}` : undefined;
}
+9
View File
@@ -3,6 +3,7 @@ import type * as SharedSandboxMod from '@n8n/agents/sandbox';
import './source-map-filter';
import type * as AiaModelDefaultsMod from './agent/aia-model-defaults';
import type * as ApplyAgentThinkingMod from './agent/apply-agent-thinking';
import type * as InstanceAgentMod from './agent/instance-agent';
import type * as SystemPromptMod from './agent/system-prompt';
@@ -94,6 +95,9 @@ const loadAgentSnapshotEvent = lazyModule(
const loadInstanceAgent = lazyModule(
() => require('./agent/instance-agent') as typeof InstanceAgentMod,
);
const loadAiaModelDefaults = lazyModule(
() => require('./agent/aia-model-defaults') as typeof AiaModelDefaultsMod,
);
const loadApplyAgentThinking = lazyModule(
() => require('./agent/apply-agent-thinking') as typeof ApplyAgentThinkingMod,
);
@@ -305,6 +309,11 @@ export const createInstanceAgent: typeof InstanceAgentMod.createInstanceAgent =
export const applyAgentThinking: typeof ApplyAgentThinkingMod.applyAgentThinking = lazyFunction(
() => loadApplyAgentThinking().applyAgentThinking,
);
export const resolveAIAPromptCaching: typeof AiaModelDefaultsMod.resolveAIAPromptCaching =
lazyFunction(() => loadAiaModelDefaults().resolveAIAPromptCaching);
export const resolveAIAReasoning: typeof AiaModelDefaultsMod.resolveAIAReasoning = lazyFunction(
() => loadAiaModelDefaults().resolveAIAReasoning,
);
export const getDateTimeSection: typeof SystemPromptMod.getDateTimeSection = lazyFunction(
() => loadSystemPrompt().getDateTimeSection,
@@ -11,6 +11,10 @@ describe('resolveCustomModelExperimentDefaults', () => {
reasoningEffort: 'low',
supportsStructuredOutputs: true,
});
expect(resolveCustomModelExperimentDefaults('moonshotai/kimi-k3')).toEqual({
reasoningEffort: 'low',
supportsStructuredOutputs: true,
});
});
it('maps GLM 5.2 models to medium effort', () => {
@@ -28,8 +28,9 @@ const customModelDefaultEntrySchema = z
type CustomModelDefaultEntry = z.infer<typeof customModelDefaultEntrySchema>;
/**
* Known custom/* model experiment defaults. First case-insensitive substring
* match wins. Add entries here when a new custom model needs stable knobs.
* Known model experiment defaults (reasoning effort, structured output, etc.).
* First case-insensitive substring match wins — broader than proxy routing, which
* uses exact `isMoonshotaiKimiK3ModelId` for the Kimi trial cohort only.
*/
const CUSTOM_MODEL_DEFAULTS = [
{
+1
View File
@@ -127,6 +127,7 @@
"dependencies": {
"@1password/connect": "1.4.2",
"@ai-sdk/anthropic": "catalog:",
"@ai-sdk/openai-compatible": "catalog:",
"@asteasolutions/zod-to-openapi": "7.3.4",
"@apidevtools/json-schema-ref-parser": "12.0.2",
"@aws-sdk/client-secrets-manager": "3.808.0",
@@ -1,5 +1,6 @@
import type { Logger } from '@n8n/backend-common';
import type { CustomFetch, HttpTransport, OutboundHttp } from '@n8n/backend-network';
import type { GlobalConfig } from '@n8n/config';
import type { CredentialsEntity, SettingsRepository, User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
@@ -8,7 +9,15 @@ import type { CredentialsService } from '@/credentials/credentials.service';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import type { AiService } from '@/services/ai.service';
import { BUILDER_NOT_CONFIGURED_CODE } from '@n8n/api-types';
import { BUILDER_NOT_CONFIGURED_CODE, MOONSHOTAI_KIMI_K3_MODEL_ID } from '@n8n/api-types';
vi.mock('@ai-sdk/openai-compatible', () => ({
createOpenAICompatible: (opts: { name: string }) => (model: string) => ({
provider: opts.name,
modelId: model,
specificationVersion: 'v3',
}),
}));
import { AgentsBuilderSettingsService } from '../agents-builder-settings.service';
import { BuilderNotConfiguredError } from '../errors';
@@ -35,6 +44,9 @@ describe('AgentsBuilderSettingsService', () => {
const logger = mock<Logger>();
const settingsRepository = mock<SettingsRepository>();
const aiService = mock<AiService>();
const globalConfig = mock<GlobalConfig>({
instanceAi: { model: '' },
} as Partial<GlobalConfig>);
const credentialsService = mock<CredentialsService>();
const credentialsFinderService = mock<CredentialsFinderService>();
const outboundHttp = mock<OutboundHttp>();
@@ -48,10 +60,12 @@ describe('AgentsBuilderSettingsService', () => {
const transport = mock<HttpTransport>();
transport.asCustomFetch.mockReturnValue(vi.fn() as unknown as CustomFetch);
outboundHttp.transport.mockReturnValue(transport);
globalConfig.instanceAi.model = '';
service = new AgentsBuilderSettingsService(
logger,
settingsRepository,
aiService,
globalConfig,
credentialsService,
credentialsFinderService,
outboundHttp,
@@ -116,6 +130,47 @@ describe('AgentsBuilderSettingsService', () => {
);
});
it('mode=default + proxy enabled + Kimi env → Kimi proxy LanguageModel', async () => {
mockPersistedSettings({ mode: 'default' });
globalConfig.instanceAi.model = MOONSHOTAI_KIMI_K3_MODEL_ID;
const proxyToken = makeJwt(Math.floor(Date.now() / 1000) + 600);
const getBuilderApiProxyToken = vi
.fn()
.mockResolvedValue({ accessToken: proxyToken, tokenType: 'Bearer' });
aiService.isProxyEnabled.mockReturnValue(true);
aiService.getClient.mockResolvedValue({
getApiProxyBaseUrl: () => 'https://proxy.example/api',
getBuilderApiProxyToken,
} as never);
const result = await service.resolveModelConfig(user);
expect(result.isProxied).toBe(true);
expect(result.config).toMatchObject({
provider: 'moonshotai',
modelId: 'kimi-k3',
});
expect(result.tracingProxyConfig?.apiUrl).toBe('https://proxy.example/api/langsmith');
});
it('mode=default + proxy enabled + near-miss Kimi env → Anthropic default model', async () => {
mockPersistedSettings({ mode: 'default' });
globalConfig.instanceAi.model = 'moonshotai/kimi-k2';
const proxyToken = makeJwt(Math.floor(Date.now() / 1000) + 600);
aiService.isProxyEnabled.mockReturnValue(true);
aiService.getClient.mockResolvedValue({
getApiProxyBaseUrl: () => 'https://proxy.example/api',
getBuilderApiProxyToken: vi
.fn()
.mockResolvedValue({ accessToken: proxyToken, tokenType: 'Bearer' }),
} as never);
const result = await service.resolveModelConfig(user);
expect(result.isProxied).toBe(true);
expect(result.config).toMatchObject({ modelId: 'claude-sonnet-4-6' });
});
it('mode=default + proxy disabled + Instance AI env set → returns Instance AI model config', async () => {
mockPersistedSettings({ mode: 'default' });
aiService.isProxyEnabled.mockReturnValue(false);
@@ -363,7 +363,23 @@ describe('AgentsBuilderService session isolation', () => {
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
);
expect(agentsSdkMocks.promptCachingCalls).toEqual([{ anthropic: { ttl: '5m' } }]);
expect(agentsSdkMocks.promptCachingCalls).toEqual([
{ enabled: true, anthropic: { ttl: '5m' } },
]);
});
it('uses low reasoning and skips Anthropic prompt caching for proxied Kimi', async () => {
const { service, user, credentialProvider } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
...baseSession,
modelConfig: { provider: 'moonshotai', modelId: 'kimi-k3' } as never,
}),
);
expect(agentsSdkMocks.promptCachingCalls).toEqual([]);
expect(agentsSdkMocks.reasoningCalls).toEqual(['low']);
});
it.each([
@@ -3,16 +3,18 @@ import {
AGENT_BUILDER_DEFAULT_MODEL,
agentBuilderAdminSettingsSchema,
buildProxyHeaders,
isMoonshotaiKimiK3ModelId,
type AgentBuilderAdminSettings,
type AgentBuilderAdminSettingsResponse,
type AgentBuilderAdminSettingsUpdateRequest,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { OutboundHttp } from '@n8n/backend-network';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { SettingsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { jsonParse, UnexpectedError } from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { N8N_VERSION } from '@/constants';
@@ -21,7 +23,7 @@ import { CredentialsService } from '@/credentials/credentials.service';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { AiService } from '@/services/ai.service';
import { ProxyTokenManager } from '@/services/proxy-token-manager';
import { createAiProxyFetch } from '@/utils/ai-proxy-fetch';
import { createProxyLanguageModel } from '@/utils/ai-proxy-language-model';
import { BuilderNotConfiguredError } from './errors';
import {
@@ -89,6 +91,7 @@ export class AgentsBuilderSettingsService {
private readonly logger: Logger,
private readonly settingsRepository: SettingsRepository,
private readonly aiService: AiService,
private readonly globalConfig: GlobalConfig,
private readonly credentialsService: CredentialsService,
private readonly credentialsFinderService: CredentialsFinderService,
private readonly outboundHttp: OutboundHttp,
@@ -161,7 +164,9 @@ export class AgentsBuilderSettingsService {
if (settings.mode === 'custom') {
const fromCredential = await this.tryResolveCustomCredential(settings);
if (fromCredential) return { config: fromCredential, isProxied: false };
if (fromCredential) {
return { config: fromCredential, isProxied: false };
}
this.logger.warn(
'Agent builder custom credential could not be resolved; falling back to default',
{ credentialId: settings.credentialId },
@@ -218,54 +223,42 @@ export class AgentsBuilderSettingsService {
}
/**
* Build a native Anthropic `LanguageModel` pointed at the proxy. Auth
* headers are injected via a `fetch` wrapper backed by `ProxyTokenManager`
* so each request gets a fresh-or-cached token.
* Build a LanguageModel pointed at the proxy. Auth headers are injected via
* a `fetch` wrapper backed by `ProxyTokenManager` so each request gets a
* fresh-or-cached token.
*/
private async resolveProxyModel(user: User): Promise<ResolvedBuilderModelConfig> {
const client = await this.aiService.getClient();
const proxyBaseUrl = client.getApiProxyBaseUrl().replace(/\/$/, '');
const baseURL = proxyBaseUrl + '/anthropic/v1';
const configuredModelId = this.globalConfig.instanceAi.model.trim();
const isExactKimi = isMoonshotaiKimiK3ModelId(configuredModelId);
const modelId = isExactKimi ? configuredModelId : AGENT_BUILDER_DEFAULT_MODEL;
const tokenManager = new ProxyTokenManager(async () => {
return await client.getBuilderApiProxyToken({ id: user.id }, { userMessageId: nanoid() });
});
const proxyHeaders = buildProxyHeaders({
feature: 'agent-builder',
const proxyHeaders = {
feature: 'agent-builder' as const,
n8nVersion: N8N_VERSION,
});
};
const { createAnthropic } = await import('@ai-sdk/anthropic');
const proxyFetch = createAiProxyFetch(this.outboundHttp);
const provider = createAnthropic({
baseURL,
apiKey: 'proxy-managed',
fetch: async (input, init) => {
const headers = new Headers(init?.headers);
const auth = await tokenManager.getAuthHeaders();
for (const [k, v] of Object.entries(auth)) {
headers.set(k, v);
}
for (const [k, v] of Object.entries(proxyHeaders)) {
headers.set(k, v);
}
return await proxyFetch(input, { ...init, headers });
},
const model = await createProxyLanguageModel({
proxyBaseUrl,
modelId,
tokenManager,
feature: proxyHeaders.feature,
n8nVersion: proxyHeaders.n8nVersion,
outboundHttp: this.outboundHttp,
});
const model = provider(AGENT_BUILDER_DEFAULT_MODEL);
// `LanguageModel` from the AI SDK is structurally compatible with ModelConfig.
if (!model) {
throw new UnexpectedError('Failed to instantiate Anthropic proxy model');
}
const tracingHeaders = buildProxyHeaders(proxyHeaders);
return {
config: model as ModelConfig,
config: model,
isProxied: true,
tracingProxyConfig: {
apiUrl: proxyBaseUrl + '/langsmith',
getAuthHeaders: async () => ({
...(await tokenManager.getAuthHeaders()),
...proxyHeaders,
...tracingHeaders,
}),
},
};
@@ -14,7 +14,11 @@ import { createObservationLogObserveFn, createObservationLogReflectFn } from '@n
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { tokenUsageToBuilderUsageItems } from '@n8n/instance-ai';
import {
resolveAIAPromptCaching,
resolveAIAReasoning,
tokenUsageToBuilderUsageItems,
} from '@n8n/instance-ai';
import { jsonParse } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -270,12 +274,15 @@ export class AgentsBuilderService {
const builder = new Agent('agent-builder')
.model(modelConfig)
.promptCaching({ anthropic: { ttl: '5m' } })
.instructions(finalInstructions)
.skills(runtimeSkills)
.memory(builderMemory)
.checkpoint(this.n8nCheckpointStorage.getStorage(agentId))
.configuration({ maxIterations: 30 });
const promptCaching = resolveAIAPromptCaching(modelConfig);
if (promptCaching) {
builder.promptCaching(promptCaching);
}
if (session.telemetry) builder.telemetry(session.telemetry);
if (session.memoryTaskObserver) builder.memoryTaskObserver(session.memoryTaskObserver);
@@ -291,7 +298,7 @@ export class AgentsBuilderService {
}),
);
builder.reasoning('medium');
builder.reasoning(resolveAIAReasoning(modelConfig));
return builder;
}
@@ -1,4 +1,4 @@
import { UNLIMITED_CREDITS } from '@n8n/api-types';
import { MOONSHOTAI_KIMI_K3_MODEL_ID, UNLIMITED_CREDITS } from '@n8n/api-types';
import type { OutboundHttp } from '@n8n/backend-network';
import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
@@ -15,6 +15,11 @@ vi.mock('@/services/proxy-token-manager', () => ({
},
}));
const createProxyLanguageModel = vi.hoisted(() => vi.fn());
vi.mock('@/utils/ai-proxy-language-model', () => ({
createProxyLanguageModel: (...args: unknown[]) => createProxyLanguageModel(...args),
}));
import { InstanceAiModelService } from '../instance-ai-model.service';
import type { InstanceAiSettingsService } from '../instance-ai-settings.service';
@@ -126,4 +131,46 @@ describe('InstanceAiModelService', () => {
);
});
});
describe('resolveProxyModel', () => {
const tokenManager = { getAuthHeaders: vi.fn() } as never;
it('passes the exact Kimi id to the shared proxy factory', async () => {
settingsService.getConfiguredModelId.mockReturnValue(MOONSHOTAI_KIMI_K3_MODEL_ID);
createProxyLanguageModel.mockResolvedValue({
provider: 'moonshotai',
modelId: 'kimi-k3',
});
await service.resolveProxyModel(fakeUser, 'https://proxy.base/', tokenManager);
expect(createProxyLanguageModel).toHaveBeenCalledWith({
proxyBaseUrl: 'https://proxy.base/',
modelId: MOONSHOTAI_KIMI_K3_MODEL_ID,
tokenManager,
feature: 'instance-ai',
n8nVersion: expect.any(String),
outboundHttp,
});
expect(settingsService.resolveModelName).not.toHaveBeenCalled();
});
it('keeps Anthropic routing for other configured models', async () => {
settingsService.getConfiguredModelId.mockReturnValue('anthropic/claude-opus-5');
settingsService.resolveModelName.mockReturnValue('claude-opus-5');
createProxyLanguageModel.mockResolvedValue({
provider: 'anthropic.messages',
modelId: 'claude-opus-5',
});
await service.resolveProxyModel(fakeUser, 'https://proxy.base', tokenManager);
expect(createProxyLanguageModel).toHaveBeenCalledWith(
expect.objectContaining({
modelId: 'claude-opus-5',
feature: 'instance-ai',
}),
);
});
});
});
@@ -1,4 +1,8 @@
import { UNLIMITED_CREDITS, buildProxyHeaders, type InstanceAiCredits } from '@n8n/api-types';
import {
UNLIMITED_CREDITS,
isMoonshotaiKimiK3ModelId,
type InstanceAiCredits,
} from '@n8n/api-types';
import { OutboundHttp } from '@n8n/backend-network';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
@@ -9,6 +13,7 @@ import { N8N_VERSION } from '@/constants';
import { AiService } from '@/services/ai.service';
import { ProxyTokenManager } from '@/services/proxy-token-manager';
import { createAiProxyFetch } from '@/utils/ai-proxy-fetch';
import { createProxyLanguageModel } from '@/utils/ai-proxy-language-model';
import { callAiServiceWithRetry } from '@/utils/ai-service-retry';
import { InstanceAiSettingsService } from './instance-ai-settings.service';
@@ -19,8 +24,7 @@ import { InstanceAiSettingsService } from './instance-ai-settings.service';
*
* Model resolution follows a layered chain so chat and eval paths share the
* same working model:
* 1. AI service proxy (when enabled) — wraps with proxy auth, returns a
* native Anthropic transport pointed at the proxy.
* 1. AI service proxy (when enabled) — wraps with proxy auth.
* 2. HTTP_PROXY (when set, e.g. e2e tests) — wraps the model with a
* proxy-aware fetch.
* 3. Env vars / user credential — raw settings resolution.
@@ -71,13 +75,8 @@ export class InstanceAiModelService {
}
/**
* Build model config. When the AI service proxy is enabled, returns a native
* Anthropic LanguageModelV2 instance pointing at the proxy.
*
* We use `@ai-sdk/anthropic` directly instead of returning a `{ url }` config
* object because this proxy route needs the native Anthropic transport.
* The proxy may forward to Vertex AI, which only supports the native Anthropic
* Messages API (`/v1/messages`), not the OpenAI-compatible endpoint.
* Build model config. When the AI service proxy is enabled, returns a
* LanguageModel pointed at the proxy.
*
* Auth headers are injected via a custom `fetch` wrapper so that each
* request gets a fresh-or-cached token from the ProxyTokenManager,
@@ -88,29 +87,17 @@ export class InstanceAiModelService {
proxyBaseUrl: string,
tokenManager: ProxyTokenManager,
): Promise<ModelConfig> {
const modelName = 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.
const modelFetch = createAiProxyFetch(this.outboundHttp);
const provider = createAnthropic({
baseURL: proxyBaseUrl + '/anthropic/v1',
apiKey: 'proxy-managed',
fetch: async (input, init) => {
const headers = new Headers(init?.headers);
const auth = await tokenManager.getAuthHeaders();
for (const [k, v] of Object.entries(auth)) {
headers.set(k, v);
}
for (const [k, v] of Object.entries(
buildProxyHeaders({ feature: 'instance-ai', n8nVersion: N8N_VERSION }),
)) {
headers.set(k, v);
}
return await modelFetch(input, { ...init, headers });
},
const configuredModelId = this.settingsService.getConfiguredModelId();
const isExactKimi = isMoonshotaiKimiK3ModelId(configuredModelId);
const modelId = isExactKimi ? configuredModelId : this.settingsService.resolveModelName(user);
return await createProxyLanguageModel({
proxyBaseUrl,
modelId,
tokenManager,
feature: 'instance-ai',
n8nVersion: N8N_VERSION,
outboundHttp: this.outboundHttp,
});
return provider(modelName);
}
/**
@@ -1330,6 +1330,10 @@ export class InstanceAiSettingsService {
return modelConfigured && sandboxConfigured && searchDecided;
}
getConfiguredModelId(): string {
return this.config.model.trim();
}
/** Resolve just the model name (e.g. 'claude-sonnet-4-20250514') for proxy routing. */
resolveModelName(user: User): string {
const prefs = this.readUserPreferences(user);
@@ -0,0 +1,148 @@
import {
MOONSHOTAI_KIMI_K3_MODEL_ID,
MOONSHOTAI_KIMI_K3_MODEL_NAME,
MOONSHOTAI_KIMI_K3_PROVIDER,
X_N8N_FEATURE_HEADER,
} from '@n8n/api-types';
import type { OutboundHttp } from '@n8n/backend-network';
import { mock } from 'vitest-mock-extended';
import type { ProxyTokenManager } from '@/services/proxy-token-manager';
const sdk = vi.hoisted(() => {
const anthropicCalls: Array<{ opts: Record<string, unknown>; model: string }> = [];
const kimiCalls: Array<{ opts: Record<string, unknown>; model: string }> = [];
return {
anthropicCalls,
kimiCalls,
createAnthropic: (opts: Record<string, unknown>) => (model: string) => {
anthropicCalls.push({ opts, model });
return { provider: 'anthropic.messages', modelId: model, specificationVersion: 'v3' };
},
createOpenAICompatible: (opts: Record<string, unknown>) => (model: string) => {
kimiCalls.push({ opts, model });
return { provider: opts.name, modelId: model, specificationVersion: 'v3' };
},
};
});
vi.mock('@ai-sdk/anthropic', () => ({
createAnthropic: sdk.createAnthropic,
}));
vi.mock('@ai-sdk/openai-compatible', () => ({
createOpenAICompatible: sdk.createOpenAICompatible,
}));
const modelFetch = vi.hoisted(() =>
vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
async () => new Response('ok'),
),
);
vi.mock('@/utils/ai-proxy-fetch', () => ({
createAiProxyFetch: vi.fn(() => modelFetch),
}));
import { createProxyLanguageModel } from '../ai-proxy-language-model';
describe('createProxyLanguageModel', () => {
const outboundHttp = mock<OutboundHttp>();
const tokenManager = {
getAuthHeaders: vi.fn(async () => ({ Authorization: 'Bearer tok' })),
} as unknown as ProxyTokenManager;
beforeEach(() => {
vi.clearAllMocks();
sdk.anthropicCalls.length = 0;
sdk.kimiCalls.length = 0;
modelFetch.mockResolvedValue(new Response('ok'));
});
it('routes exact moonshotai/kimi-k3 through the Kimi OpenAI-compatible proxy', async () => {
const model = await createProxyLanguageModel({
proxyBaseUrl: 'https://proxy.example/api/',
modelId: MOONSHOTAI_KIMI_K3_MODEL_ID,
tokenManager,
feature: 'instance-ai',
n8nVersion: '1.2.3',
outboundHttp,
});
expect(sdk.kimiCalls).toHaveLength(1);
expect(sdk.anthropicCalls).toHaveLength(0);
expect(sdk.kimiCalls[0]?.model).toBe(MOONSHOTAI_KIMI_K3_MODEL_NAME);
expect(sdk.kimiCalls[0]?.opts).toMatchObject({
name: MOONSHOTAI_KIMI_K3_PROVIDER,
baseURL: 'https://proxy.example/api/kimi/v1',
apiKey: 'proxy-managed',
supportsStructuredOutputs: true,
includeUsage: true,
});
expect(model).toMatchObject({
provider: MOONSHOTAI_KIMI_K3_PROVIDER,
modelId: MOONSHOTAI_KIMI_K3_MODEL_NAME,
});
});
it.each(['moonshotai/kimi-k2', 'custom/moonshotai/kimi-k3', 'anthropic/claude-opus-5'] as const)(
'keeps the Anthropic proxy path for %s',
async (modelId) => {
await createProxyLanguageModel({
proxyBaseUrl: 'https://proxy.example/api',
modelId,
tokenManager,
feature: 'instance-ai',
n8nVersion: '1.2.3',
outboundHttp,
});
expect(sdk.kimiCalls).toHaveLength(0);
expect(sdk.anthropicCalls).toHaveLength(1);
expect(sdk.anthropicCalls[0]?.opts.baseURL).toBe('https://proxy.example/api/anthropic/v1');
},
);
it('uses a bare Anthropic model name as-is', async () => {
await createProxyLanguageModel({
proxyBaseUrl: 'https://proxy.example/api',
modelId: 'claude-sonnet-4-6',
tokenManager,
feature: 'agent-builder',
n8nVersion: '1.2.3',
outboundHttp,
});
expect(sdk.anthropicCalls[0]?.model).toBe('claude-sonnet-4-6');
expect(sdk.anthropicCalls[0]?.opts.baseURL).toBe('https://proxy.example/api/anthropic/v1');
});
it('stamps proxy auth and feature headers on Kimi requests', async () => {
await createProxyLanguageModel({
proxyBaseUrl: 'https://proxy.example/api',
modelId: MOONSHOTAI_KIMI_K3_MODEL_ID,
tokenManager,
feature: 'instance-ai',
n8nVersion: '1.2.3',
outboundHttp,
});
const fetch = sdk.kimiCalls[0]?.opts.fetch as typeof globalThis.fetch;
await fetch('https://proxy.example/api/kimi/v1/chat/completions', { method: 'POST' });
expect(modelFetch).toHaveBeenCalledWith(
'https://proxy.example/api/kimi/v1/chat/completions',
expect.objectContaining({
headers: expect.any(Headers),
}),
);
const headers = modelFetch.mock.calls[0]?.[1]?.headers;
expect(headers).toBeInstanceOf(Headers);
if (!(headers instanceof Headers)) {
throw new Error('expected Headers');
}
expect(headers.get('Authorization')).toBe('Bearer tok');
expect(headers.get(X_N8N_FEATURE_HEADER)).toBe('instance-ai');
expect(headers.get('x-n8n-version')).toBe('1.2.3');
});
});
@@ -0,0 +1,69 @@
import {
MOONSHOTAI_KIMI_K3_MODEL_NAME,
MOONSHOTAI_KIMI_K3_PROVIDER,
buildProxyHeaders,
isMoonshotaiKimiK3ModelId,
type N8nProxyFeature,
} from '@n8n/api-types';
import type { OutboundHttp } from '@n8n/backend-network';
import type { LanguageModel } from 'ai';
import type { ProxyTokenManager } from '@/services/proxy-token-manager';
import { createAiProxyFetch } from '@/utils/ai-proxy-fetch';
const ANTHROPIC_PROXY_PATH = '/anthropic/v1';
const KIMI_PROXY_PATH = '/kimi/v1';
export interface CreateProxyLanguageModelOptions {
proxyBaseUrl: string;
modelId: string;
tokenManager: ProxyTokenManager;
feature: N8nProxyFeature;
n8nVersion: string;
outboundHttp: OutboundHttp;
}
export async function createProxyLanguageModel(
options: CreateProxyLanguageModelOptions,
): Promise<LanguageModel> {
const proxyBaseUrl = options.proxyBaseUrl.replace(/\/$/, '');
const proxyHeaders = buildProxyHeaders({
feature: options.feature,
n8nVersion: options.n8nVersion,
});
const modelFetch = createAiProxyFetch(options.outboundHttp);
const fetch: typeof globalThis.fetch = async (input, init) => {
const headers = new Headers(init?.headers);
const auth = await options.tokenManager.getAuthHeaders();
for (const [k, v] of Object.entries(auth)) {
headers.set(k, v);
}
for (const [k, v] of Object.entries(proxyHeaders)) {
headers.set(k, v);
}
return await modelFetch(input, { ...init, headers });
};
if (isMoonshotaiKimiK3ModelId(options.modelId)) {
const openaiCompatible: typeof import('@ai-sdk/openai-compatible') = await import(
'@ai-sdk/openai-compatible'
);
return openaiCompatible.createOpenAICompatible({
name: MOONSHOTAI_KIMI_K3_PROVIDER,
baseURL: proxyBaseUrl + KIMI_PROXY_PATH,
apiKey: 'proxy-managed',
fetch,
supportsStructuredOutputs: true,
includeUsage: true,
})(MOONSHOTAI_KIMI_K3_MODEL_NAME);
}
const { createAnthropic } = await import('@ai-sdk/anthropic');
const slash = options.modelId.indexOf('/');
const modelName = slash >= 0 ? options.modelId.slice(slash + 1) : options.modelId;
return createAnthropic({
baseURL: proxyBaseUrl + ANTHROPIC_PROXY_PATH,
apiKey: 'proxy-managed',
fetch,
})(modelName);
}
+3
View File
@@ -4102,6 +4102,9 @@ importers:
'@ai-sdk/anthropic':
specifier: 'catalog:'
version: 4.0.27(zod@3.25.76)
'@ai-sdk/openai-compatible':
specifier: 'catalog:'
version: 3.0.14(zod@3.25.76)
'@apidevtools/json-schema-ref-parser':
specifier: 12.0.2
version: 12.0.2