mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-31 02:17:56 +08:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
+56
-1
@@ -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);
|
||||
|
||||
+17
-1
@@ -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);
|
||||
}
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user