first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
dist
+351
View File
@@ -0,0 +1,351 @@
# @n8n/ai-node-sdk
> **Preview:** This package is in preview. The API may change without notice. AI nodes are not yet accepted for verification.
Public SDK for building AI nodes in n8n. This package provides a simplified API for creating chat model and memory nodes without LangChain dependencies.
## Installation in node packages
Include the package in your node packages by updating `peerDependencies`:
```json
{
"peerDependencies": {
"n8n-workflow": "*",
"@n8n/ai-node-sdk": "*"
}
}
```
## Development
```bash
# Build the package
pnpm build
# Run tests
pnpm test
# Run in watch mode
pnpm dev
```
## Chat Model Nodes
Chat model nodes implement the `INodeType` interface and use `supplyModel` to provide model instances.
### Simple Pattern: OpenAI-Compatible Providers
For OpenAI-compatible providers, use the config object pattern with `supplyModel`:
```typescript
import { supplyModel } from '@n8n/ai-node-sdk';
import {
type INodeType,
type INodeTypeDescription,
NodeConnectionTypes,
type SupplyData,
type ISupplyDataFunctions,
} from 'n8n-workflow';
export class LmChatMyProvider implements INodeType {
description: INodeTypeDescription = {
displayName: 'MyProvider Chat Model',
name: 'lmChatMyProvider',
icon: 'fa:robot',
group: ['transform'],
version: [1],
description: 'For advanced usage with an AI chain',
defaults: {
name: 'MyProvider Chat Model',
},
inputs: [],
outputs: [NodeConnectionTypes.AiLanguageModel],
credentials: [{ name: 'myProviderApi', required: true }],
properties: [
{
displayName: 'Model',
name: 'model',
type: 'string',
default: 'my-model',
},
{
displayName: 'Temperature',
name: 'temperature',
type: 'number',
default: 0.7,
},
],
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('myProviderApi');
const model = this.getNodeParameter('model', itemIndex) as string;
const temperature = this.getNodeParameter('temperature', itemIndex) as number;
// Return config for OpenAI-compatible providers
return supplyModel(this, {
type: 'openai',
baseUrl: credentials.url as string,
apiKey: credentials.apiKey as string,
model,
temperature,
});
}
}
```
### Advanced Pattern: Custom Model Class
For providers with custom APIs, extend `BaseChatModel` and pass an instance to `supplyModel`:
```typescript
import {
BaseChatModel,
supplyModel,
type Message,
type GenerateResult,
type StreamChunk,
type ChatModelConfig,
} from '@n8n/ai-node-sdk';
import {
type INodeType,
type INodeTypeDescription,
NodeConnectionTypes,
type IHttpRequestMethods,
type ISupplyDataFunctions,
type SupplyData,
} from 'n8n-workflow';
import type Stream from 'node:stream';
import { Readable } from 'node:stream';
// Custom model implementation
class MyProviderChatModel extends BaseChatModel {
constructor(
modelId: string,
private requests: {
httpRequest: (
method: IHttpRequestMethods,
url: string,
body?: object,
headers?: Record<string, string>,
) => Promise<{ body: unknown }>;
openStream: (
method: IHttpRequestMethods,
url: string,
body?: object,
headers?: Record<string, string>,
) => Promise<{ body: ReadableStream<Uint8Array> }>;
},
config?: ChatModelConfig,
) {
super('my-provider', modelId, config);
}
async generate(messages: Message[], config?: ChatModelConfig): Promise<GenerateResult> {
// Convert n8n messages to provider format
const providerMessages = messages.map(m => ({
role: m.role,
content: m.content.find(c => c.type === 'text')?.text ?? '',
}));
// Call the provider API
const response = await this.requests.httpRequest('POST', '/chat', {
model: this.modelId,
messages: providerMessages,
temperature: config?.temperature,
});
const body = response.body as any;
return {
finishReason: 'stop',
message: {
id: body.id,
role: 'assistant',
content: [{ type: 'text', text: body.content }],
},
usage: {
promptTokens: body.usage.prompt_tokens,
completionTokens: body.usage.completion_tokens,
totalTokens: body.usage.total_tokens,
},
};
}
async *stream(messages: Message[], config?: ChatModelConfig): AsyncIterable<StreamChunk> {
// Implement streaming...
yield { type: 'text-delta', delta: 'response text' };
yield { type: 'finish', finishReason: 'stop' };
}
}
// Node definition
export class LmChatMyProvider implements INodeType {
description: INodeTypeDescription = {
displayName: 'MyProvider Chat Model',
name: 'lmChatMyProvider',
icon: 'fa:robot',
group: ['transform'],
version: [1],
description: 'For advanced usage with an AI chain',
defaults: {
name: 'MyProvider Chat Model',
},
inputs: [],
outputs: [NodeConnectionTypes.AiLanguageModel],
credentials: [{ name: 'myProviderApi', required: true }],
properties: [
{ displayName: 'Model', name: 'model', type: 'string', default: 'my-model' },
{ displayName: 'Temperature', name: 'temperature', type: 'number', default: 0.7 },
],
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const model = this.getNodeParameter('model', itemIndex) as string;
const temperature = this.getNodeParameter('temperature', itemIndex) as number;
const chatModel = new MyProviderChatModel(
model,
{
httpRequest: async (method, url, body, headers) => {
const response = await this.helpers.httpRequestWithAuthentication.call(
this,
'myProviderApi',
{ method, url, body, headers },
);
return { body: response };
},
openStream: async (method, url, body, headers) => {
const response = (await this.helpers.httpRequestWithAuthentication.call(
this,
'myProviderApi',
{ method, url, body, headers, encoding: 'stream' },
)) as Stream.Readable;
return { body: Readable.toWeb(response) as ReadableStream<Uint8Array> };
},
},
{ temperature },
);
return supplyModel(this, chatModel);
}
}
```
## Memory Nodes
Memory nodes implement the `INodeType` interface and use `supplyMemory` to provide memory instances.
### Pattern: Custom Storage with Windowed Memory
Extend `BaseChatHistory` to implement storage, then wrap it with `WindowedChatMemory` and pass to `supplyMemory`:
```typescript
import {
BaseChatHistory,
WindowedChatMemory,
supplyMemory,
type Message,
} from '@n8n/ai-node-sdk';
import {
type INodeType,
type INodeTypeDescription,
NodeConnectionTypes,
type ISupplyDataFunctions,
type SupplyData,
} from 'n8n-workflow';
// Custom storage implementation
class MyDbChatHistory extends BaseChatHistory {
constructor(
private sessionId: string,
private apiKey: string,
private httpRequest: any,
) {
super();
}
async getMessages(): Promise<Message[]> {
const data = await this.httpRequest({
method: 'GET',
url: `/sessions/${this.sessionId}/messages`,
headers: { Authorization: `Bearer ${this.apiKey}` },
json: true,
});
return data.messages.map((m: any) => ({
role: m.role,
content: [{ type: 'text', text: m.content }],
}));
}
async addMessage(message: Message): Promise<void> {
const text = message.content.find(c => c.type === 'text')?.text ?? '';
await this.httpRequest({
method: 'POST',
url: `/sessions/${this.sessionId}/messages`,
headers: { Authorization: `Bearer ${this.apiKey}` },
body: { role: message.role, content: text },
json: true,
});
}
async clear(): Promise<void> {
await this.httpRequest({
method: 'DELETE',
url: `/sessions/${this.sessionId}`,
headers: { Authorization: `Bearer ${this.apiKey}` },
});
}
}
// Memory node
export class MemoryMyDb implements INodeType {
description: INodeTypeDescription = {
displayName: 'MyDB Memory',
name: 'memoryMyDb',
icon: 'fa:database',
group: ['transform'],
version: [1],
description: 'Store conversation history in MyDB',
defaults: {
name: 'MyDB Memory',
},
inputs: [],
outputs: [NodeConnectionTypes.AiMemory],
credentials: [{ name: 'myDbApi', required: true }],
properties: [
{
displayName: 'Session ID',
name: 'sessionId',
type: 'string',
default: '={{ $json.sessionId }}',
},
{
displayName: 'Window Size',
name: 'windowSize',
type: 'number',
default: 10,
description: 'Number of recent message pairs to keep',
},
],
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('myDbApi');
const sessionId = this.getNodeParameter('sessionId', itemIndex) as string;
const windowSize = this.getNodeParameter('windowSize', itemIndex) as number;
const history = new MyDbChatHistory(
sessionId,
credentials.apiKey as string,
this.helpers.httpRequest,
);
const memory = new WindowedChatMemory(history, { windowSize });
return supplyMemory(this, memory);
}
}
```
@@ -0,0 +1,13 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig(nodeConfig, globalIgnores(['dist/**']), {
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'@typescript-eslint/naming-convention': 'warn',
},
});
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../../jest.config'),
collectCoverageFrom: ['src/**/*.ts'],
coveragePathIgnorePatterns: ['src/index.ts'],
};
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@n8n/ai-node-sdk",
"version": "0.3.0",
"description": "SDK for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",
"main": "dist/cjs/index.js",
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./*": "./*"
},
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc --build tsconfig.build.esm.json tsconfig.build.cjs.json",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc --build tsconfig.build.esm.json tsconfig.build.cjs.json --watch",
"test": "jest --passWithNoTests",
"test:unit": "jest --passWithNoTests",
"test:dev": "jest --watch"
},
"files": [
"dist"
],
"dependencies": {
"@n8n/ai-utilities": "workspace:*"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*"
}
}
+37
View File
@@ -0,0 +1,37 @@
export { parseSSEStream } from '@n8n/ai-utilities';
export type { GenerateResult, StreamChunk, TokenUsage, FinishReason } from '@n8n/ai-utilities';
export type { Tool, ToolResult, ToolCall, ProviderTool } from '@n8n/ai-utilities';
export type {
Message,
ContentFile,
ContentMetadata,
ContentReasoning,
ContentText,
ContentToolCall,
ContentToolResult,
MessageContent,
MessageRole,
} from '@n8n/ai-utilities';
export type { JSONArray, JSONObject, JSONValue } from '@n8n/ai-utilities';
export type { ServerSentEventMessage } from '@n8n/ai-utilities';
export { getParametersJsonSchema } from '@n8n/ai-utilities';
// Chat model types
export type { ChatModel, ChatModelConfig } from '@n8n/ai-utilities';
// Chat model base classes
export { BaseChatModel } from '@n8n/ai-utilities';
// Memory types
export type { ChatHistory, ChatMemory } from '@n8n/ai-utilities';
// Memory base classes
export { BaseChatHistory } from '@n8n/ai-utilities';
export { BaseChatMemory } from '@n8n/ai-utilities';
// Memory implementations
export { WindowedChatMemory, type WindowedChatMemoryConfig } from '@n8n/ai-utilities';
// Suppliers
export { supplyMemory, type SupplyMemoryOptions } from '@n8n/ai-utilities';
export { supplyModel, type SupplyModelOptions, type OpenAiModel } from '@n8n/ai-utilities';
@@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/modern/tsconfig.cjs.json"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist/cjs",
"tsBuildInfoFile": "dist/cjs/typecheck.tsbuildinfo",
"declaration": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "src/**/__tests__/**", "src/**/*.test.ts"]
}
@@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist/esm",
"tsBuildInfoFile": "dist/esm/typecheck.tsbuildinfo",
"declaration": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "src/**/__tests__/**", "src/**/*.test.ts"]
}
@@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": [
"@n8n/typescript-config/tsconfig.common.json",
"@n8n/typescript-config/tsconfig.backend.json"
],
"compilerOptions": {
"rootDir": ".",
"baseUrl": ".",
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[package.json]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.yml]
indent_style = space
indent_size = 2
+9
View File
@@ -0,0 +1,9 @@
node_modules
.DS_Store
.tmp
tmp
dist
npm-debug.log*
yarn.lock
.vscode/launch.json
.env
+2
View File
@@ -0,0 +1,2 @@
.DS_Store
*.tsbuildinfo
+29
View File
@@ -0,0 +1,29 @@
# @n8n/ai-utilities
Core utilities and abstractions for AI functionality in n8n. This package provides the foundational building blocks used internally by the n8n platform.
This package is reexported from @n8n/ai-node-sdk, that exposes methods and types for public usage.
When changing logic in this package, make sure your changes are backwards compatible. What that means:
- don't remove existing interfaces or properties in them
- make new properties optional or create new versions of interfaces
- publicly exposed methods should handle both old and new interfaces
- when making a breaking change or adding a new public helper function that is exported in `@n8n/ai-node-sdk`, make sure to update `AI_NODE_SDK_VERSION` in `ai-node-sdk-version.ts`
## Development
```bash
# Build the package
pnpm build
# Run tests
pnpm test
# Run in watch mode
pnpm dev
```
## Usage
For public SDK documentation see `@n8n/ai-node-sdk`.
@@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig(nodeConfig, globalIgnores(['scripts/**']), {
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'no-case-declarations': 'warn',
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
'@typescript-eslint/naming-convention': 'warn',
},
});
@@ -0,0 +1,623 @@
import axios from 'axios';
import { tool } from 'langchain';
import { Readable } from 'node:stream';
import z from 'zod';
export const weatherTool = tool(
({ city }) => {
return `It's always sunny in ${city}!`;
},
{
name: 'get_weather',
description: 'Get weather for a given city.',
schema: z.object({
city: z.string(),
}),
},
);
export const mockToolCallResponse = {
id: 'resp_02a127c1e73b5fe4016989e989cb188195a27d4911084e4223',
object: 'response',
created_at: 1770645897,
status: 'completed',
background: false,
billing: { payer: 'developer' },
completed_at: 1770645898,
error: null,
frequency_penalty: 0,
incomplete_details: null,
instructions: null,
max_output_tokens: null,
max_tool_calls: null,
model: 'gpt-4o-2024-08-06',
output: [
{
id: 'fc_02a127c1e73b5fe4016989e98a70b881959fb6cf1d58b5db8b',
type: 'function_call',
status: 'completed',
arguments: '{"city":"Tokyo"}',
call_id: 'call_YONsRdkCKu8Sh8WGkUiXqlYW',
name: 'get_weather',
},
],
parallel_tool_calls: true,
presence_penalty: 0,
previous_response_id: null,
prompt_cache_key: null,
prompt_cache_retention: null,
reasoning: { effort: null, summary: null },
safety_identifier: null,
service_tier: 'default',
store: false,
temperature: 1,
text: { format: { type: 'text' }, verbosity: 'medium' },
tool_choice: 'auto',
tools: [
{
type: 'function',
description: 'Get weather for a given city.',
name: 'get_weather',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
additionalProperties: false,
},
strict: true,
},
],
top_logprobs: 0,
top_p: 1,
truncation: 'disabled',
usage: {
input_tokens: 46,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 15,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 61,
},
user: null,
metadata: {},
};
export const mockFinalResponse = {
id: 'resp_00a8729c01103919016989e98b13888190bca486b9676ce0cd',
object: 'response',
created_at: 1770645899,
status: 'completed',
background: false,
billing: { payer: 'developer' },
completed_at: 1770645899,
error: null,
frequency_penalty: 0,
incomplete_details: null,
instructions: null,
max_output_tokens: null,
max_tool_calls: null,
model: 'gpt-4o-2024-08-06',
output: [
{
id: 'msg_00a8729c01103919016989e98bb58c81909a9eb194728f1db0',
type: 'message',
status: 'completed',
content: [
{
type: 'output_text',
annotations: [],
logprobs: [],
text: "It's always sunny in Tokyo!",
},
],
role: 'assistant',
},
],
parallel_tool_calls: true,
presence_penalty: 0,
previous_response_id: null,
prompt_cache_key: null,
prompt_cache_retention: null,
reasoning: { effort: null, summary: null },
safety_identifier: null,
service_tier: 'default',
store: false,
temperature: 1,
text: { format: { type: 'text' }, verbosity: 'medium' },
tool_choice: 'auto',
tools: [
{
type: 'function',
description: 'Get weather for a given city.',
name: 'get_weather',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
additionalProperties: false,
},
strict: true,
},
],
top_logprobs: 0,
top_p: 1,
truncation: 'disabled',
usage: {
input_tokens: 76,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 8,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 84,
},
user: null,
metadata: {},
};
export const mockStreamToolCallEvents = [
{
type: 'event',
data: {
type: 'response.created',
response: {
id: 'resp_stream_001',
object: 'response',
created_at: 1770647361,
status: 'in_progress',
model: 'gpt-4o-2024-08-06',
},
sequence_number: 0,
},
},
{
type: 'event',
data: {
type: 'response.in_progress',
response: {
id: 'resp_stream_001',
status: 'in_progress',
},
sequence_number: 1,
},
},
{
type: 'event',
data: {
type: 'response.output_item.added',
item: {
id: 'fc_stream_001',
type: 'function_call',
status: 'in_progress',
arguments: '',
call_id: 'call_StreamTest123',
name: 'get_weather',
},
output_index: 0,
sequence_number: 2,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.delta',
delta: '{"',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 3,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.delta',
delta: 'city',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 4,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.delta',
delta: '":"',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 5,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.delta',
delta: 'Tokyo',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 6,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.delta',
delta: '"}',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 7,
},
},
{
type: 'event',
data: {
type: 'response.function_call_arguments.done',
arguments: '{"city":"Tokyo"}',
item_id: 'fc_stream_001',
output_index: 0,
sequence_number: 8,
},
},
{
type: 'event',
data: {
type: 'response.output_item.done',
item: {
id: 'fc_stream_001',
type: 'function_call',
status: 'completed',
arguments: '{"city":"Tokyo"}',
call_id: 'call_StreamTest123',
name: 'get_weather',
},
output_index: 0,
sequence_number: 9,
},
},
{
type: 'event',
data: {
type: 'response.completed',
response: {
id: 'resp_stream_001',
object: 'response',
created_at: 1770647361,
status: 'completed',
completed_at: 1770647362,
model: 'gpt-4o-2024-08-06',
output: [
{
id: 'fc_stream_001',
type: 'function_call',
status: 'completed',
arguments: '{"city":"Tokyo"}',
call_id: 'call_StreamTest123',
name: 'get_weather',
},
],
usage: {
input_tokens: 46,
input_tokens_details: {
cached_tokens: 0,
},
output_tokens: 15,
output_tokens_details: {
reasoning_tokens: 0,
},
total_tokens: 61,
},
},
sequence_number: 10,
},
},
{
type: 'done',
data: null,
},
];
export const mockStreamFinalResponseEvents = [
{
type: 'event',
data: {
type: 'response.created',
response: {
id: 'resp_stream_002',
object: 'response',
created_at: 1770647362,
status: 'in_progress',
model: 'gpt-4o-2024-08-06',
},
sequence_number: 0,
},
},
{
type: 'event',
data: {
type: 'response.in_progress',
response: {
id: 'resp_stream_002',
status: 'in_progress',
},
sequence_number: 1,
},
},
{
type: 'event',
data: {
type: 'response.output_item.added',
item: {
id: 'msg_stream_002',
type: 'message',
status: 'in_progress',
content: [],
role: 'assistant',
},
output_index: 0,
sequence_number: 2,
},
},
{
type: 'event',
data: {
type: 'response.content_part.added',
content_index: 0,
item_id: 'msg_stream_002',
output_index: 0,
part: {
type: 'output_text',
annotations: [],
logprobs: [],
text: '',
},
sequence_number: 3,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: "It's",
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 4,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: ' always',
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 5,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: ' sunny',
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 6,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: ' in',
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 7,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: ' Tokyo',
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 8,
},
},
{
type: 'event',
data: {
type: 'response.output_text.delta',
content_index: 0,
delta: '!',
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 9,
},
},
{
type: 'event',
data: {
type: 'response.output_text.done',
content_index: 0,
item_id: 'msg_stream_002',
logprobs: [],
output_index: 0,
sequence_number: 10,
text: "It's always sunny in Tokyo!",
},
},
{
type: 'event',
data: {
type: 'response.content_part.done',
content_index: 0,
item_id: 'msg_stream_002',
output_index: 0,
part: {
type: 'output_text',
annotations: [],
logprobs: [],
text: "It's always sunny in Tokyo!",
},
sequence_number: 11,
},
},
{
type: 'event',
data: {
type: 'response.output_item.done',
item: {
id: 'msg_stream_002',
type: 'message',
status: 'completed',
content: [
{
type: 'output_text',
annotations: [],
logprobs: [],
text: "It's always sunny in Tokyo!",
},
],
role: 'assistant',
},
output_index: 0,
sequence_number: 12,
},
},
{
type: 'event',
data: {
type: 'response.completed',
response: {
id: 'resp_stream_002',
object: 'response',
created_at: 1770647362,
status: 'completed',
completed_at: 1770647363,
model: 'gpt-4o-2024-08-06',
output: [
{
id: 'msg_stream_002',
type: 'message',
status: 'completed',
content: [
{
type: 'output_text',
annotations: [],
logprobs: [],
text: "It's always sunny in Tokyo!",
},
],
role: 'assistant',
},
],
usage: {
input_tokens: 76,
input_tokens_details: {
cached_tokens: 0,
},
output_tokens: 8,
output_tokens_details: {
reasoning_tokens: 0,
},
total_tokens: 84,
},
},
sequence_number: 13,
},
},
{
type: 'done',
data: null,
},
];
export function createSSEStream(events: Array<{ type: string; data: unknown }>) {
const stream = new Readable({
read() {},
});
let eventIndex = 0;
function sendData() {
setTimeout(() => {
if (eventIndex < events.length) {
const event = events[eventIndex];
if (event.type === 'done') {
stream.push('data: [DONE]\n\n');
stream.push(null);
} else {
stream.push(`data: ${JSON.stringify(event.data)}\n\n`);
}
eventIndex++;
sendData();
}
}, 50);
}
sendData();
return stream;
}
export function createMockHttpRequests() {
return {
httpRequest: async (
method: string,
url: string,
body?: object,
headers?: Record<string, string>,
) => {
const response = await axios({
method,
url,
data: body,
headers: {
...headers,
'Content-Type': 'application/json',
Authorization: 'Bearer test-api-key',
},
validateStatus: () => true, // Don't throw on any status
});
return {
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.statusText,
body: response.data,
};
},
openStream: async (
method: string,
url: string,
body?: object,
headers?: Record<string, string>,
) => {
const response = await axios({
method,
url,
data: body,
headers: {
...headers,
'Content-Type': 'application/json',
Authorization: 'Bearer test-api-key',
},
responseType: 'stream',
validateStatus: () => true, // Don't throw on any status
});
return {
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.statusText,
body: response.data,
};
},
};
}
@@ -0,0 +1,280 @@
import { createAgent, HumanMessage } from 'langchain';
import nock from 'nock';
import { LangchainChatModelAdapter } from 'src';
import { OpenAIChatModel } from './openai';
import {
createMockHttpRequests,
createSSEStream,
mockFinalResponse,
mockStreamFinalResponseEvents,
mockStreamToolCallEvents,
mockToolCallResponse,
weatherTool,
} from './openai.fixtures';
describe('OpenAI Integration with Langchain Agent', () => {
const baseURL = 'https://api.openai.com/v1';
beforeEach(() => {
nock.cleanAll();
});
afterEach(() => {
nock.cleanAll();
});
it('should execute agent with tool calling through langchain adapter', async () => {
nock(baseURL)
.post('/responses', (body) => {
expect(body).toMatchObject({
model: 'gpt-4o',
input: 'What is the weather in tokyo?',
tools: [
{
type: 'function',
name: 'get_weather',
description: 'Get weather for a given city.',
parameters: {
type: 'object',
properties: {
city: {
type: 'string',
},
},
required: ['city'],
additionalProperties: false,
},
},
],
parallel_tool_calls: true,
store: false,
stream: false,
});
return true;
})
.reply(200, mockToolCallResponse);
nock(baseURL)
.post('/responses', (body) => {
expect(body).toMatchObject({
model: 'gpt-4o',
input: expect.arrayContaining([
{ role: 'user', content: 'What is the weather in tokyo?' },
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: '' }],
},
{
type: 'function_call',
call_id: 'call_YONsRdkCKu8Sh8WGkUiXqlYW',
name: 'get_weather',
arguments: '{"city":"Tokyo"}',
},
{
type: 'function_call_output',
call_id: 'call_YONsRdkCKu8Sh8WGkUiXqlYW',
output: "It's always sunny in Tokyo!",
},
]),
parallel_tool_calls: true,
store: false,
stream: false,
});
return true;
})
.reply(200, mockFinalResponse);
const openaiChatModel = new OpenAIChatModel('gpt-4o', createMockHttpRequests(), { baseURL });
const chatModel = new LangchainChatModelAdapter(openaiChatModel);
const agent = createAgent({
model: chatModel,
tools: [weatherTool],
});
const result = await agent.invoke({
messages: [new HumanMessage('What is the weather in tokyo?')],
});
expect(result).toBeDefined();
expect(result.messages).toHaveLength(4);
expect(result.messages[0]).toMatchObject({
content: 'What is the weather in tokyo?',
});
expect(result.messages[1]).toMatchObject({
id: 'resp_02a127c1e73b5fe4016989e989cb188195a27d4911084e4223',
tool_calls: [
{
type: 'tool_call',
id: 'call_YONsRdkCKu8Sh8WGkUiXqlYW',
name: 'get_weather',
args: {
city: 'Tokyo',
},
},
],
});
expect(result.messages[2]).toMatchObject({
content: "It's always sunny in Tokyo!",
name: 'get_weather',
tool_call_id: 'call_YONsRdkCKu8Sh8WGkUiXqlYW',
});
expect(result.messages[3]).toMatchObject({
id: 'resp_00a8729c01103919016989e98b13888190bca486b9676ce0cd',
content: [
{
type: 'text',
text: "It's always sunny in Tokyo!",
},
],
});
expect(nock.isDone()).toBe(true);
});
it('should execute agent with streaming through langchain adapter', async () => {
nock(baseURL)
.post('/responses', (body) => {
expect(body).toMatchObject({
model: 'gpt-4o',
input: 'What is the weather in tokyo?',
stream: true,
});
return true;
})
.reply(() => {
const stream = createSSEStream(mockStreamToolCallEvents);
return [
200,
stream,
{
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
];
});
nock(baseURL)
.post('/responses', (body) => {
expect(body).toMatchObject({
model: 'gpt-4o',
stream: true,
});
return true;
})
.reply(() => {
const stream = createSSEStream(mockStreamFinalResponseEvents);
return [
200,
stream,
{
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
];
});
const openaiChatModel = new OpenAIChatModel('gpt-4o', createMockHttpRequests(), { baseURL });
const chatModel = new LangchainChatModelAdapter(openaiChatModel);
const agent = createAgent({
model: chatModel,
tools: [weatherTool],
});
const chunks: unknown[] = [];
const stream = await agent.stream(
{ messages: [{ role: 'user', content: 'What is the weather in tokyo?' }] },
{ streamMode: 'messages' },
);
for await (const chunk of stream) {
chunks.push(chunk);
}
expect(chunks).toHaveLength(10);
const getChunkData = (chunk: unknown) => {
const chunkArray = chunk as unknown[];
return {
message: chunkArray[0] as Record<string, unknown>,
metadata: chunkArray[1] as Record<string, unknown>,
};
};
const { message: message1, metadata: metadata1 } = getChunkData(chunks[0]);
const toolCalls1 = message1.tool_calls as Array<Record<string, unknown>>;
expect(toolCalls1).toHaveLength(1);
expect(toolCalls1[0]).toMatchObject({
name: 'get_weather',
args: {
city: 'Tokyo',
},
id: 'call_StreamTest123',
type: 'tool_call',
});
expect(metadata1.langgraph_step).toBeDefined();
const { message: message2 } = getChunkData(chunks[1]);
expect(message2.usage_metadata).toEqual({
input_tokens: 46,
output_tokens: 15,
total_tokens: 61,
});
const responseMetadata2 = message2.response_metadata as Record<string, unknown>;
expect(responseMetadata2.finish_reason).toBe('stop');
const { message: message3 } = getChunkData(chunks[2]);
expect(message3.content).toBe("It's always sunny in Tokyo!");
expect(message3.tool_call_id).toBe('call_StreamTest123');
expect(message3.name).toBe('get_weather');
const { message: message4 } = getChunkData(chunks[3]);
const content4 = message4.content as Array<{ type: string; text: string }>;
expect(content4[0].text).toBe("It's");
const { message: message5 } = getChunkData(chunks[4]);
const content5 = message5.content as Array<{ type: string; text: string }>;
expect(content5[0].text).toBe(' always');
const { message: message6 } = getChunkData(chunks[5]);
const content6 = message6.content as Array<{ type: string; text: string }>;
expect(content6[0].text).toBe(' sunny');
const { message: message7 } = getChunkData(chunks[6]);
const content7 = message7.content as Array<{ type: string; text: string }>;
expect(content7[0].text).toBe(' in');
const { message: message8 } = getChunkData(chunks[7]);
const content8 = message8.content as Array<{ type: string; text: string }>;
expect(content8[0].text).toBe(' Tokyo');
const { message: message9 } = getChunkData(chunks[8]);
const content9 = message9.content as Array<{ type: string; text: string }>;
expect(content9[0].text).toBe('!');
const { message: message10 } = getChunkData(chunks[9]);
expect(message10.usage_metadata).toEqual({
input_tokens: 76,
output_tokens: 8,
total_tokens: 84,
});
const responseMetadata10 = message10.response_metadata as Record<string, unknown>;
expect(responseMetadata10.finish_reason).toBe('stop');
for (const chunk of chunks) {
const { metadata } = getChunkData(chunk);
expect(metadata).toBeDefined();
expect(metadata.langgraph_step).toBeDefined();
}
expect(nock.isDone()).toBe(true);
});
});
@@ -0,0 +1,536 @@
import type { JSONSchema7 } from 'json-schema';
import type { IHttpRequestMethods } from 'n8n-workflow';
import {
BaseChatModel,
getParametersJsonSchema,
parseSSEStream,
type TokenUsage,
type Tool,
type ToolCall,
type ChatModelConfig,
type GenerateResult,
type Message,
type MessageContent,
type ProviderTool,
type StreamChunk,
} from 'src';
// Types
type OpenAITool =
| {
type: 'function';
name: string;
description?: string;
parameters: JSONSchema7;
strict?: boolean;
}
| {
type: 'web_search';
};
type OpenAIToolChoice = 'auto' | 'required' | 'none' | { type: 'function'; name: string };
type ResponsesInputItem =
| { role: 'user'; content: string }
| { role: 'user'; content: Array<{ type: 'input_text'; text: string }> }
| {
type: 'message';
role: 'assistant';
content: Array<{ type: 'output_text'; text: string }>;
}
| {
type: 'function_call';
call_id: string;
name: string;
arguments: string;
}
| { type: 'function_call_output'; call_id: string; output: string };
interface OpenAIResponsesRequest {
model: string;
input: string | ResponsesInputItem[];
instructions?: string;
max_output_tokens?: number;
temperature?: number;
top_p?: number;
tools?: OpenAITool[];
tool_choice?: OpenAIToolChoice;
parallel_tool_calls?: boolean;
store?: boolean;
stream?: boolean;
metadata?: Record<string, unknown>;
}
interface OpenAIResponsesResponse {
id: string;
object: string;
created_at: string;
model: string;
output: ResponsesOutputItem[];
status: string;
usage?: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
input_tokens_details?: {
cached_tokens?: number;
};
output_tokens_details?: {
reasoning_tokens?: number;
};
};
incomplete_details?: Record<string, unknown>;
metadata?: Record<string, unknown>;
user?: string;
service_tier?: string;
}
type ResponsesOutputItem =
| {
type: 'message';
role: 'assistant';
id?: string;
content: Array<{
type: 'output_text';
text: string;
}>;
}
| {
type: 'function_call';
id?: string;
call_id: string;
name: string;
arguments: string;
}
| {
type: 'reasoning';
id?: string;
summary: Array<{
type: string;
text: string;
}>;
};
interface OpenAIStreamEvent {
type: string;
delta?: string;
output_index?: number;
item?: Record<string, unknown>;
response?: Record<string, unknown>;
}
// Helpers
async function* parseOpenAIStreamEvents(
body: AsyncIterableIterator<Buffer | Uint8Array>,
): AsyncIterable<OpenAIStreamEvent> {
for await (const message of parseSSEStream(body)) {
if (!message.data) continue;
if (message.data === '[DONE]') continue;
try {
const event = JSON.parse(message.data);
yield event as OpenAIStreamEvent;
} catch (e) {
if (process.env.NODE_ENV !== 'production') {
console.warn('Failed to parse OpenAI SSE event:', message.data);
}
}
}
}
function genericMessagesToResponsesInput(messages: Message[]): {
instructions?: string;
input: string | ResponsesInputItem[];
} {
const instructionsParts: string[] = [];
const inputItems: ResponsesInputItem[] = [];
for (const msg of messages) {
if (msg.role === 'system') {
for (const contentPart of msg.content) {
if (contentPart.type === 'text') {
instructionsParts.push(contentPart.text);
}
}
}
if (msg.role === 'user') {
for (const contentPart of msg.content) {
if (contentPart.type === 'text') {
inputItems.push({
role: 'user',
content: contentPart.text,
});
}
}
continue;
}
if (msg.role === 'assistant') {
for (const contentPart of msg.content) {
if (contentPart.type === 'text') {
inputItems.push({
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: contentPart.text,
},
],
});
} else if (contentPart.type === 'tool-call') {
if (!contentPart.toolCallId) {
throw new Error('Tool call ID is required');
}
inputItems.push({
type: 'function_call',
call_id: contentPart.toolCallId,
name: contentPart.toolName,
arguments: contentPart.input,
});
} else if (contentPart.type === 'reasoning') {
inputItems.push({
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: contentPart.text,
},
],
});
}
}
}
if (msg.role === 'tool') {
for (const contentPart of msg.content) {
if (contentPart.type === 'tool-result') {
const output =
typeof contentPart.result === 'string'
? contentPart.result
: JSON.stringify(contentPart.result);
inputItems.push({
type: 'function_call_output',
call_id: contentPart.toolCallId,
output,
});
}
}
}
}
const instructions = instructionsParts.length > 0 ? instructionsParts.join('\n\n') : undefined;
const single = inputItems[0];
if (
inputItems.length === 1 &&
single &&
'role' in single &&
single.role === 'user' &&
typeof single.content === 'string'
) {
return { instructions, input: single.content };
}
return { instructions, input: inputItems };
}
function genericToolToResponsesTool(tool: Tool): OpenAITool {
if (tool.type === 'provider') {
if (tool.name === 'web_search') {
return {
type: 'web_search',
...tool.args,
};
}
throw new Error(`Unsupported provider tool: ${tool.name}`);
}
const parameters = getParametersJsonSchema(tool);
return {
type: 'function',
name: tool.name,
description: tool.description,
parameters,
strict: tool.strict,
};
}
function parseResponsesOutput(output: ResponsesOutputItem[]): {
text: string;
toolCalls: ToolCall[];
} {
let text = '';
const toolCalls: ToolCall[] = [];
for (const item of output) {
if (item.type === 'message' && item.role === 'assistant') {
for (const block of item.content) {
if (block.type === 'output_text') {
text += block.text;
}
}
}
if (item.type === 'function_call') {
try {
toolCalls.push({
id: item.call_id,
name: item.name,
arguments: JSON.parse(item.arguments) as Record<string, unknown>,
argumentsRaw: item.arguments,
});
} catch (e) {
throw new Error(`Failed to parse function call arguments: ${item.arguments}`);
}
}
}
return { text, toolCalls };
}
function parseTokenUsage(
usage: OpenAIResponsesResponse['usage'] | undefined,
): TokenUsage | undefined {
return usage
? {
promptTokens: usage.input_tokens ?? 0,
completionTokens: usage.output_tokens ?? 0,
totalTokens: usage.total_tokens ?? 0,
inputTokenDetails: {
...(!!usage.input_tokens_details?.cached_tokens && {
cacheRead: usage.input_tokens_details.cached_tokens,
}),
},
outputTokenDetails: {
...(!!usage.output_tokens_details?.reasoning_tokens && {
reasoning: usage.output_tokens_details.reasoning_tokens,
}),
},
}
: undefined;
}
interface OpenAIChatModelConfig extends ChatModelConfig {
apiKey?: string;
baseURL?: string;
providerTools?: ProviderTool[];
}
interface RequestConfig {
httpRequest: (
method: IHttpRequestMethods,
url: string,
body?: object,
headers?: Record<string, string>,
) => Promise<{ body: unknown }>;
openStream: (
method: IHttpRequestMethods,
url: string,
body?: object,
headers?: Record<string, string>,
) => Promise<{ body: AsyncIterableIterator<Buffer | Uint8Array> }>;
}
export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
private baseURL: string;
constructor(
modelId: string = 'gpt-4o',
private requests: RequestConfig,
config?: OpenAIChatModelConfig,
) {
super('openai', modelId, config);
this.baseURL = config?.baseURL ?? 'https://api.openai.com/v1';
}
private getTools(config?: OpenAIChatModelConfig) {
const ownTools = this.tools;
const providerTools = config?.providerTools ?? this.defaultConfig?.providerTools ?? [];
return [...ownTools, ...providerTools].map(genericToolToResponsesTool);
}
async generate(messages: Message[], config?: OpenAIChatModelConfig): Promise<GenerateResult> {
const merged = this.mergeConfig(config);
const { instructions, input } = genericMessagesToResponsesInput(messages);
const tools = this.getTools(config);
const requestBody: OpenAIResponsesRequest = {
model: this.modelId,
input,
instructions,
max_output_tokens: merged.maxTokens,
temperature: merged.temperature,
top_p: merged.topP,
tools,
parallel_tool_calls: true,
store: false,
stream: false,
};
const response = await this.requests.httpRequest(
'POST',
`${this.baseURL}/responses`,
requestBody,
);
const body = response.body as OpenAIResponsesResponse;
const { text, toolCalls } = parseResponsesOutput(body.output);
const usage = parseTokenUsage(body.usage);
const responseMetadata: Record<string, unknown> = {
model_provider: 'openai',
model: body.model,
created_at: body.created_at,
id: body.id,
incomplete_details: body.incomplete_details,
metadata: body.metadata,
object: body.object,
status: body.status,
user: body.user,
service_tier: body.service_tier,
model_name: body.model,
output: body.output,
};
for (const item of body.output as unknown[]) {
const o = item as Record<string, unknown>;
if (o.type === 'reasoning') {
responseMetadata.reasoning = o;
}
}
const content: MessageContent[] = [];
if (toolCalls.length) {
for (const toolCall of toolCalls) {
content.push({
type: 'tool-call',
toolCallId: toolCall.id,
toolName: toolCall.name,
input: JSON.stringify(toolCall.arguments),
});
}
}
content.push({ type: 'text', text });
const message: Message = {
role: 'assistant',
content,
id: body.id,
};
return {
id: body.id,
finishReason: body.status === 'completed' ? 'stop' : 'other',
usage,
message,
rawResponse: body,
providerMetadata: responseMetadata,
};
}
async *stream(messages: Message[], config?: OpenAIChatModelConfig): AsyncIterable<StreamChunk> {
const merged = this.mergeConfig(config) as OpenAIChatModelConfig;
const { instructions, input } = genericMessagesToResponsesInput(messages);
const tools = this.getTools(config);
const requestBody: OpenAIResponsesRequest = {
model: this.modelId,
input,
instructions,
max_output_tokens: merged.maxTokens,
temperature: merged.temperature,
top_p: merged.topP,
tools,
parallel_tool_calls: true,
store: false,
stream: true,
};
const streamResponse = await this.requests.openStream(
'POST',
`${this.baseURL}/responses`,
requestBody,
);
const streamBody = streamResponse.body;
const toolCallBuffers: Record<number, { name: string; arguments: string }> = {};
for await (const event of parseOpenAIStreamEvents(streamBody)) {
const type = event.type;
if (type === 'response.output_text.delta') {
const delta = event.delta;
if (delta) {
yield { type: 'text-delta', delta };
}
}
if (type === 'response.output_item.added') {
const item = event.item;
if (item?.type === 'function_call') {
const idx = event.output_index ?? 0;
toolCallBuffers[idx] = {
name: (item.name as string) ?? '',
arguments: (item.arguments as string) ?? '',
};
}
if (item?.type === 'reasoning') {
const summary = (item.summary as Array<Record<string, unknown>>) ?? [];
const reasoningText = summary
.map((s) => s.text)
.filter(Boolean)
.join('');
if (reasoningText) {
yield { type: 'reasoning-delta', delta: reasoningText };
}
}
}
if (type === 'response.reasoning_summary_text.delta') {
const delta = event.delta;
if (delta) {
yield { type: 'reasoning-delta', delta };
}
}
if (type === 'response.function_call_arguments.delta') {
const idx = event.output_index ?? 0;
const delta = event.delta;
if (toolCallBuffers[idx] && delta) {
toolCallBuffers[idx].arguments += delta;
}
}
if (type === 'response.output_item.done') {
const item = event.item;
if (item?.type === 'function_call') {
const idx = event.output_index ?? 0;
const buf = toolCallBuffers[idx];
if (buf) {
yield {
type: 'tool-call-delta',
id: (item.call_id as string) ?? (item.id as string),
name: buf.name,
argumentsDelta: buf.arguments,
};
}
}
}
if (type === 'response.done' || type === 'response.completed') {
const responseData =
(event.response as unknown as OpenAIResponsesResponse) ??
(event as unknown as OpenAIResponsesResponse);
yield {
type: 'finish',
finishReason: 'stop',
usage: parseTokenUsage(responseData.usage),
};
}
}
}
}
@@ -0,0 +1,6 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../../jest.config'),
collectCoverageFrom: ['src/**/*.ts', 'integration-tests/**/*.ts'],
setupFilesAfterEnv: ['jest-expect-message'],
};
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@n8n/ai-utilities",
"version": "0.5.0",
"description": "Utilities for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",
"main": "dist/cjs/index.js",
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./*": "./*"
},
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm run watch",
"typecheck": "tsc --noEmit",
"copy-tokenizer-json": "node scripts/copy-tokenizer-json.js .",
"build": "tsc --build tsconfig.build.esm.json tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.cjs.json && pnpm copy-tokenizer-json dist/cjs && pnpm copy-tokenizer-json dist/esm",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc --build tsconfig.build.esm.json tsconfig.build.cjs.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"files": [
"dist"
],
"devDependencies": {
"@types/json-schema": "^7.0.15",
"jest-mock-extended": "^3.0.4",
"@types/mime-types": "catalog:",
"tsx": "catalog:",
"axios": "catalog:",
"n8n-workflow": "workspace:*"
},
"dependencies": {
"zod": "catalog:",
"zod-to-json-schema": "catalog:",
"@langchain/core": "catalog:",
"@langchain/classic": "1.0.5",
"@langchain/community": "catalog:",
"@langchain/textsplitters": "1.0.1",
"@langchain/openai": "catalog:",
"langchain": "catalog:",
"@n8n/config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"tmp-promise": "3.0.3",
"js-tiktoken": "catalog:",
"https-proxy-agent": "catalog:",
"proxy-from-env": "^1.1.0",
"undici": "^6.21.0"
},
"peerDependencies": {
"n8n-workflow": "*"
}
}
@@ -0,0 +1,22 @@
const glob = require('fast-glob');
const fs = require('fs');
const path = require('path');
function copyTokenizerJsonFiles(baseDir) {
const targetBaseDir = process.argv[3] || 'dist';
// Make sure the target directory exists
const targetDir = path.resolve(baseDir, targetBaseDir, 'utils', 'tokenizer');
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// Copy all tokenizer JSON files
const files = glob.sync('src/utils/tokenizer/*.json', { cwd: baseDir });
for (const file of files) {
const sourcePath = path.resolve(baseDir, file);
const targetPath = path.resolve(baseDir, targetBaseDir, file.replace('src/', ''));
fs.copyFileSync(sourcePath, targetPath);
console.log(`Copied: ${file} -> ${targetPath.replace(baseDir, '')}`);
}
}
copyTokenizerJsonFiles(process.argv[2] || '.');
@@ -0,0 +1,310 @@
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
import { HumanMessage } from '@langchain/core/messages';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import type { GenerateResult, StreamChunk } from 'src/types/output';
import { LangchainChatModelAdapter } from '../../adapters/langchain-chat-model';
jest.mock('src/converters/tool', () => ({
fromLcTool: jest.fn().mockImplementation((t: { name?: string }) => ({
type: 'function' as const,
name: t?.name ?? 'tool',
description: '',
inputSchema: { type: 'object' as const },
})),
}));
jest.mock('src/utils/n8n-llm-tracing', () => ({
N8nLlmTracing: jest.fn().mockImplementation(function (this: unknown) {
return this;
}),
}));
jest.mock('src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler', () => ({
makeN8nLlmFailedAttemptHandler: jest.fn().mockReturnValue(jest.fn()),
}));
const { fromLcTool } = jest.requireMock('src/converters/tool');
const { N8nLlmTracing } = jest.requireMock('src/utils/n8n-llm-tracing');
const { makeN8nLlmFailedAttemptHandler } = jest.requireMock(
'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler',
);
function createMockChatModel(
overrides: {
generate?: jest.Mock;
stream?: jest.Mock;
withTools?: jest.Mock;
} = {},
) {
const generate = jest.fn();
const stream = jest.fn();
const withTools = jest.fn().mockImplementation(function (
this: ReturnType<typeof createMockChatModel>,
) {
return this;
});
return {
provider: 'test-provider',
modelId: 'test-model',
generate: overrides.generate ?? generate,
stream: overrides.stream ?? stream,
withTools: overrides.withTools ?? withTools,
};
}
describe('LangchainAdapter', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('passes callbacks and onFailedAttempt when ctx is provided', () => {
const ctx = {
getNode: jest.fn(),
addOutputData: jest.fn(),
} as unknown as ISupplyDataFunctions;
const chatModel = createMockChatModel();
new LangchainChatModelAdapter(chatModel, ctx);
expect(N8nLlmTracing).toHaveBeenCalledWith(ctx, expect.any(Object));
expect(makeN8nLlmFailedAttemptHandler).toHaveBeenCalledWith(ctx);
});
it('does not pass callbacks or onFailedAttempt when ctx is omitted', () => {
const chatModel = createMockChatModel();
new LangchainChatModelAdapter(chatModel);
expect(N8nLlmTracing).not.toHaveBeenCalled();
expect(makeN8nLlmFailedAttemptHandler).not.toHaveBeenCalled();
});
});
describe('_llmType', () => {
it('returns "n8n-chat-model"', () => {
const adapter = new LangchainChatModelAdapter(createMockChatModel());
expect(adapter._llmType()).toBe('n8n-chat-model');
});
});
describe('_generate', () => {
it('transforms messages and calls chatModel.generate', async () => {
const chatModel = createMockChatModel();
const response: GenerateResult = {
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Hi there' }],
},
};
chatModel.generate.mockResolvedValue(response);
const adapter = new LangchainChatModelAdapter(chatModel);
const messages = [new HumanMessage('hello')];
const options = { temperature: 0.5 };
const result = await adapter._generate(messages, options);
expect(chatModel.generate).toHaveBeenCalledWith(
[{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
options,
);
expect(result.generations).toHaveLength(1);
expect(result.generations[0].text).toBe('Hi there');
expect(result.generations[0].message.content).toEqual([{ type: 'text', text: 'Hi there' }]);
});
it('builds usage_metadata from result.usage', async () => {
const chatModel = createMockChatModel();
const response: GenerateResult = {
message: {
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
},
usage: {
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
inputTokenDetails: { cacheRead: 5 },
outputTokenDetails: { reasoning: 15 },
},
};
chatModel.generate.mockResolvedValue(response);
const adapter = new LangchainChatModelAdapter(chatModel);
const result = await adapter._generate([new HumanMessage('x')], {});
const msg = result.generations[0].message as any;
expect(msg.usage_metadata).toEqual({
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
input_token_details: { cache_read: 5 },
output_token_details: { reasoning: 15 },
});
expect(result.llmOutput?.tokenUsage).toEqual(msg.usage_metadata);
});
it('maps result.toolCalls to message tool_calls and sets provider metadata', async () => {
const chatModel = createMockChatModel();
const response: GenerateResult = {
message: {
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'get_weather',
input: JSON.stringify({ location: 'Berlin' }),
},
{ type: 'text', text: 'Hello, world!' },
],
},
id: 'gen-1',
providerMetadata: { finish_reason: 'tool_calls' },
};
chatModel.generate.mockResolvedValue(response);
const adapter = new LangchainChatModelAdapter(chatModel);
const result = await adapter._generate([new HumanMessage('hi')], {});
const msg = result.generations[0].message as any;
expect(msg.tool_calls).toEqual([
{ type: 'tool_call', id: 'tc-1', name: 'get_weather', args: { location: 'Berlin' } },
]);
expect(msg.response_metadata).toEqual(
expect.objectContaining({
model: 'test-model',
provider: 'test-provider',
finish_reason: 'tool_calls',
}),
);
expect(result.llmOutput?.id).toBe('gen-1');
});
});
describe('_streamResponseChunks', () => {
it('yields ChatGenerationChunk for text-delta chunks and calls runManager.handleLLMNewToken', async () => {
async function* stream() {
const response: StreamChunk = {
type: 'text-delta',
delta: 'Hello ',
};
const response2: StreamChunk = {
type: 'text-delta',
delta: 'world',
};
yield response;
yield response2;
}
const chatModel = createMockChatModel({
stream: jest.fn().mockImplementation(() => stream()),
});
const adapter = new LangchainChatModelAdapter(chatModel);
const handleLLMNewToken = jest.fn();
const chunks: any[] = [];
for await (const chunk of adapter._streamResponseChunks([new HumanMessage('hi')], {}, {
handleLLMNewToken,
} as unknown as CallbackManagerForLLMRun)) {
chunks.push(chunk);
}
expect(chunks).toHaveLength(2);
expect(chunks[0].text).toBe('Hello ');
expect(chunks[1].text).toBe('world');
expect(handleLLMNewToken).toHaveBeenCalledWith(
'Hello ',
expect.any(Object),
undefined,
undefined,
undefined,
expect.any(Object),
);
expect(handleLLMNewToken).toHaveBeenCalledWith(
'world',
expect.any(Object),
undefined,
undefined,
undefined,
expect.any(Object),
);
});
it('yields ChatGenerationChunk for tool-call-delta chunks', async () => {
async function* stream() {
const response: StreamChunk = {
type: 'tool-call-delta',
id: 'tc-1',
name: 'search',
argumentsDelta: '{"q":"x"}',
};
yield response;
}
const chatModel = createMockChatModel({
stream: jest.fn().mockImplementation(() => stream()),
});
const adapter = new LangchainChatModelAdapter(chatModel);
const chunks: any[] = [];
for await (const chunk of adapter._streamResponseChunks([new HumanMessage('hi')], {})) {
chunks.push(chunk);
}
expect(chunks).toHaveLength(1);
expect(chunks[0].message.tool_call_chunks).toEqual([
{ type: 'tool_call_chunk', id: 'tc-1', name: 'search', args: '{"q":"x"}', index: 0 },
]);
});
it('yields ChatGenerationChunk for finish chunks with usage_metadata', async () => {
async function* stream() {
yield {
type: 'finish' as const,
finishReason: 'stop' as const,
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
};
}
const chatModel = createMockChatModel({
stream: jest.fn().mockImplementation(() => stream()),
});
const adapter = new LangchainChatModelAdapter(chatModel);
const chunks: any[] = [];
for await (const chunk of adapter._streamResponseChunks([new HumanMessage('hi')], {})) {
chunks.push(chunk);
}
expect(chunks).toHaveLength(1);
expect(chunks[0].message.usage_metadata).toEqual({
input_tokens: 1,
output_tokens: 2,
total_tokens: 3,
});
expect(chunks[0].generationInfo?.finish_reason).toBe('stop');
});
});
describe('bindTools', () => {
it('converts tools via fromLcTool, calls chatModel.withTools, and returns new LangchainAdapter', () => {
const chatModel = createMockChatModel();
const adapter = new LangchainChatModelAdapter(chatModel, undefined);
const lcTools = [{ name: 'my_tool', schema: {}, invoke: jest.fn() }];
const bound = adapter.bindTools(lcTools);
expect(fromLcTool).toHaveBeenCalledWith(lcTools[0], 0, lcTools);
expect(chatModel.withTools).toHaveBeenCalledWith([
{ type: 'function', name: 'my_tool', description: '', inputSchema: { type: 'object' } },
]);
expect(bound).toBeInstanceOf(LangchainChatModelAdapter);
expect(bound).not.toBe(adapter);
});
});
});
@@ -0,0 +1,164 @@
import { AIMessage, HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
import { LangchainHistoryAdapter } from '../../adapters/langchain-history';
import type { ChatHistory } from '../../types/memory';
import type { Message } from '../../types/message';
describe('LangchainHistoryAdapter', () => {
const createMessage = (role: 'user' | 'assistant' | 'system', text: string): Message => ({
role,
content: [{ type: 'text', text }],
});
const createMockHistory = (messages: Message[] = []): ChatHistory => ({
getMessages: jest.fn().mockResolvedValue([...messages]),
addMessage: jest.fn().mockResolvedValue(undefined),
addMessages: jest.fn().mockResolvedValue(undefined),
clear: jest.fn().mockResolvedValue(undefined),
});
describe('getMessages', () => {
it('should return empty array when no messages', async () => {
const history = createMockHistory([]);
const adapter = new LangchainHistoryAdapter(history);
const result = await adapter.getMessages();
expect(result).toEqual([]);
});
it('should convert human messages to HumanMessage', async () => {
const history = createMockHistory([createMessage('user', 'Hello')]);
const adapter = new LangchainHistoryAdapter(history);
const result = await adapter.getMessages();
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(HumanMessage);
expect(result[0].content).toEqual([{ type: 'text', text: 'Hello' }]);
});
it('should convert AI messages to AIMessage', async () => {
const history = createMockHistory([createMessage('assistant', 'Hi there!')]);
const adapter = new LangchainHistoryAdapter(history);
const result = await adapter.getMessages();
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(AIMessage);
expect(result[0].content).toEqual([{ type: 'text', text: 'Hi there!' }]);
});
it('should convert system messages to SystemMessage', async () => {
const history = createMockHistory([createMessage('system', 'You are a helpful assistant')]);
const adapter = new LangchainHistoryAdapter(history);
const result = await adapter.getMessages();
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(SystemMessage);
expect(result[0].content).toEqual([{ type: 'text', text: 'You are a helpful assistant' }]);
});
it('should convert multiple messages preserving order', async () => {
const messages = [
createMessage('system', 'System prompt'),
createMessage('user', 'Hello'),
createMessage('assistant', 'Hi!'),
];
const history = createMockHistory(messages);
const adapter = new LangchainHistoryAdapter(history);
const result = await adapter.getMessages();
expect(result).toHaveLength(3);
expect(result[0]).toBeInstanceOf(SystemMessage);
expect(result[1]).toBeInstanceOf(HumanMessage);
expect(result[2]).toBeInstanceOf(AIMessage);
});
});
describe('addMessage', () => {
it('should convert LangChain HumanMessage to n8n format and delegate', async () => {
const history = createMockHistory();
const adapter = new LangchainHistoryAdapter(history);
await adapter.addMessage(new HumanMessage('Hello'));
expect(history.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'text', text: 'Hello' }),
]),
}),
);
});
it('should convert LangChain AIMessage to n8n format and delegate', async () => {
const history = createMockHistory();
const adapter = new LangchainHistoryAdapter(history);
await adapter.addMessage(new AIMessage('Hi there!'));
expect(history.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'assistant',
content: expect.arrayContaining([
expect.objectContaining({ type: 'text', text: 'Hi there!' }),
]),
}),
);
});
it('should convert LangChain ToolMessage to n8n format and delegate', async () => {
const history = createMockHistory();
const adapter = new LangchainHistoryAdapter(history);
await adapter.addMessage(new ToolMessage({ content: 'result', tool_call_id: 'call-1' }));
expect(history.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'tool',
content: expect.arrayContaining([
expect.objectContaining({ type: 'tool-result', toolCallId: 'call-1' }),
]),
}),
);
});
});
describe('addMessages', () => {
it('should convert and delegate multiple messages in batch', async () => {
const history = createMockHistory();
const adapter = new LangchainHistoryAdapter(history);
await adapter.addMessages([new HumanMessage('Hello'), new AIMessage('Hi!')]);
expect(history.addMessages).toHaveBeenCalledWith([
expect.objectContaining({ role: 'user' }),
expect.objectContaining({ role: 'assistant' }),
]);
});
it('should call addMessages with empty array', async () => {
const history = createMockHistory();
const adapter = new LangchainHistoryAdapter(history);
await adapter.addMessages([]);
expect(history.addMessages).toHaveBeenCalledWith([]);
});
});
describe('clear', () => {
it('should delegate to history.clear()', async () => {
const history = createMockHistory();
const adapter = new LangchainHistoryAdapter(history);
await adapter.clear();
expect(history.clear).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,122 @@
import { AIMessage, HumanMessage, SystemMessage } from '@langchain/core/messages';
import { LangchainMemoryAdapter } from '../../adapters/langchain-memory';
import type { ChatHistory, ChatMemory } from '../../types/memory';
import type { Message } from '../../types/message';
describe('LangchainMemoryAdapter', () => {
const createMessage = (role: 'user' | 'assistant' | 'system', text: string): Message => ({
role,
content: [{ type: 'text', text }],
});
const createMockMemory = (messages: Message[] = []): ChatMemory => {
const mockHistory: ChatHistory = {
getMessages: jest.fn().mockResolvedValue([...messages]),
addMessage: jest.fn().mockResolvedValue(undefined),
addMessages: jest.fn().mockResolvedValue(undefined),
clear: jest.fn().mockResolvedValue(undefined),
};
return {
loadMessages: jest.fn().mockResolvedValue([...messages]),
saveTurn: jest.fn().mockResolvedValue(undefined),
clear: jest.fn().mockResolvedValue(undefined),
chatHistory: mockHistory,
};
};
describe('loadMemoryVariables', () => {
it('should return empty chat_history when no messages', async () => {
const memory = createMockMemory([]);
const adapter = new LangchainMemoryAdapter(memory);
const result = await adapter.loadMemoryVariables({});
expect(result.chat_history).toEqual([]);
});
it('should convert human messages to HumanMessage', async () => {
const memory = createMockMemory([createMessage('user', 'Hello')]);
const adapter = new LangchainMemoryAdapter(memory);
const result = await adapter.loadMemoryVariables({});
expect(result.chat_history).toHaveLength(1);
expect(result.chat_history[0]).toBeInstanceOf(HumanMessage);
expect(result.chat_history[0].content).toEqual([{ type: 'text', text: 'Hello' }]);
});
it('should convert AI messages to AIMessage', async () => {
const memory = createMockMemory([createMessage('assistant', 'Hi there!')]);
const adapter = new LangchainMemoryAdapter(memory);
const result = await adapter.loadMemoryVariables({});
expect(result.chat_history).toHaveLength(1);
expect(result.chat_history[0]).toBeInstanceOf(AIMessage);
expect(result.chat_history[0].content).toEqual([{ type: 'text', text: 'Hi there!' }]);
});
it('should convert system messages to SystemMessage', async () => {
const memory = createMockMemory([createMessage('system', 'You are a helpful assistant')]);
const adapter = new LangchainMemoryAdapter(memory);
const result = await adapter.loadMemoryVariables({});
expect(result.chat_history).toHaveLength(1);
expect(result.chat_history[0]).toBeInstanceOf(SystemMessage);
expect(result.chat_history[0].content).toEqual([
{ type: 'text', text: 'You are a helpful assistant' },
]);
});
it('should convert multiple messages in order', async () => {
const messages = [
createMessage('system', 'System prompt'),
createMessage('user', 'Hello'),
createMessage('assistant', 'Hi!'),
];
const memory = createMockMemory(messages);
const adapter = new LangchainMemoryAdapter(memory);
const result = await adapter.loadMemoryVariables({});
expect(result.chat_history).toHaveLength(3);
expect(result.chat_history[0]).toBeInstanceOf(SystemMessage);
expect(result.chat_history[1]).toBeInstanceOf(HumanMessage);
expect(result.chat_history[2]).toBeInstanceOf(AIMessage);
});
});
describe('saveContext', () => {
it('should call memory.saveTurn with input and output', async () => {
const memory = createMockMemory();
const adapter = new LangchainMemoryAdapter(memory);
await adapter.saveContext({ input: 'Hello!' }, { output: 'Hi there!' });
expect(memory.saveTurn).toHaveBeenCalledWith('Hello!', 'Hi there!');
});
});
describe('clear', () => {
it('should call memory.clear()', async () => {
const memory = createMockMemory();
const adapter = new LangchainMemoryAdapter(memory);
await adapter.clear();
expect(memory.clear).toHaveBeenCalled();
});
});
describe('memoryKeys', () => {
it('should return chat_history as memory key', () => {
const memory = createMockMemory();
const adapter = new LangchainMemoryAdapter(memory);
expect(adapter.memoryKeys).toEqual(['chat_history']);
});
});
});
@@ -0,0 +1,555 @@
import {
AIMessage,
type BaseMessage,
HumanMessage,
SystemMessage,
ToolMessage,
} from '@langchain/core/messages';
import { fromLcMessage, toLcMessage } from '../../converters/message';
import type { Message } from '../../types/message';
/**
* Round-trip tests: n8n -> LC -> n8n (toLcMessage then fromLcMessage)
*
* Verifies that converting an n8n Message to LangChain and back
* produces the same message.
*/
describe('message round-trip: n8n -> LC -> n8n', () => {
function roundTrip(original: Message): Message {
const lc = toLcMessage(original);
return fromLcMessage(lc);
}
describe('system messages', () => {
it('should round-trip a simple text message', () => {
const original: Message = {
role: 'system',
content: [{ type: 'text', text: 'You are a helpful assistant' }],
};
expect(roundTrip(original)).toEqual(original);
});
it('should preserve id and name', () => {
const original: Message = {
role: 'system',
content: [{ type: 'text', text: 'System prompt' }],
id: 'msg-123',
name: 'system-bot',
};
expect(roundTrip(original)).toEqual(original);
});
});
describe('human messages', () => {
it('should round-trip a simple text message', () => {
const original: Message = {
role: 'user',
content: [{ type: 'text', text: 'Hello!' }],
};
expect(roundTrip(original)).toEqual(original);
});
it('should round-trip multiple text blocks', () => {
const original: Message = {
role: 'user',
content: [
{ type: 'text', text: 'First part' },
{ type: 'text', text: 'Second part' },
],
};
expect(roundTrip(original)).toEqual(original);
});
it('should preserve id and name', () => {
const original: Message = {
role: 'user',
content: [{ type: 'text', text: 'Hello' }],
id: 'msg-456',
name: 'user-1',
};
expect(roundTrip(original)).toEqual(original);
});
it('should round-trip a file content block', () => {
const original: Message = {
role: 'user',
content: [
{
type: 'file',
mediaType: 'image/png',
data: 'iVBORw0KGgo=',
},
],
};
expect(roundTrip(original)).toEqual(original);
});
it('should round-trip a file with url in providerMetadata', () => {
const original: Message = {
role: 'user',
content: [
{
type: 'file',
mediaType: 'image/jpeg',
data: '/9j/4AAQ==',
providerMetadata: {
url: 'https://example.com/image.jpg',
fileId: 'file-123',
},
},
],
};
expect(roundTrip(original)).toEqual(original);
});
});
describe('ai messages', () => {
it('should round-trip a simple text response', () => {
const original: Message = {
role: 'assistant',
content: [{ type: 'text', text: 'Hello! How can I help you?' }],
};
expect(roundTrip(original)).toEqual(original);
});
it('should round-trip a reasoning block', () => {
const original: Message = {
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Let me think about this...' },
{ type: 'text', text: 'The answer is 42.' },
],
};
expect(roundTrip(original)).toEqual(original);
});
it('should round-trip an AI message with tool calls', () => {
const original: Message = {
role: 'assistant',
content: [
{ type: 'text', text: 'Let me look that up.' },
{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'search',
input: '{"query":"weather"}',
},
],
};
const result = roundTrip(original);
// Content order may differ: fromLcMessage appends tool calls after content
expect(result.role).toBe('assistant');
expect(result.content).toHaveLength(2);
const textBlock = result.content.find((c) => c.type === 'text');
expect(textBlock).toEqual({ type: 'text', text: 'Let me look that up.' });
const toolCallBlock = result.content.find((c) => c.type === 'tool-call');
expect(toolCallBlock).toMatchObject({
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'search',
input: '{"query":"weather"}',
});
});
it('should round-trip multiple tool calls', () => {
const original: Message = {
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'search',
input: '{"q":"foo"}',
},
{
type: 'tool-call',
toolCallId: 'call-2',
toolName: 'calculator',
input: '{"expr":"2+2"}',
},
],
};
const result = roundTrip(original);
expect(result.role).toBe('assistant');
const toolCalls = result.content.filter((c) => c.type === 'tool-call');
expect(toolCalls).toHaveLength(2);
expect(toolCalls[0]).toMatchObject({ toolCallId: 'call-1', toolName: 'search' });
expect(toolCalls[1]).toMatchObject({ toolCallId: 'call-2', toolName: 'calculator' });
});
it('should round-trip an invalid tool call', () => {
const original: Message = {
role: 'assistant',
content: [
{ type: 'text', text: 'I tried to call a tool but it failed.' },
{
type: 'invalid-tool-call',
toolCallId: 'call-1',
error: 'Invalid JSON in arguments',
args: '{"malformed": }',
name: 'search',
},
],
};
const result = roundTrip(original);
expect(result.role).toBe('assistant');
expect(result.content).toHaveLength(2);
const textBlock = result.content.find((c) => c.type === 'text');
expect(textBlock).toEqual({ type: 'text', text: 'I tried to call a tool but it failed.' });
const invalidToolCallBlock = result.content.find((c) => c.type === 'invalid-tool-call');
expect(invalidToolCallBlock).toMatchObject({
type: 'invalid-tool-call',
toolCallId: 'call-1',
error: 'Invalid JSON in arguments',
args: '{"malformed": }',
name: 'search',
});
});
it('should round-trip multiple invalid tool calls', () => {
const original: Message = {
role: 'assistant',
content: [
{
type: 'invalid-tool-call',
toolCallId: 'call-1',
error: 'Tool not found',
args: '{}',
name: 'nonexistent_tool',
},
{
type: 'invalid-tool-call',
toolCallId: 'call-2',
error: 'Invalid arguments',
args: '{"foo": "bar"}',
name: 'search',
},
],
};
const result = roundTrip(original);
expect(result.role).toBe('assistant');
const invalidToolCalls = result.content.filter((c) => c.type === 'invalid-tool-call');
expect(invalidToolCalls).toHaveLength(2);
expect(invalidToolCalls[0]).toMatchObject({
toolCallId: 'call-1',
error: 'Tool not found',
name: 'nonexistent_tool',
});
expect(invalidToolCalls[1]).toMatchObject({
toolCallId: 'call-2',
error: 'Invalid arguments',
name: 'search',
});
});
it('should preserve id and name', () => {
const original: Message = {
role: 'assistant',
content: [{ type: 'text', text: 'Response' }],
id: 'msg-789',
name: 'assistant',
};
expect(roundTrip(original)).toEqual(original);
});
it('should round-trip a citation block', () => {
const original: Message = {
role: 'assistant',
content: [
{
type: 'citation',
source: 'web',
url: 'https://example.com',
title: 'Example Page',
startIndex: 0,
endIndex: 10,
text: 'cited text',
},
{ type: 'text', text: 'According to the source...' },
],
};
expect(roundTrip(original)).toEqual(original);
});
it('should round-trip a provider (non-standard) block', () => {
const original: Message = {
role: 'assistant',
content: [
{ type: 'provider', value: { customField: 'customValue', nested: { a: 1 } } },
{ type: 'text', text: 'Normal text' },
],
};
expect(roundTrip(original)).toEqual(original);
});
});
describe('tool messages', () => {
it('should round-trip a tool result with string content', () => {
const original: Message = {
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: 'call-1',
result: 'The weather is sunny',
isError: false,
},
],
};
const result = roundTrip(original);
expect(result.role).toBe('tool');
expect(result.content).toHaveLength(1);
expect(result.content[0]).toMatchObject({
type: 'tool-result',
toolCallId: 'call-1',
result: 'The weather is sunny',
isError: false,
});
});
it('should round-trip a tool error result', () => {
const original: Message = {
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: 'call-1',
result: 'Connection timeout',
isError: true,
},
],
};
const result = roundTrip(original);
expect(result.role).toBe('tool');
expect(result.content[0]).toMatchObject({
type: 'tool-result',
toolCallId: 'call-1',
result: 'Connection timeout',
isError: true,
});
});
it('should preserve name on tool messages', () => {
const original: Message = {
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: 'call-1',
result: 'done',
},
],
name: 'search-tool',
};
const result = roundTrip(original);
expect(result.name).toBe('search-tool');
});
});
});
/**
* Round-trip tests: LC -> n8n -> LC (fromLcMessage then toLcMessage)
*
* Verifies that converting a LangChain message to n8n and back
* produces a structurally equivalent LangChain message.
*/
describe('message round-trip: LC -> n8n -> LC', () => {
function roundTrip(original: BaseMessage): BaseMessage {
const n8n = fromLcMessage(original);
return toLcMessage(n8n);
}
// LangChain messages carry extra internal fields (lc_*, kwargs, etc).
// We compare only the semantically meaningful properties.
//
// Plain string content normalizes to structured content blocks during
// round-trip (fromLcMessage wraps strings in { type: 'text', text }),
// so we normalize before comparing.
function normalizeContent(
content: string | Array<Record<string, unknown>>,
): Array<Record<string, unknown>> {
if (typeof content === 'string') {
return [{ type: 'text', text: content }];
}
return content;
}
function expectLcEqual(actual: BaseMessage, expected: BaseMessage) {
expect(actual.constructor.name).toBe(expected.constructor.name);
expect(normalizeContent(actual.content as string | Array<Record<string, unknown>>)).toEqual(
normalizeContent(expected.content as string | Array<Record<string, unknown>>),
);
expect(actual.name).toBe(expected.name);
if ('tool_call_id' in expected) {
expect((actual as ToolMessage).tool_call_id).toBe((expected as ToolMessage).tool_call_id);
}
if ('tool_calls' in expected && (expected as AIMessage).tool_calls?.length) {
expect((actual as AIMessage).tool_calls).toEqual((expected as AIMessage).tool_calls);
}
if ('status' in expected) {
expect((actual as ToolMessage).status).toBe((expected as ToolMessage).status);
}
}
describe('SystemMessage', () => {
it('should round-trip a plain text SystemMessage', () => {
const original = new SystemMessage({
content: 'You are a helpful assistant',
name: 'sys',
});
expectLcEqual(roundTrip(original), original);
});
it('should round-trip a SystemMessage with structured content', () => {
const original = new SystemMessage({
content: [{ type: 'text', text: 'System instructions' }],
});
expectLcEqual(roundTrip(original), original);
});
});
describe('HumanMessage', () => {
it('should round-trip a plain text HumanMessage', () => {
const original = new HumanMessage({ content: 'Hello!', name: 'user' });
expectLcEqual(roundTrip(original), original);
});
it('should round-trip a HumanMessage with structured content', () => {
const original = new HumanMessage({
content: [
{ type: 'text', text: 'Look at this image' },
{
type: 'file',
mimeType: 'image/png',
data: 'base64data',
},
],
});
expectLcEqual(roundTrip(original), original);
});
});
describe('AIMessage', () => {
it('should round-trip a plain text AIMessage', () => {
const original = new AIMessage({
content: 'Hello! How can I help?',
name: 'assistant',
});
expectLcEqual(roundTrip(original), original);
});
it('should round-trip an AIMessage with tool_calls', () => {
const original = new AIMessage({
content: 'Let me search for that.',
tool_calls: [
{ type: 'tool_call', id: 'call-1', name: 'search', args: { query: 'weather' } },
],
});
const result = roundTrip(original);
expect(result).toBeInstanceOf(AIMessage);
expect((result as AIMessage).tool_calls).toEqual(original.tool_calls);
});
it('should round-trip an AIMessage with invalid_tool_call content', () => {
const original = new AIMessage({
content: [
{ type: 'text', text: 'I tried to call a tool but encountered an error.' },
{
type: 'invalid_tool_call',
id: 'call-1',
error: 'Invalid JSON in arguments',
args: '{"malformed": }',
name: 'search',
},
],
});
const result = roundTrip(original);
expect(result).toBeInstanceOf(AIMessage);
const content = normalizeContent(result.content as string | Array<Record<string, unknown>>);
const invalidToolCall = content.find((c) => c.type === 'invalid_tool_call');
expect(invalidToolCall).toMatchObject({
type: 'invalid_tool_call',
id: 'call-1',
error: 'Invalid JSON in arguments',
args: '{"malformed": }',
name: 'search',
});
});
});
describe('ToolMessage', () => {
it('should round-trip a ToolMessage with string content', () => {
const original = new ToolMessage({
content: 'The weather is sunny',
tool_call_id: 'call-1',
status: 'success',
});
expectLcEqual(roundTrip(original), original);
});
it('should round-trip a ToolMessage with error status', () => {
const original = new ToolMessage({
content: 'Connection timeout',
tool_call_id: 'call-1',
status: 'error',
});
expectLcEqual(roundTrip(original), original);
});
it('should round-trip a ToolMessage with name', () => {
const original = new ToolMessage({
content: 'result',
tool_call_id: 'call-1',
name: 'search',
status: 'success',
});
expectLcEqual(roundTrip(original), original);
});
});
});
@@ -0,0 +1,156 @@
import type { JSONSchema7 } from 'json-schema';
import { z } from 'zod';
import { fromLcTool, getParametersJsonSchema } from '../../converters/tool';
describe('fromLcTool', () => {
it('converts StructuredTool (schema + invoke) to N8n function tool', () => {
const tool = {
name: 'search',
description: 'Search the web',
schema: { type: 'object', properties: { q: { type: 'string' } } },
invoke: jest.fn(),
};
const result = fromLcTool(tool);
expect(result).toEqual({
type: 'function',
name: 'search',
description: 'Search the web',
inputSchema: { type: 'object', properties: { q: { type: 'string' } } },
});
});
it('converts DynamicStructuredTool (schema + func) to N8n function tool', () => {
const tool = {
name: 'calculator',
description: 'Do math',
schema: { type: 'object', properties: { a: { type: 'number' } } },
func: jest.fn(),
};
const result = fromLcTool(tool);
expect(result).toEqual({
type: 'function',
name: 'calculator',
description: 'Do math',
inputSchema: { type: 'object', properties: { a: { type: 'number' } } },
});
});
it('converts tool with name and schema (no invoke/func) to N8n function tool', () => {
const tool = {
name: 'lookup',
description: 'Look up data',
schema: { type: 'object' },
};
const result = fromLcTool(tool);
expect(result).toEqual({
type: 'function',
name: 'lookup',
description: 'Look up data',
inputSchema: { type: 'object' },
});
});
it('converts FunctionDefinition (function + type === "function") to N8n function tool', () => {
const parameters: JSONSchema7 = {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
};
const tool = {
type: 'function' as const,
function: {
name: 'get_weather',
description: 'Get weather for a location',
parameters,
},
};
const result = fromLcTool(tool);
expect(result).toEqual({
type: 'function',
name: 'get_weather',
description: 'Get weather for a location',
inputSchema: parameters,
});
});
it('throws when tool format is unrecognized', () => {
const tool = { unknown: 'shape' };
expect(() => fromLcTool(tool)).toThrow(
'Unable to convert tool to N8nTool: {"unknown":"shape"}',
);
});
it('throws when tool is empty object', () => {
expect(() => fromLcTool({})).toThrow('Unable to convert tool to N8nTool');
});
});
describe('getParametersJsonSchema', () => {
it('returns schema as-is when inputSchema is plain JSONSchema7', () => {
const schema: JSONSchema7 = {
type: 'object',
properties: { id: { type: 'string' } },
};
const tool = {
type: 'function' as const,
name: 'test',
inputSchema: schema,
};
const result = getParametersJsonSchema(tool);
expect(result).toBe(schema);
expect(result).toEqual({
type: 'object',
properties: { id: { type: 'string' } },
});
});
it('returns schema.toJSONSchema() when ZodSchema has toJSONSchema method', () => {
const jsonSchema: JSONSchema7 = { type: 'object', properties: {} };
const zodSchema = z.object({ x: z.string() });
(zodSchema as { toJSONSchema?: () => JSONSchema7 }).toJSONSchema = jest
.fn()
.mockReturnValue(jsonSchema);
const tool = {
type: 'function' as const,
name: 'test',
inputSchema: zodSchema,
};
const result = getParametersJsonSchema(tool);
expect(result).toBe(jsonSchema);
expect((zodSchema as unknown as { toJSONSchema: jest.Mock }).toJSONSchema).toHaveBeenCalled();
});
it('returns zodToJsonSchema(schema) when ZodSchema has no toJSONSchema', () => {
const zodSchema = z.object({ name: z.string() });
const tool = {
type: 'function' as const,
name: 'test',
inputSchema: zodSchema,
};
const result = getParametersJsonSchema(tool);
expect(result).toEqual(
expect.objectContaining({
type: 'object',
properties: expect.objectContaining({
name: expect.objectContaining({ type: 'string' }),
}),
}),
);
});
});
@@ -0,0 +1,123 @@
import { WindowedChatMemory } from '../../memory/windowed-chat-memory';
import type { ChatHistory } from '../../types/memory';
import type { Message } from '../../types/message';
describe('WindowedChatMemory', () => {
const createMessage = (role: 'user' | 'assistant', text: string): Message => ({
role,
content: [{ type: 'text', text }],
});
const createMockHistory = (messages: Message[] = []): ChatHistory => {
const storage = [...messages];
return {
getMessages: jest.fn().mockImplementation(async () => await Promise.resolve([...storage])),
addMessage: jest.fn().mockImplementation(async (msg: Message) => {
storage.push(msg);
return;
}),
addMessages: jest.fn().mockImplementation(async (msgs: Message[]) => {
storage.push(...msgs);
return;
}),
clear: jest.fn().mockImplementation(async () => {
storage.length = 0;
return;
}),
};
};
describe('loadMessages', () => {
it('should return empty array when history is empty', async () => {
const history = createMockHistory();
const memory = new WindowedChatMemory(history, { windowSize: 5 });
const messages = await memory.loadMessages();
expect(messages).toEqual([]);
});
it('should return all messages when under window size', async () => {
const msgs = [createMessage('user', 'Hello'), createMessage('assistant', 'Hi!')];
const history = createMockHistory(msgs);
const memory = new WindowedChatMemory(history, { windowSize: 5 });
const messages = await memory.loadMessages();
expect(messages).toEqual(msgs);
});
it('should return only last N pairs when over window size', async () => {
const msgs = [
createMessage('user', 'Message 1'),
createMessage('assistant', 'Response 1'),
createMessage('user', 'Message 2'),
createMessage('assistant', 'Response 2'),
createMessage('user', 'Message 3'),
createMessage('assistant', 'Response 3'),
];
const history = createMockHistory(msgs);
const memory = new WindowedChatMemory(history, { windowSize: 2 });
const messages = await memory.loadMessages();
// windowSize: 2 means 2 pairs = 4 messages
expect(messages).toHaveLength(4);
expect(messages[0].content[0]).toEqual({ type: 'text', text: 'Message 2' });
expect(messages[1].content[0]).toEqual({ type: 'text', text: 'Response 2' });
expect(messages[2].content[0]).toEqual({ type: 'text', text: 'Message 3' });
expect(messages[3].content[0]).toEqual({ type: 'text', text: 'Response 3' });
});
it('should use default window size of 10', async () => {
const history = createMockHistory();
const memory = new WindowedChatMemory(history);
// Add 25 messages (more than default 10 pairs = 20 messages)
const msgs: Message[] = [];
for (let i = 1; i <= 25; i++) {
msgs.push(createMessage(i % 2 === 1 ? 'user' : 'assistant', `Message ${i}`));
}
await history.addMessages(msgs);
const messages = await memory.loadMessages();
// Default windowSize: 10 means 10 pairs = 20 messages
expect(messages).toHaveLength(20);
});
});
describe('saveTurn', () => {
it('should add human and AI message pair', async () => {
const history = createMockHistory();
const memory = new WindowedChatMemory(history, { windowSize: 5 });
await memory.saveTurn('Hello!', 'Hi there!');
expect(history.addMessages).toHaveBeenCalledWith([
{ role: 'user', content: [{ type: 'text', text: 'Hello!' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'Hi there!' }] },
]);
});
});
describe('clear', () => {
it('should delegate to chatHistory.clear()', async () => {
const history = createMockHistory();
const memory = new WindowedChatMemory(history, { windowSize: 5 });
await memory.clear();
expect(history.clear).toHaveBeenCalled();
});
});
describe('chatHistory accessor', () => {
it('should return the underlying chat history', () => {
const history = createMockHistory();
const memory = new WindowedChatMemory(history, { windowSize: 5 });
expect(memory.chatHistory).toBe(history);
});
});
});
@@ -0,0 +1,248 @@
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { supplyModel } from 'src/suppliers/supplyModel';
const mockLangchainAdapterInstance = { __brand: 'LangchainChatModelAdapter' };
jest.mock('@langchain/openai', () => ({
ChatOpenAI: jest.fn().mockImplementation(function (this: any) {
// Return a new object each time so metadata can be set independently
return { __brand: 'ChatOpenAI', metadata: {} };
}),
}));
jest.mock('src/utils/http-proxy-agent', () => ({
getProxyAgent: jest.fn().mockReturnValue({ __agent: true }),
}));
jest.mock('src/utils/n8n-llm-tracing', () => ({
N8nLlmTracing: jest.fn().mockImplementation(function (this: unknown) {
return this;
}),
}));
jest.mock('src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler', () => ({
makeN8nLlmFailedAttemptHandler: jest.fn().mockReturnValue(jest.fn()),
}));
jest.mock('src/adapters/langchain-chat-model', () => ({
LangchainChatModelAdapter: jest.fn().mockImplementation(() => mockLangchainAdapterInstance),
}));
const { ChatOpenAI } = jest.requireMock('@langchain/openai');
const { LangchainChatModelAdapter } = jest.requireMock('src/adapters/langchain-chat-model');
const { getProxyAgent } = jest.requireMock('src/utils/http-proxy-agent');
const { makeN8nLlmFailedAttemptHandler } = jest.requireMock(
'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler',
);
const { N8nLlmTracing } = jest.requireMock('src/utils/n8n-llm-tracing');
describe('supplyModel', () => {
const mockCtx = {
getNode: jest.fn(),
addOutputData: jest.fn(),
addInputData: jest.fn(),
getNextRunIndex: jest.fn(),
} as unknown as ISupplyDataFunctions;
beforeEach(() => {
jest.clearAllMocks();
});
describe('OpenAI model path', () => {
it('returns response from ChatOpenAI when model has type "openai"', () => {
const openAiModel = {
type: 'openai' as const,
baseUrl: 'https://api.openai.com',
model: 'gpt-4',
apiKey: 'test-key',
};
const result = supplyModel(mockCtx, openAiModel);
expect(result.response).toEqual(
expect.objectContaining({
__brand: 'ChatOpenAI',
metadata: {},
}),
);
expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gpt-4',
apiKey: 'test-key',
configuration: expect.objectContaining({
baseURL: 'https://api.openai.com',
}),
onFailedAttempt: expect.any(Function),
callbacks: [expect.any(Object)],
}),
);
expect(makeN8nLlmFailedAttemptHandler).toHaveBeenCalledWith(mockCtx, undefined);
expect(N8nLlmTracing).toHaveBeenCalledWith(mockCtx);
expect(LangchainChatModelAdapter).not.toHaveBeenCalled();
});
it('passes ctx and OpenAI options to ChatOpenAI when model has defaultHeaders and timeout', () => {
const openAiModel = {
type: 'openai' as const,
baseUrl: 'https://api.example.com',
model: 'gpt-4',
apiKey: 'key',
defaultHeaders: { 'X-Custom': 'value' },
timeout: 60_000,
};
supplyModel(mockCtx, openAiModel);
expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gpt-4',
apiKey: 'key',
configuration: expect.objectContaining({
baseURL: 'https://api.example.com',
defaultHeaders: { 'X-Custom': 'value' },
}),
}),
);
});
it('includes providerTools in metadata when model has providerTools', () => {
const result = supplyModel(mockCtx, {
type: 'openai' as const,
baseUrl: 'https://api.openai.com',
model: 'gpt-4',
apiKey: 'key',
providerTools: [{ type: 'provider', name: 'web_search', args: { size: 'medium' } }],
});
expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gpt-4',
apiKey: 'key',
}),
);
// Verify that the returned model has the correct metadata with providerTools
// The providerTools should be mapped to metadata.tools format
expect((result.response as any).metadata).toEqual({
tools: [
{
type: 'web_search',
size: 'medium',
},
],
});
});
it('maps multiple providerTools correctly in metadata', () => {
const result = supplyModel(mockCtx, {
type: 'openai' as const,
baseUrl: 'https://api.openai.com',
model: 'gpt-4',
apiKey: 'key',
providerTools: [
{ type: 'provider', name: 'web_search', args: { engine: 'google', limit: 10 } },
{ type: 'provider', name: 'code_interpreter', args: { timeout: 30 } },
],
});
// Verify that all providerTools are correctly mapped to metadata.tools
expect((result.response as any).metadata.tools).toHaveLength(2);
expect((result.response as any).metadata.tools[0]).toEqual({
type: 'web_search',
engine: 'google',
limit: 10,
});
expect((result.response as any).metadata.tools[1]).toEqual({
type: 'code_interpreter',
timeout: 30,
});
});
it('does not set metadata.tools when providerTools is empty', () => {
const result = supplyModel(mockCtx, {
type: 'openai' as const,
baseUrl: 'https://api.openai.com',
model: 'gpt-4',
apiKey: 'key',
providerTools: [],
});
// Empty providerTools should not set metadata.tools
expect((result.response as any).metadata).toEqual({});
});
it('sets timeout in OpenAI class and in fetchOptions', () => {
supplyModel(mockCtx, {
type: 'openai' as const,
baseUrl: 'https://api.openai.com',
model: 'gpt-4',
apiKey: 'key',
timeout: 12345,
});
expect(getProxyAgent).toHaveBeenCalledWith('https://api.openai.com', {
headersTimeout: 12345,
bodyTimeout: 12345,
});
expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 12345,
}),
);
});
});
describe('ChatModel (LangchainChatModelAdapter) path', () => {
it('returns response from LangchainChatModelAdapter when model does not have type "openai"', () => {
const chatModel = {
provider: 'anthropic',
modelId: 'claude-3',
generate: jest.fn(),
stream: jest.fn(),
withTools: jest.fn().mockReturnThis(),
};
const result = supplyModel(mockCtx, chatModel);
expect(result).toEqual({ response: mockLangchainAdapterInstance });
expect(LangchainChatModelAdapter).toHaveBeenCalledTimes(1);
expect(LangchainChatModelAdapter).toHaveBeenCalledWith(chatModel, mockCtx);
expect(ChatOpenAI).not.toHaveBeenCalled();
});
it('uses LangchainChatModelAdapter when model has type other than "openai"', () => {
const modelWithOtherType = {
type: 'custom',
provider: 'custom',
modelId: 'custom-model',
generate: jest.fn(),
stream: jest.fn(),
withTools: jest.fn().mockReturnThis(),
};
const result = supplyModel(mockCtx, modelWithOtherType);
expect(result).toEqual({ response: mockLangchainAdapterInstance });
expect(LangchainChatModelAdapter).toHaveBeenCalledWith(modelWithOtherType, mockCtx);
expect(ChatOpenAI).not.toHaveBeenCalled();
});
it('uses LangchainChatModelAdapter when model has no type property', () => {
const modelWithoutType = {
provider: 'google',
modelId: 'gemini-pro',
generate: jest.fn(),
stream: jest.fn(),
withTools: jest.fn().mockReturnThis(),
};
const result = supplyModel(mockCtx, modelWithoutType);
expect(result).toEqual({ response: mockLangchainAdapterInstance });
expect(LangchainChatModelAdapter).toHaveBeenCalledWith(modelWithoutType, mockCtx);
expect(ChatOpenAI).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,81 @@
import type { INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import {
validateEmbedQueryInput,
validateEmbedDocumentsInput,
} from 'src/utils/embeddings-input-validation';
describe('validateEmbedQueryInput', () => {
const mockNode: INode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
it('should return valid non-empty string', () => {
const result = validateEmbedQueryInput('valid query', mockNode);
expect(result).toBe('valid query');
});
it('should throw NodeOperationError for invalid input with proper description', () => {
expect(() => validateEmbedQueryInput('', mockNode)).toThrow(NodeOperationError);
expect(() => validateEmbedQueryInput(undefined, mockNode)).toThrow(NodeOperationError);
try {
validateEmbedQueryInput('', mockNode);
fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(NodeOperationError);
const nodeError = error as NodeOperationError;
expect(nodeError.description).toContain('text provided for embedding is empty or undefined');
}
});
});
describe('validateEmbedDocumentsInput', () => {
const mockNode: INode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
it('should return valid array of strings', () => {
const docs = ['doc1', 'doc2', 'doc3'];
const result = validateEmbedDocumentsInput(docs, mockNode);
expect(result).toEqual(docs);
});
it('should throw NodeOperationError for non-array input with proper description', () => {
expect(() => validateEmbedDocumentsInput('not an array', mockNode)).toThrow(NodeOperationError);
expect(() => validateEmbedDocumentsInput(undefined, mockNode)).toThrow(NodeOperationError);
expect(() => validateEmbedDocumentsInput({}, mockNode)).toThrow(NodeOperationError);
try {
validateEmbedDocumentsInput('not array', mockNode);
fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(NodeOperationError);
const nodeError = error as NodeOperationError;
expect(nodeError.description).toContain('Expected an array of strings');
}
});
it('should throw NodeOperationError for invalid document at correct index', () => {
const docs = ['valid', undefined, 'valid2'];
expect(() => validateEmbedDocumentsInput(docs, mockNode)).toThrow(
'Invalid document at index 1',
);
const docs2 = ['valid1', 'valid2', null, 'valid3'];
expect(() => validateEmbedDocumentsInput(docs2, mockNode)).toThrow(
'Invalid document at index 2',
);
});
});
@@ -0,0 +1,66 @@
import { n8nDefaultFailedAttemptHandler } from 'src/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler';
class MockHttpError extends Error {
response: { status: number };
constructor(message: string, code: number) {
super(message);
this.response = { status: code };
}
}
describe('n8nDefaultFailedAttemptHandler', () => {
it('should throw error if message starts with "Cancel"', () => {
const error = new Error('Cancel operation');
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
});
it('should throw error if message starts with "AbortError"', () => {
const error = new Error('AbortError occurred');
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
});
it('should throw error if name is "AbortError"', () => {
class MockAbortError extends Error {
constructor() {
super('Some error');
this.name = 'AbortError';
}
}
const error = new MockAbortError();
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
});
it('should throw error if code is "ECONNABORTED"', () => {
class MockAbortError extends Error {
code: string;
constructor() {
super('Some error');
this.code = 'ECONNABORTED';
}
}
const error = new MockAbortError();
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
});
it('should throw error if status is in STATUS_NO_RETRY', () => {
const error = new MockHttpError('Some error', 400);
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
});
it('should not throw error if status is not in STATUS_NO_RETRY', () => {
const error = new MockHttpError('Some error', 500);
error.response = { status: 500 };
expect(() => n8nDefaultFailedAttemptHandler(error)).not.toThrow();
});
it('should not throw error if no conditions are met', () => {
const error = new Error('Some random error');
expect(() => n8nDefaultFailedAttemptHandler(error)).not.toThrow();
});
});
@@ -0,0 +1,65 @@
import { mock } from 'jest-mock-extended';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { ApplicationError, NodeApiError } from 'n8n-workflow';
import { makeN8nLlmFailedAttemptHandler } from 'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
describe('makeN8nLlmFailedAttemptHandler', () => {
const ctx = mock<ISupplyDataFunctions>({
getNode: jest.fn(),
});
it('should throw a wrapped error, when NO custom handler is provided', () => {
const handler = makeN8nLlmFailedAttemptHandler(ctx);
expect(() => handler(new Error('Test error'))).toThrow(NodeApiError);
});
it('should wrapped error when custom handler is provided', () => {
const customHandler = jest.fn();
const handler = makeN8nLlmFailedAttemptHandler(ctx, customHandler);
expect(() => handler(new Error('Test error'))).toThrow(NodeApiError);
expect(customHandler).toHaveBeenCalled();
});
it('should throw wrapped exception from custom handler', () => {
const customHandler = jest.fn(() => {
throw new ApplicationError('Custom handler error');
});
const handler = makeN8nLlmFailedAttemptHandler(ctx, customHandler);
expect(() => handler(new Error('Test error'))).toThrow('Custom handler error');
expect(customHandler).toHaveBeenCalled();
});
it('should not throw if retries are left', () => {
const customHandler = jest.fn();
const handler = makeN8nLlmFailedAttemptHandler(ctx, customHandler);
const error = new Error('Test error');
(error as any).retriesLeft = 1;
expect(() => handler(error)).not.toThrow();
});
it('should throw NodeApiError if no retries are left', () => {
const handler = makeN8nLlmFailedAttemptHandler(ctx);
const error = new Error('Test error');
(error as any).retriesLeft = 0;
expect(() => handler(error)).toThrow(NodeApiError);
});
it('should throw NodeApiError if no retries are left with custom handler', () => {
const customHandler = jest.fn();
const handler = makeN8nLlmFailedAttemptHandler(ctx, customHandler);
const error = new Error('Test error');
(error as any).retriesLeft = 0;
expect(() => handler(error)).toThrow(NodeApiError);
expect(customHandler).toHaveBeenCalled();
});
});
@@ -0,0 +1,190 @@
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
import type { INode, INodeParameters } from 'n8n-workflow';
import {
createToolFromNode,
createZodSchemaFromArgs,
extractFromAIParameters,
} from '../../utils/fromai-tool-factory';
describe('fromAIToolFactory', () => {
describe('extractFromAIParameters', () => {
it('should extract $fromAI parameters from node parameters', () => {
const nodeParameters: INodeParameters = {
someField: '$fromAI("name", "The name of the user", "string")',
nestedField: {
value: '$fromAI("age", "The age of the user", "number")',
},
};
const result = extractFromAIParameters(nodeParameters);
expect(result).toHaveLength(2);
expect(result).toContainEqual(
expect.objectContaining({
key: 'name',
description: 'The name of the user',
type: 'string',
}),
);
expect(result).toContainEqual(
expect.objectContaining({ key: 'age', description: 'The age of the user', type: 'number' }),
);
});
it('should deduplicate parameters with the same key', () => {
const nodeParameters: INodeParameters = {
field1: '$fromAI("name", "First description")',
field2: '$fromAI("name", "Second description")',
};
const result = extractFromAIParameters(nodeParameters);
expect(result).toHaveLength(1);
expect(result[0].key).toBe('name');
});
it('should return empty array when no $fromAI parameters exist', () => {
const nodeParameters: INodeParameters = {
someField: 'regular value',
anotherField: 123,
};
const result = extractFromAIParameters(nodeParameters);
expect(result).toHaveLength(0);
});
it('should handle arrays in node parameters', () => {
const nodeParameters: INodeParameters = {
items: ['$fromAI("item1", "First item")', '$fromAI("item2", "Second item")'],
};
const result = extractFromAIParameters(nodeParameters);
expect(result).toHaveLength(2);
});
});
describe('createZodSchemaFromArgs', () => {
it('should create a Zod schema from arguments', () => {
const args = [
{ key: 'name', description: 'The name', type: 'string' as const },
{ key: 'age', description: 'The age', type: 'number' as const },
];
const schema = createZodSchemaFromArgs(args);
expect(schema.shape).toHaveProperty('name');
expect(schema.shape).toHaveProperty('age');
});
it('should handle boolean type', () => {
const args = [{ key: 'isActive', description: 'Is active', type: 'boolean' as const }];
const schema = createZodSchemaFromArgs(args);
const result = schema.safeParse({ isActive: true });
expect(result.success).toBe(true);
});
it('should handle default values', () => {
const args = [
{ key: 'name', description: 'The name', type: 'string' as const, defaultValue: 'John' },
];
const schema = createZodSchemaFromArgs(args);
const result = schema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('John');
}
});
});
describe('createToolFromNode', () => {
const createMockNode = (parameters: INodeParameters = {}): INode => ({
id: 'test-node',
name: 'Test Node',
type: 'test',
typeVersion: 1,
position: [0, 0],
parameters,
});
it('should create a DynamicTool when no $fromAI params and no extraArgs', () => {
const node = createMockNode({ regularParam: 'value' });
const tool = createToolFromNode(node, {
name: 'test-tool',
description: 'A test tool',
func: async () => 'result',
});
expect(tool).toBeInstanceOf(DynamicTool);
expect(tool.name).toBe('test-tool');
expect(tool.description).toBe('A test tool');
});
it('should create a DynamicStructuredTool when $fromAI params exist', () => {
const node = createMockNode({
field: '$fromAI("query", "The search query")',
});
const tool = createToolFromNode(node, {
name: 'search-tool',
description: 'A search tool',
func: async () => 'result',
});
expect(tool).toBeInstanceOf(DynamicStructuredTool);
expect(tool.name).toBe('search-tool');
});
it('should create a DynamicStructuredTool when extraArgs are provided', () => {
const node = createMockNode({});
const tool = createToolFromNode(node, {
name: 'tool-with-extra',
description: 'A tool with extra args',
func: async () => 'result',
extraArgs: [{ key: 'input', description: 'The input query' }],
});
expect(tool).toBeInstanceOf(DynamicStructuredTool);
});
it('should combine $fromAI params with extraArgs', () => {
const node = createMockNode({
field: '$fromAI("filter", "Filter criteria")',
});
const tool = createToolFromNode(node, {
name: 'combined-tool',
description: 'A tool with combined args',
func: async () => 'result',
extraArgs: [{ key: 'input', description: 'The input query' }],
}) as DynamicStructuredTool;
expect(tool).toBeInstanceOf(DynamicStructuredTool);
// Verify schema contains both keys by checking if parsing works
const schema = tool.schema as unknown as { shape: Record<string, unknown> };
expect(schema.shape).toHaveProperty('filter');
expect(schema.shape).toHaveProperty('input');
});
it('should pass the func to the tool', async () => {
const mockFunc = jest.fn().mockResolvedValue('test result');
const node = createMockNode({});
const tool = createToolFromNode(node, {
name: 'func-test-tool',
description: 'Testing func',
func: mockFunc,
});
const result = await tool.invoke('test input');
// Verify the function was called with the input as the first argument
expect(mockFunc).toHaveBeenCalled();
expect(mockFunc.mock.calls[0][0]).toBe('test input');
expect(result).toBe('test result');
});
});
});
@@ -0,0 +1,105 @@
import { hasLongSequentialRepeat } from 'src/utils/helpers';
describe('hasLongSequentialRepeat', () => {
it('should return false for text shorter than threshold', () => {
const text = 'a'.repeat(99);
expect(hasLongSequentialRepeat(text, 100)).toBe(false);
});
it('should return false for normal text without repeats', () => {
const text = 'This is a normal text without many sequential repeating characters.';
expect(hasLongSequentialRepeat(text)).toBe(false);
});
it('should return true for text with exactly threshold repeats', () => {
const text = 'a'.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should return true for text with more than threshold repeats', () => {
const text = 'b'.repeat(150);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should detect repeats in the middle of text', () => {
const text = 'Normal text ' + 'x'.repeat(100) + ' more normal text';
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should detect repeats at the end of text', () => {
const text = 'Normal text at the beginning' + 'z'.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should work with different thresholds', () => {
const text = 'a'.repeat(50);
expect(hasLongSequentialRepeat(text, 30)).toBe(true);
expect(hasLongSequentialRepeat(text, 60)).toBe(false);
});
it('should handle special characters', () => {
const text = '.'.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should handle spaces', () => {
const text = ' '.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should handle newlines', () => {
const text = '\n'.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
it('should not detect non-sequential repeats', () => {
const text = 'ababab'.repeat(50); // 300 chars but no sequential repeats
expect(hasLongSequentialRepeat(text, 100)).toBe(false);
});
it('should handle mixed content with repeats below threshold', () => {
const text = 'aaa' + 'b'.repeat(50) + 'ccc' + 'd'.repeat(40) + 'eee';
expect(hasLongSequentialRepeat(text, 100)).toBe(false);
});
it('should handle empty string', () => {
expect(hasLongSequentialRepeat('', 100)).toBe(false);
});
it('should work with very large texts', () => {
const normalText = 'Lorem ipsum dolor sit amet '.repeat(1000);
const textWithRepeat = normalText + 'A'.repeat(100) + normalText;
expect(hasLongSequentialRepeat(textWithRepeat, 100)).toBe(true);
});
it('should detect unicode character repeats', () => {
const text = '😀'.repeat(100);
expect(hasLongSequentialRepeat(text, 100)).toBe(true);
});
describe('error handling', () => {
it('should handle null input', () => {
expect(hasLongSequentialRepeat(null as unknown as string)).toBe(false);
});
it('should handle undefined input', () => {
expect(hasLongSequentialRepeat(undefined as unknown as string)).toBe(false);
});
it('should handle non-string input', () => {
expect(hasLongSequentialRepeat(123 as unknown as string)).toBe(false);
expect(hasLongSequentialRepeat({} as unknown as string)).toBe(false);
expect(hasLongSequentialRepeat([] as unknown as string)).toBe(false);
});
it('should handle zero or negative threshold', () => {
const text = 'a'.repeat(100);
expect(hasLongSequentialRepeat(text, 0)).toBe(false);
expect(hasLongSequentialRepeat(text, -1)).toBe(false);
});
it('should handle empty string', () => {
expect(hasLongSequentialRepeat('', 100)).toBe(false);
});
});
});
@@ -0,0 +1,460 @@
import { Agent, ProxyAgent } from 'undici';
import { getProxyAgent, proxyFetch } from 'src/utils/http-proxy-agent';
// Mock the dependencies
jest.mock('undici', () => ({
Agent: jest.fn().mockImplementation((options) => ({ type: 'Agent', options })),
ProxyAgent: jest.fn().mockImplementation((options) => ({ type: 'ProxyAgent', options })),
}));
// Mock global fetch
global.fetch = jest.fn();
describe('getProxyAgent', () => {
// Store original environment variables
const originalEnv = { ...process.env };
// Reset environment variables before each test
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.HTTP_PROXY;
delete process.env.http_proxy;
delete process.env.HTTPS_PROXY;
delete process.env.https_proxy;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
delete process.env.N8N_AI_TIMEOUT_MAX;
});
// Restore original environment after all tests
afterAll(() => {
process.env = originalEnv;
});
describe('backward compatible behavior (no timeout options)', () => {
it('should return undefined when no proxy environment variables are set and no timeout options', () => {
const agent = getProxyAgent();
expect(agent).toBeUndefined();
expect(ProxyAgent).not.toHaveBeenCalled();
expect(Agent).not.toHaveBeenCalled();
});
it('should return undefined when no proxy is configured for target URL and no timeout options', () => {
const agent = getProxyAgent('https://api.openai.com/v1');
expect(agent).toBeUndefined();
expect(ProxyAgent).not.toHaveBeenCalled();
expect(Agent).not.toHaveBeenCalled();
});
it('should create ProxyAgent with default timeouts when HTTPS_PROXY is set', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const agent = getProxyAgent();
expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl,
headersTimeout: 3600000,
bodyTimeout: 3600000,
});
expect(agent).toEqual({
type: 'ProxyAgent',
options: { uri: proxyUrl, headersTimeout: 3600000, bodyTimeout: 3600000 },
});
});
it('should create ProxyAgent when https_proxy is set', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.https_proxy = proxyUrl;
getProxyAgent();
expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl,
headersTimeout: 3600000,
bodyTimeout: 3600000,
});
});
it('should respect priority order of proxy environment variables', () => {
// Set multiple proxy environment variables
process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080';
process.env.http_proxy = 'http://http-proxy-lowercase.example.com:8080';
process.env.HTTPS_PROXY = 'https://https-proxy.example.com:8080';
process.env.https_proxy = 'https://https-proxy-lowercase.example.com:8080';
getProxyAgent();
// Should use https_proxy as it has highest priority now
expect(ProxyAgent).toHaveBeenCalledWith(
expect.objectContaining({
uri: 'https://https-proxy-lowercase.example.com:8080',
}),
);
});
});
describe('target URL provided', () => {
it('should create ProxyAgent for HTTPS URL when HTTPS_PROXY is set', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
getProxyAgent('https://api.openai.com/v1');
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
});
it('should create ProxyAgent for HTTP URL when HTTP_PROXY is set', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTP_PROXY = proxyUrl;
getProxyAgent('http://api.example.com');
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
});
it('should use HTTPS_PROXY for HTTPS URLs even when HTTP_PROXY is set', () => {
const httpProxy = 'http://http-proxy.example.com:8080';
const httpsProxy = 'https://https-proxy.example.com:8443';
process.env.HTTP_PROXY = httpProxy;
process.env.HTTPS_PROXY = httpsProxy;
getProxyAgent('https://api.openai.com/v1');
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: httpsProxy }));
});
it('should respect NO_PROXY for localhost', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTP_PROXY = proxyUrl;
process.env.NO_PROXY = 'localhost,127.0.0.1';
const agent = getProxyAgent('http://localhost:3000');
expect(agent).toBeUndefined();
expect(ProxyAgent).not.toHaveBeenCalled();
});
it('should respect NO_PROXY wildcard patterns', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.NO_PROXY = '*.internal.company.com,localhost';
const agent = getProxyAgent('https://api.internal.company.com');
expect(agent).toBeUndefined();
expect(ProxyAgent).not.toHaveBeenCalled();
});
it('should use proxy for URLs not in NO_PROXY', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.NO_PROXY = 'localhost,127.0.0.1';
getProxyAgent('https://api.openai.com/v1');
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
});
it('should handle mixed case environment variables', () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.https_proxy = proxyUrl;
process.env.no_proxy = 'localhost';
getProxyAgent('https://api.openai.com/v1');
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
});
});
describe('timeout options', () => {
it('should pass custom timeout options to ProxyAgent when proxy is set', () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
getProxyAgent('https://api.openai.com/v1', {
headersTimeout: 120000,
bodyTimeout: 180000,
});
expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl,
headersTimeout: 120000,
bodyTimeout: 180000,
});
});
it('should create Agent with timeout options when no proxy is configured', () => {
const agent = getProxyAgent('https://api.openai.com/v1', {
headersTimeout: 120000,
bodyTimeout: 180000,
});
expect(Agent).toHaveBeenCalledWith({
headersTimeout: 120000,
bodyTimeout: 180000,
});
expect(agent).toEqual({
type: 'Agent',
options: { headersTimeout: 120000, bodyTimeout: 180000 },
});
});
it('should use default timeouts when empty timeout options object is passed', () => {
getProxyAgent('https://api.openai.com/v1', {});
expect(Agent).toHaveBeenCalledWith({
headersTimeout: 3600000,
bodyTimeout: 3600000,
});
});
it('should include connectTimeout when provided', () => {
getProxyAgent('https://api.openai.com/v1', {
headersTimeout: 60000,
bodyTimeout: 60000,
connectTimeout: 30000,
});
expect(Agent).toHaveBeenCalledWith({
headersTimeout: 60000,
bodyTimeout: 60000,
connectTimeout: 30000,
});
});
it('should respect custom timeout from environment variable', () => {
process.env.N8N_AI_TIMEOUT_MAX = '300000';
// Need to re-import to pick up env vars (or mock module)
// For this test, we just verify the default timeout parsing
// The actual behavior is tested by integration tests
// Empty options should use env var defaults
getProxyAgent('https://api.openai.com/v1', {});
// Since we can't easily re-import, we verify the mock was called with defaults
expect(Agent).toHaveBeenCalled();
});
});
});
describe('proxyFetch', () => {
// Store original environment variables
const originalEnv = { ...process.env };
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
// Reset environment variables and mocks before each test
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.HTTP_PROXY;
delete process.env.http_proxy;
delete process.env.HTTPS_PROXY;
delete process.env.https_proxy;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
// Setup default fetch mock response
mockFetch.mockResolvedValue(
new Response('{}', {
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'application/json' },
}),
);
});
// Restore original environment after all tests
afterAll(() => {
process.env = originalEnv;
});
describe('with no proxy configured', () => {
it('should call fetch with undefined dispatcher when no proxy is set and no timeout options', async () => {
const url = 'https://api.openai.com/v1';
await proxyFetch(url);
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined,
});
});
it('should call fetch with Agent dispatcher when timeout options are provided', async () => {
const url = 'https://api.openai.com/v1';
await proxyFetch(url, undefined, { headersTimeout: 60000 });
expect(Agent).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: expect.objectContaining({ type: 'Agent' }),
});
});
it('should pass through RequestInit options', async () => {
const url = 'https://api.openai.com/v1';
const init: RequestInit = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test: 'data' }),
};
await proxyFetch(url, init);
expect(mockFetch).toHaveBeenCalledWith(url, {
...init,
dispatcher: undefined,
});
});
it('should handle URL objects', async () => {
const url = new URL('https://api.openai.com/v1');
await proxyFetch(url);
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined,
});
});
it('should handle Request objects', async () => {
const request = new Request('https://api.openai.com/v1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test: 'data' }),
});
await proxyFetch(request);
expect(mockFetch).toHaveBeenCalledWith(request, {
dispatcher: undefined,
});
});
});
describe('with proxy configured', () => {
it('should call fetch with ProxyAgent dispatcher when proxy is set', async () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const url = 'https://api.openai.com/v1';
await proxyFetch(url);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }),
});
});
it('should pass through RequestInit options with proxy', async () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const url = 'https://api.openai.com/v1';
const init: RequestInit = {
method: 'POST',
headers: { Authorization: 'Bearer token123' },
};
await proxyFetch(url, init);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url, {
...init,
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }),
});
});
it('should handle URL objects with proxy', async () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTP_PROXY = proxyUrl;
const url = new URL('http://api.example.com/data');
await proxyFetch(url);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }),
});
});
it('should handle Request objects with proxy', async () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const request = new Request('https://api.openai.com/v1');
await proxyFetch(request);
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
expect(mockFetch).toHaveBeenCalledWith(request, {
dispatcher: expect.objectContaining({ type: 'ProxyAgent' }),
});
});
it('should respect NO_PROXY environment variable', async () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
process.env.NO_PROXY = 'localhost,127.0.0.1';
const url = 'https://localhost:3000/api';
await proxyFetch(url);
// Should not create ProxyAgent for localhost
expect(mockFetch).toHaveBeenCalledWith(url, {
dispatcher: undefined,
});
});
it('should pass timeout options to ProxyAgent when proxy is configured', async () => {
const proxyUrl = 'https://proxy.example.com:8080';
process.env.HTTPS_PROXY = proxyUrl;
const url = 'https://api.openai.com/v1';
await proxyFetch(url, undefined, { headersTimeout: 300000, bodyTimeout: 300000 });
expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl,
headersTimeout: 300000,
bodyTimeout: 300000,
});
});
});
describe('return value', () => {
it('should return the Response from fetch', async () => {
const expectedResponse = new Response('{"success":true}', {
status: 200,
statusText: 'OK',
});
mockFetch.mockResolvedValueOnce(expectedResponse);
const url = 'https://api.openai.com/v1';
const result = await proxyFetch(url);
expect(result).toBe(expectedResponse);
});
it('should propagate fetch errors', async () => {
const error = new Error('Network error');
mockFetch.mockRejectedValueOnce(error);
const url = 'https://api.openai.com/v1';
await expect(proxyFetch(url)).rejects.toThrow('Network error');
});
it('should return error responses without throwing', async () => {
const errorResponse = new Response('Not Found', {
status: 404,
statusText: 'Not Found',
});
mockFetch.mockResolvedValueOnce(errorResponse);
const url = 'https://api.openai.com/v1';
const result = await proxyFetch(url);
expect(result).toBe(errorResponse);
expect(result.status).toBe(404);
});
});
});
@@ -0,0 +1,78 @@
import type { AiEvent, IDataObject, IExecuteFunctions, ISupplyDataFunctions } from 'n8n-workflow';
import { logAiEvent } from 'src/utils/log-ai-event';
describe('logAiEvent', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions | ISupplyDataFunctions>;
let mockLogger: { debug: jest.Mock };
beforeEach(() => {
mockLogger = {
debug: jest.fn(),
};
mockExecuteFunctions = {
logAiEvent: jest.fn(),
logger: mockLogger,
} as unknown as jest.Mocked<IExecuteFunctions | ISupplyDataFunctions>;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('successful logging', () => {
it('should log AI event without data', () => {
const event: AiEvent = 'ai-llm-generated-output';
logAiEvent(mockExecuteFunctions, event);
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledWith(event, undefined);
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledTimes(1);
});
it('should log AI event with data object', () => {
const event: AiEvent = 'ai-llm-generated-output';
const data: IDataObject = { response: 'test response', tokens: 100 };
logAiEvent(mockExecuteFunctions, event, data);
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledWith(event, JSON.stringify(data));
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledTimes(1);
});
it('should log different AI event types', () => {
const events: AiEvent[] = ['ai-llm-generated-output', 'ai-llm-errored', 'ai-tool-called'];
const data: IDataObject = { test: 'data' };
events.forEach((event) => {
logAiEvent(mockExecuteFunctions, event, data);
});
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledTimes(3);
});
});
it('should catch error and log debug message when logAiEvent throws', () => {
const event: AiEvent = 'ai-llm-generated-output';
const error = new Error('Logging failed');
mockExecuteFunctions.logAiEvent.mockImplementation(() => {
throw error;
});
// Should not throw
expect(() => logAiEvent(mockExecuteFunctions, event)).not.toThrow();
expect(mockLogger.debug).toHaveBeenCalledWith(`Error logging AI event: ${event}`);
});
it('should handle JSON.stringify errors gracefully', () => {
const event: AiEvent = 'ai-llm-generated-output';
const circularData: IDataObject = {};
circularData.self = circularData; // Create circular reference
// Should not throw
expect(() => logAiEvent(mockExecuteFunctions, event, circularData)).not.toThrow();
expect(mockLogger.debug).toHaveBeenCalledWith(`Error logging AI event: ${event}`);
});
});
@@ -0,0 +1,567 @@
import type { Document } from '@langchain/core/documents';
import type { TextSplitter } from '@langchain/textsplitters';
import type { IBinaryData, IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
import { BINARY_ENCODING, NodeOperationError } from 'n8n-workflow';
import { Readable } from 'stream';
import { N8nBinaryLoader } from 'src/utils/n8n-binary-loader';
// Mock the helpers module
jest.mock('src/utils/helpers', () => ({
getMetadataFiltersValues: jest.fn(),
}));
// Mock LangChain loaders
jest.mock('@langchain/classic/document_loaders/fs/json', () => ({
JSONLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'json content', metadata: {} }]),
})),
}));
jest.mock('@langchain/classic/document_loaders/fs/text', () => ({
TextLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'text content', metadata: {} }]),
})),
}));
jest.mock('@langchain/community/document_loaders/fs/csv', () => ({
CSVLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'csv content', metadata: {} }]),
})),
}));
jest.mock('@langchain/community/document_loaders/fs/docx', () => ({
DocxLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'docx content', metadata: {} }]),
})),
}));
jest.mock('@langchain/community/document_loaders/fs/epub', () => ({
EPubLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'epub content', metadata: {} }]),
})),
}));
jest.mock('@langchain/community/document_loaders/fs/pdf', () => ({
PDFLoader: jest.fn().mockImplementation(() => ({
load: jest.fn().mockResolvedValue([{ pageContent: 'pdf content', metadata: {} }]),
})),
}));
const { getMetadataFiltersValues } = jest.requireMock('src/utils/helpers');
describe('N8nBinaryLoader', () => {
let mockContext: jest.MockedObjectDeep<IExecuteFunctions>;
let mockNode: INode;
beforeEach(() => {
mockNode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
mockContext = {
getNode: jest.fn().mockReturnValue(mockNode),
getNodeParameter: jest.fn(),
getInputData: jest.fn().mockReturnValue([]),
helpers: {
assertBinaryData: jest.fn(),
binaryToBuffer: jest.fn(),
getBinaryStream: jest.fn(),
},
} as unknown as jest.MockedObjectDeep<IExecuteFunctions>;
getMetadataFiltersValues.mockReturnValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('should create instance with default parameters', () => {
const loader = new N8nBinaryLoader(mockContext);
expect(loader).toBeInstanceOf(N8nBinaryLoader);
});
it('should create instance with all parameters', () => {
const mockSplitter = {} as TextSplitter;
const loader = new N8nBinaryLoader(mockContext, 'prefix.', 'binaryKey', mockSplitter);
expect(loader).toBeInstanceOf(N8nBinaryLoader);
});
});
describe('processAll', () => {
it('should return empty array for undefined items', async () => {
const loader = new N8nBinaryLoader(mockContext);
const result = await loader.processAll(undefined);
expect(result).toEqual([]);
});
it('should return empty array for empty items', async () => {
const loader = new N8nBinaryLoader(mockContext);
const result = await loader.processAll([]);
expect(result).toEqual([]);
});
it('should process multiple items', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test content').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const items: INodeExecutionData[] = [
{ json: {}, binary: { file: mockBinaryData } },
{ json: {}, binary: { file: mockBinaryData } },
];
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processAll(items);
expect(result).toBeInstanceOf(Array);
});
});
describe('processItem - singleFile mode', () => {
it('should process text file', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test content').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { data: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'data');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.helpers.assertBinaryData).toHaveBeenCalledWith(0, 'data');
});
it('should process PDF file', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'pdfLoader';
if (param === 'splitPages') return false;
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'application/pdf',
data: Buffer.from('fake pdf content').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { document: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'document');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('splitPages', 0, false);
});
it('should process CSV file with options', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'csvLoader';
if (param === 'column') return 'text';
if (param === 'separator') return ';';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/csv',
data: Buffer.from('col1;col2\nval1;val2').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { csv: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'csv');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('column', 0, null);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('separator', 0, ',');
});
it('should process JSON file with pointers', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'jsonLoader';
if (param === 'pointers') return '/data, /items';
return undefined;
});
const jsonData = JSON.stringify({ data: 'test', items: ['item1', 'item2'] });
const mockBinaryData: IBinaryData = {
mimeType: 'application/json',
data: Buffer.from(jsonData).toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { json: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'json');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('pointers', 0, '');
});
});
describe('processItem - allInputData mode', () => {
it('should process all binary data from input', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'allInputData';
if (param === 'loader') return 'auto';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
mockContext.getInputData.mockReturnValue([
{
json: {},
binary: {
file1: mockBinaryData,
file2: mockBinaryData,
},
},
]);
const item: INodeExecutionData = {
json: {},
binary: {
file1: mockBinaryData,
file2: mockBinaryData,
},
};
const loader = new N8nBinaryLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.getInputData).toHaveBeenCalled();
});
it('should handle empty binary data in allInputData mode', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'allInputData';
return undefined;
});
mockContext.getInputData.mockReturnValue([{ json: {} }]);
const item: INodeExecutionData = {
json: {},
};
const loader = new N8nBinaryLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toEqual([]);
});
});
describe('validateMimeType', () => {
it('should throw error when loader does not match mime type', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'pdfLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain', // Wrong mime type for pdfLoader
data: Buffer.from('test').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
await expect(loader.processItem(item, 0)).rejects.toThrow(NodeOperationError);
await expect(loader.processItem(item, 0)).rejects.toThrow(
"Mime type doesn't match selected loader",
);
});
it('should throw error for unsupported mime type', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'auto';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'video/mp4', // Unsupported mime type
data: Buffer.from('test').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
await expect(loader.processItem(item, 0)).rejects.toThrow(NodeOperationError);
await expect(loader.processItem(item, 0)).rejects.toThrow('Unsupported mime type');
});
it('should accept valid mime type for auto loader', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'auto';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
});
});
describe('binary data with ID', () => {
it('should handle binary data with ID', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
id: 'binary-123',
mimeType: 'text/plain',
data: Buffer.from('test content').toString(BINARY_ENCODING),
};
const mockStream = Buffer.from('test content');
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
mockContext.helpers.getBinaryStream.mockResolvedValue(Readable.from(mockStream));
mockContext.helpers.binaryToBuffer.mockResolvedValue(mockStream);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.helpers.getBinaryStream).toHaveBeenCalledWith('binary-123');
expect(mockContext.helpers.binaryToBuffer).toHaveBeenCalled();
});
});
describe('metadata handling', () => {
it('should add custom metadata to documents', async () => {
const customMetadata = { source: 'test', type: 'document' };
getMetadataFiltersValues.mockReturnValue(customMetadata);
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processItem(item, 0);
expect(result.length).toBeGreaterThan(0);
expect(result[0].metadata).toMatchObject(customMetadata);
});
});
describe('text splitter integration', () => {
it('should use text splitter when provided', async () => {
const mockDocuments: Document[] = [
{ pageContent: 'split 1', metadata: {} },
{ pageContent: 'split 2', metadata: {} },
];
const mockSplitter = {
splitDocuments: jest.fn().mockResolvedValue(mockDocuments),
} as unknown as TextSplitter;
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test content').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file', mockSplitter);
await loader.processItem(item, 0);
expect(mockSplitter.splitDocuments).toHaveBeenCalled();
});
});
describe('options prefix', () => {
it('should use options prefix for loader parameters', async () => {
const prefix = 'options.';
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'pdfLoader';
if (param === `${prefix}splitPages`) return true;
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'application/pdf',
data: Buffer.from('fake pdf').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { pdf: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, prefix, 'pdf');
await loader.processItem(item, 0);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith(`${prefix}splitPages`, 0, false);
});
});
it('should process text file', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'textLoader';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'text/plain',
data: Buffer.from('test content').toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
});
it('should process JSON file', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'binaryMode') return 'singleFile';
if (param === 'loader') return 'jsonLoader';
if (param === 'pointers') return '';
return undefined;
});
const mockBinaryData: IBinaryData = {
mimeType: 'application/json',
data: Buffer.from(JSON.stringify({ test: 'content' })).toString(BINARY_ENCODING),
};
mockContext.helpers.assertBinaryData.mockReturnValue(mockBinaryData);
const item: INodeExecutionData = {
json: {},
binary: { file: mockBinaryData },
};
const loader = new N8nBinaryLoader(mockContext, '', 'file');
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
});
});
@@ -0,0 +1,320 @@
import type { Document } from '@langchain/core/documents';
import type { TextSplitter } from '@langchain/textsplitters';
import type { IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { N8nJsonLoader } from 'src/utils/n8n-json-loader';
// Mock the helpers module
jest.mock('src/utils/helpers', () => ({
getMetadataFiltersValues: jest.fn(),
}));
const { getMetadataFiltersValues } = jest.requireMock('src/utils/helpers');
describe('N8nJsonLoader', () => {
let mockContext: jest.Mocked<IExecuteFunctions>;
let mockNode: INode;
beforeEach(() => {
mockNode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
mockContext = {
getNode: jest.fn().mockReturnValue(mockNode),
getNodeParameter: jest.fn(),
} as unknown as jest.Mocked<IExecuteFunctions>;
getMetadataFiltersValues.mockReturnValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('should create instance with default parameters', () => {
const loader = new N8nJsonLoader(mockContext);
expect(loader).toBeInstanceOf(N8nJsonLoader);
});
it('should create instance with options prefix', () => {
const loader = new N8nJsonLoader(mockContext, 'prefix.');
expect(loader).toBeInstanceOf(N8nJsonLoader);
});
it('should create instance with text splitter', () => {
const mockSplitter = {
splitDocuments: jest.fn(),
} as unknown as TextSplitter;
const loader = new N8nJsonLoader(mockContext, '', mockSplitter);
expect(loader).toBeInstanceOf(N8nJsonLoader);
});
});
describe('processAll', () => {
it('should return empty array for undefined items', async () => {
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processAll(undefined);
expect(result).toEqual([]);
});
it('should return empty array for empty items', async () => {
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processAll([]);
expect(result).toEqual([]);
});
it('should process single item', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const items: INodeExecutionData[] = [
{
json: { message: 'test data' },
},
];
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processAll(items);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
});
it('should process multiple items', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const items: INodeExecutionData[] = [
{ json: { message: 'item 1' } },
{ json: { message: 'item 2' } },
{ json: { message: 'item 3' } },
];
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processAll(items);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
});
});
describe('processItem - allInputData mode', () => {
it('should process item in allInputData mode', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: { test: 'data', nested: { value: 123 } },
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty('pageContent');
expect(result[0]).toHaveProperty('metadata');
});
it('should process item with JSON pointers in allInputData mode', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '/test, /nested/value';
return undefined;
});
const item: INodeExecutionData = {
json: { test: 'data', nested: { value: 123 } },
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('pointers', 0, '');
});
});
it('should process string data in expressionData mode', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'expressionData';
if (param === 'jsonData') return 'plain text data';
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: {},
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('jsonData', 0);
});
it('should process object data in expressionData mode', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'expressionData';
if (param === 'jsonData') return { test: 'object data' };
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: {},
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
});
it('should add metadata to documents', async () => {
const metadata = { source: 'test', category: 'document' };
getMetadataFiltersValues.mockReturnValue(metadata);
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: { test: 'data' },
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result.length).toBeGreaterThan(0);
expect(result[0].metadata).toMatchObject(metadata);
});
it('should use text splitter when provided', async () => {
const mockDocuments: Document[] = [
{ pageContent: 'split content 1', metadata: {} },
{ pageContent: 'split content 2', metadata: {} },
];
const mockSplitter = {
splitDocuments: jest.fn().mockResolvedValue(mockDocuments),
} as unknown as TextSplitter;
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: { test: 'data' },
};
const loader = new N8nJsonLoader(mockContext, '', mockSplitter);
const result = await loader.processItem(item, 0);
expect(mockSplitter.splitDocuments).toHaveBeenCalled();
expect(result).toEqual(mockDocuments);
});
it('should use options prefix for pointers parameter', async () => {
const prefix = 'customPrefix.';
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === `${prefix}pointers`) return '/custom';
return '';
});
const item: INodeExecutionData = {
json: { test: 'data' },
};
const loader = new N8nJsonLoader(mockContext, prefix);
await loader.processItem(item, 0);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith(`${prefix}pointers`, 0, '');
});
it('should return empty array for null item', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(null as unknown as INodeExecutionData, 0);
expect(result).toEqual([]);
});
it('should throw NodeOperationError when document loader is not initialized', async () => {
// Mock a scenario where documentLoader stays null
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'unknownMode'; // Invalid mode
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: { test: 'data' },
};
const loader = new N8nJsonLoader(mockContext);
await expect(loader.processItem(item, 0)).rejects.toThrow(NodeOperationError);
await expect(loader.processItem(item, 0)).rejects.toThrow('Document loader is not initialized');
});
it('should handle complex JSON structures with nesting and arrays', async () => {
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'jsonMode') return 'allInputData';
if (param === 'pointers') return '';
return undefined;
});
const item: INodeExecutionData = {
json: {
level1: {
level2: {
level3: {
value: 'deep value',
},
},
},
items: ['item1', 'item2', 'item3'],
},
};
const loader = new N8nJsonLoader(mockContext);
const result = await loader.processItem(item, 0);
expect(result).toBeInstanceOf(Array);
expect(result.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,600 @@
import type { Serialized } from '@langchain/core/load/serializable';
import type { BaseMessage } from '@langchain/core/messages';
import type { LLMResult } from '@langchain/core/outputs';
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { N8nLlmTracing } from 'src/utils/n8n-llm-tracing';
// Mock the dependencies
jest.mock('src/utils/log-ai-event', () => ({
logAiEvent: jest.fn(),
}));
jest.mock('src/utils/tokenizer/token-estimator', () => ({
estimateTokensFromStringList: jest.fn().mockResolvedValue(100),
}));
const { logAiEvent } = jest.requireMock('src/utils/log-ai-event');
const { estimateTokensFromStringList } = jest.requireMock('src/utils/tokenizer/token-estimator');
describe('N8nLlmTracing', () => {
let mockExecutionFunctions: jest.Mocked<ISupplyDataFunctions>;
let mockNode: INode;
beforeEach(() => {
mockNode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.testNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
mockExecutionFunctions = {
getNode: jest.fn().mockReturnValue(mockNode),
addOutputData: jest.fn(),
addInputData: jest.fn().mockReturnValue({ index: 0 }),
getNextRunIndex: jest.fn().mockReturnValue(0),
} as unknown as jest.Mocked<ISupplyDataFunctions>;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('should create instance with default options', () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
expect(tracer).toBeInstanceOf(N8nLlmTracing);
expect(tracer.name).toBe('N8nLlmTracing');
expect(tracer.awaitHandlers).toBe(true);
expect(tracer.connectionType).toBe(NodeConnectionTypes.AiLanguageModel);
});
it('should create instance with custom tokensUsageParser', () => {
const customParser = jest.fn().mockReturnValue({
completionTokens: 50,
promptTokens: 30,
totalTokens: 80,
});
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
tokensUsageParser: customParser,
});
expect(tracer).toBeInstanceOf(N8nLlmTracing);
});
it('should create instance with custom errorDescriptionMapper', () => {
const customMapper = jest.fn().mockReturnValue('Custom error description');
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
errorDescriptionMapper: customMapper,
});
expect(tracer).toBeInstanceOf(N8nLlmTracing);
});
});
describe('handleLLMStart', () => {
it('should handle LLM start event with prompts', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'llms', 'openai'],
kwargs: { modelName: 'gpt-4', temperature: 0.7 },
};
const prompts = ['What is the capital of France?'];
const runId = 'run-123';
await tracer.handleLLMStart(llm, prompts, runId);
expect(mockExecutionFunctions.addInputData).toHaveBeenCalledWith(
NodeConnectionTypes.AiLanguageModel,
expect.arrayContaining([
expect.arrayContaining([
expect.objectContaining({
json: expect.objectContaining({
messages: prompts,
estimatedTokens: expect.any(Number),
options: llm.kwargs,
}),
}),
]),
]),
undefined,
);
expect(estimateTokensFromStringList).toHaveBeenCalledWith(prompts, 'gpt-4o');
});
it('should store run details for later use', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'not_implemented',
id: ['langchain', 'llms', 'test'],
};
const prompts = ['Test prompt'];
const runId = 'run-123';
await tracer.handleLLMStart(llm, prompts, runId);
expect(tracer.runsMap[runId]).toBeDefined();
expect(tracer.runsMap[runId].messages).toEqual(prompts);
expect(tracer.runsMap[runId].index).toBe(0);
});
it('should handle multiple prompts', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'llms', 'openai'],
kwargs: {},
};
const prompts = ['Prompt 1', 'Prompt 2', 'Prompt 3'];
const runId = 'run-123';
await tracer.handleLLMStart(llm, prompts, runId);
expect(tracer.runsMap[runId].messages).toEqual(prompts);
expect(estimateTokensFromStringList).toHaveBeenCalledWith(prompts, 'gpt-4o');
});
it('should use parent run index when set', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
tracer.setParentRunIndex(5);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['test'],
kwargs: {},
};
mockExecutionFunctions.getNextRunIndex.mockReturnValue(2);
await tracer.handleLLMStart(llm, ['test'], 'run-123');
expect(mockExecutionFunctions.addInputData).toHaveBeenCalledWith(
NodeConnectionTypes.AiLanguageModel,
expect.any(Array),
7, // 5 (parent) + 2 (next)
);
});
});
describe('handleLLMEnd', () => {
it('should handle LLM end event with token usage', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
// Setup run
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test prompt'],
options: {},
};
tracer.promptTokensEstimate = 50;
const output: LLMResult = {
generations: [[{ text: 'Response text', generationInfo: {} }]],
llmOutput: {
tokenUsage: {
completionTokens: 30,
promptTokens: 50,
totalTokens: 80,
},
},
};
await tracer.handleLLMEnd(output, runId);
expect(mockExecutionFunctions.addOutputData).toHaveBeenCalledWith(
NodeConnectionTypes.AiLanguageModel,
0,
expect.arrayContaining([
expect.arrayContaining([
expect.objectContaining({
json: expect.objectContaining({
response: expect.objectContaining({
generations: expect.any(Array),
}),
tokenUsage: expect.objectContaining({
completionTokens: 30,
promptTokens: 50,
totalTokens: 80,
}),
}),
}),
]),
]),
undefined,
undefined,
);
expect(logAiEvent).toHaveBeenCalledWith(
mockExecutionFunctions,
'ai-llm-generated-output',
expect.any(Object),
);
});
it('should use token estimates when actual tokens not available', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test prompt'],
options: {},
};
tracer.promptTokensEstimate = 50;
estimateTokensFromStringList.mockResolvedValue(25);
const output: LLMResult = {
generations: [[{ text: 'Response text' }]],
llmOutput: {},
};
await tracer.handleLLMEnd(output, runId);
const callArgs = mockExecutionFunctions.addOutputData.mock.calls[0] as any;
const outputData = callArgs?.[2]?.[0]?.[0]?.json;
expect(outputData.tokenUsageEstimate).toBeDefined();
expect(outputData.tokenUsageEstimate.completionTokens).toBe(25);
expect(outputData.tokenUsageEstimate.promptTokens).toBe(50);
expect(outputData.tokenUsageEstimate.totalTokens).toBe(75);
});
it('should handle string messages', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: 'Simple string message',
options: {},
};
const output: LLMResult = {
generations: [[{ text: 'Response' }]],
llmOutput: {},
};
await tracer.handleLLMEnd(output, runId);
expect(logAiEvent).toHaveBeenCalledWith(
mockExecutionFunctions,
'ai-llm-generated-output',
expect.objectContaining({
messages: 'Simple string message',
}),
);
});
it('should handle BaseMessage objects with toJSON', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const mockMessage: Partial<BaseMessage> = {
toJSON: jest.fn().mockReturnValue({ content: 'test', role: 'user' }),
};
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: [mockMessage as BaseMessage],
options: {},
};
const output: LLMResult = {
generations: [[{ text: 'Response' }]],
llmOutput: {},
};
await tracer.handleLLMEnd(output, runId);
expect(mockMessage.toJSON).toHaveBeenCalled();
});
it('should handle missing run details gracefully', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const output: LLMResult = {
generations: [[{ text: 'Response' }]],
llmOutput: {},
};
// Set up minimal run details with index but no messages
tracer.runsMap['non-existent-run'] = {
index: 0,
messages: [],
options: {},
};
// Run without full setup
await tracer.handleLLMEnd(output, 'non-existent-run');
expect(mockExecutionFunctions.addOutputData).toHaveBeenCalled();
});
it('should strip unnecessary fields from generation info', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const output: LLMResult = {
generations: [
[
{
text: 'Response',
generationInfo: { model: 'gpt-4' },
extraField: 'should be removed',
} as any,
],
],
llmOutput: {},
};
await tracer.handleLLMEnd(output, runId);
const callArgs = mockExecutionFunctions.addOutputData.mock.calls[0] as any;
const generations = callArgs[2][0][0].json.response.generations[0][0];
expect(generations).toHaveProperty('text');
expect(generations).toHaveProperty('generationInfo');
expect(generations).not.toHaveProperty('extraField');
});
});
describe('handleLLMError', () => {
it('should handle NodeError', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const error = new NodeOperationError(mockNode, 'Test error', {
description: 'Test description',
});
await tracer.handleLLMError(error, runId);
expect(mockExecutionFunctions.addOutputData).toHaveBeenCalledWith(
NodeConnectionTypes.AiLanguageModel,
0,
error,
);
expect(logAiEvent).toHaveBeenCalledWith(
mockExecutionFunctions,
'ai-llm-errored',
expect.objectContaining({
error: expect.any(Object),
runId,
}),
);
});
it('should wrap non-NodeError errors', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const error = new Error('Generic error');
await tracer.handleLLMError(error, runId);
expect(mockExecutionFunctions.addOutputData).toHaveBeenCalledWith(
NodeConnectionTypes.AiLanguageModel,
0,
expect.any(NodeOperationError),
);
});
it('should filter out non-x- headers from error', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const error = {
headers: {
'x-request-id': '123',
authorization: 'Bearer token',
'content-type': 'application/json',
'x-custom-header': 'value',
},
};
await tracer.handleLLMError(error, runId);
expect(error.headers).toHaveProperty('x-request-id');
expect(error.headers).toHaveProperty('x-custom-header');
expect(error.headers).not.toHaveProperty('authorization');
expect(error.headers).not.toHaveProperty('content-type');
});
it('should use custom error description mapper', async () => {
const customMapper = jest.fn().mockReturnValue('Custom description');
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
errorDescriptionMapper: customMapper,
});
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const error = new NodeOperationError(mockNode, 'Test error');
await tracer.handleLLMError(error, runId);
expect(customMapper).toHaveBeenCalledWith(error);
expect(error.description).toBe('Custom description');
});
it('should handle error with empty object', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const error = {};
await tracer.handleLLMError(error, runId);
expect(mockExecutionFunctions.addOutputData).toHaveBeenCalled();
expect(logAiEvent).toHaveBeenCalledWith(
mockExecutionFunctions,
'ai-llm-errored',
expect.objectContaining({
error: expect.any(String), // Should call toString()
}),
);
});
});
describe('token estimation', () => {
it('should estimate tokens from generation', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const generations: LLMResult['generations'] = [
[{ text: 'Response 1' }, { text: 'Response 2' }],
];
estimateTokensFromStringList.mockResolvedValue(42);
const result = await tracer.estimateTokensFromGeneration(generations);
expect(result).toBe(42);
expect(estimateTokensFromStringList).toHaveBeenCalledWith(
['Response 1', 'Response 2'],
'gpt-4o',
);
});
it('should estimate tokens from string list', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const list = ['String 1', 'String 2', 'String 3'];
estimateTokensFromStringList.mockResolvedValue(75);
const result = await tracer.estimateTokensFromStringList(list);
expect(result).toBe(75);
expect(estimateTokensFromStringList).toHaveBeenCalledWith(list, 'gpt-4o');
});
});
describe('setParentRunIndex', () => {
it('should set parent run index', () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
tracer.setParentRunIndex(10);
// The private field can't be accessed directly, but we can verify behavior
// in handleLLMStart
expect(tracer).toBeDefined();
});
});
describe('custom token usage parser', () => {
it('should use custom token usage parser', async () => {
const customParser = jest.fn().mockReturnValue({
completionTokens: 100,
promptTokens: 50,
totalTokens: 150,
});
const tracer = new N8nLlmTracing(mockExecutionFunctions, {
tokensUsageParser: customParser,
});
const runId = 'run-123';
tracer.runsMap[runId] = {
index: 0,
messages: ['Test'],
options: {},
};
const output: LLMResult = {
generations: [[{ text: 'Response' }]],
llmOutput: { customTokenData: 'test' },
};
await tracer.handleLLMEnd(output, runId);
expect(customParser).toHaveBeenCalledWith(output);
const callArgs = mockExecutionFunctions.addOutputData.mock.calls[0] as any;
const outputData = callArgs[2][0][0].json;
expect(outputData.tokenUsage).toEqual({
completionTokens: 100,
promptTokens: 50,
totalTokens: 150,
});
});
});
describe('runsMap management', () => {
it('should track multiple runs', async () => {
const tracer = new N8nLlmTracing(mockExecutionFunctions);
const llm: Serialized = {
lc: 1,
type: 'constructor',
id: ['test'],
kwargs: {},
};
await tracer.handleLLMStart(llm, ['Prompt 1'], 'run-1');
await tracer.handleLLMStart(llm, ['Prompt 2'], 'run-2');
await tracer.handleLLMStart(llm, ['Prompt 3'], 'run-3');
expect(Object.keys(tracer.runsMap)).toHaveLength(3);
expect(tracer.runsMap['run-1']).toBeDefined();
expect(tracer.runsMap['run-2']).toBeDefined();
expect(tracer.runsMap['run-3']).toBeDefined();
});
});
});
@@ -0,0 +1,270 @@
import type { ServerSentEventMessage } from 'src/utils/sse';
import { parseSSEStream } from 'src/utils/sse';
describe('parseSSEStream', () => {
function createStreamFromChunks(chunks: string[]): AsyncIterableIterator<Buffer | Uint8Array> {
const encoder = new TextEncoder();
return (async function* () {
for (const chunk of chunks) {
yield encoder.encode(chunk);
}
})();
}
// Helper to collect all events from stream
async function collectEvents(
stream: AsyncIterableIterator<Buffer | Uint8Array>,
): Promise<ServerSentEventMessage[]> {
const events: ServerSentEventMessage[] = [];
for await (const event of parseSSEStream(stream)) {
events.push(event);
}
return events;
}
it('should parse simple data-only event', async () => {
const stream = createStreamFromChunks(['data: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
});
it('should parse multiple events', async () => {
const stream = createStreamFromChunks(['data: first\n\ndata: second\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(2);
expect(events[0]).toEqual({ data: 'first' });
expect(events[1]).toEqual({ data: 'second' });
});
it('should parse complete event with all fields', async () => {
const stream = createStreamFromChunks([
'event: update\nid: 42\ndata: test data\nretry: 5000\n\n',
]);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
event: 'update',
id: 42,
data: 'test data',
retry: 5000,
});
});
it('should parse event with string id', async () => {
const stream = createStreamFromChunks(['id: abc-123\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ id: 'abc-123', data: 'hello' });
});
describe('multi-line data', () => {
it('should join multiple data fields with newlines', async () => {
const stream = createStreamFromChunks(['data: line 1\ndata: line 2\ndata: line 3\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'line 1\nline 2\nline 3' });
});
it('should handle empty data fields', async () => {
const stream = createStreamFromChunks(['data: first\ndata:\ndata: third\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'first\n\nthird' });
});
});
it('should handle mixed line endings (LF, CRLF, CR)', async () => {
const stream = createStreamFromChunks(['data: line1\r\ndata: line2\ndata: line3\r\r']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'line1\nline2\nline3' });
});
it('should handle comments and trim leading space', async () => {
const stream = createStreamFromChunks([': comment with spaces\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ comment: 'comment with spaces', data: 'hello' });
});
describe('field value parsing', () => {
it('should remove single leading space after colon', async () => {
const stream = createStreamFromChunks(['data: value with space\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
// First space is removed, subsequent spaces are preserved
expect(events[0]).toEqual({ data: ' value with space' });
});
it('should handle field with no value', async () => {
const stream = createStreamFromChunks(['data\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
// Field with no value results in undefined data per SSE spec
expect(events[0]).toEqual({ data: undefined });
});
it('should handle empty id field (should not set id)', async () => {
const stream = createStreamFromChunks(['id:\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
expect(events[0].id).toBeUndefined();
});
it('should ignore invalid retry values', async () => {
const stream = createStreamFromChunks(['retry: invalid\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
expect(events[0].retry).toBeUndefined();
});
it('should ignore negative retry values', async () => {
const stream = createStreamFromChunks(['retry: -1000\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
expect(events[0].retry).toBeUndefined();
});
it('should ignore unknown fields', async () => {
const stream = createStreamFromChunks(['unknown: field\ndata: hello\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
});
});
it('should handle data split across chunks', async () => {
const stream = createStreamFromChunks(['data: hel', 'lo\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
});
it('should handle multiple events split across chunks', async () => {
const stream = createStreamFromChunks(['data: fir', 'st\n\nda', 'ta: sec', 'ond\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(2);
expect(events[0]).toEqual({ data: 'first' });
expect(events[1]).toEqual({ data: 'second' });
});
it('should handle UTF-8 sequences split across chunks', async () => {
// Split a multi-byte UTF-8 character across chunks
const encoder = new TextEncoder();
const fullText = 'data: 你好\n\n';
const bytes = encoder.encode(fullText);
// Split in the middle of a multi-byte character
const chunk1 = bytes.slice(0, 8);
const chunk2 = bytes.slice(8);
const stream = (async function* () {
yield chunk1;
yield chunk2;
})();
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: '你好' });
});
it('should handle empty stream', async () => {
const stream = createStreamFromChunks([]);
const events = await collectEvents(stream);
expect(events).toHaveLength(0);
});
it('should handle incomplete event at end of stream', async () => {
const stream = createStreamFromChunks(['data: incomplete']);
const events = await collectEvents(stream);
// Incomplete events are flushed at end
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'incomplete' });
});
it('should not yield events with no content and handle empty lines', async () => {
const stream = createStreamFromChunks(['\n\n\n\ndata: hello\n\n\n\n']);
const events = await collectEvents(stream);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ data: 'hello' });
});
describe('real-world scenarios', () => {
it('should parse typical SSE chat stream', async () => {
const stream = createStreamFromChunks([
'event: message\n',
'id: 1\n',
'data: {"text": "Hello"}\n',
'\n',
'event: message\n',
'id: 2\n',
'data: {"text": " world"}\n',
'\n',
]);
const events = await collectEvents(stream);
expect(events).toHaveLength(2);
expect(events[0]).toEqual({ event: 'message', id: 1, data: '{"text": "Hello"}' });
expect(events[1]).toEqual({ event: 'message', id: 2, data: '{"text": " world"}' });
});
it('should parse OpenAI-style streaming', async () => {
const stream = createStreamFromChunks([
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
'data: [DONE]\n\n',
]);
const events = await collectEvents(stream);
expect(events).toHaveLength(3);
expect(events[0]).toEqual({ data: '{"choices":[{"delta":{"content":"Hello"}}]}' });
expect(events[1]).toEqual({ data: '{"choices":[{"delta":{"content":" world"}}]}' });
expect(events[2]).toEqual({ data: '[DONE]' });
});
it('should parse events with metadata and heartbeats', async () => {
const stream = createStreamFromChunks([
': heartbeat\n',
'\n',
'event: status\n',
'data: connected\n',
'\n',
': heartbeat\n',
'\n',
'event: data\n',
'data: actual data\n',
'\n',
]);
const events = await collectEvents(stream);
expect(events).toHaveLength(2);
// First event includes comment from preceding line
expect(events[0]).toEqual({ comment: 'heartbeat', event: 'status', data: 'connected' });
expect(events[1]).toEqual({ comment: 'heartbeat', event: 'data', data: 'actual data' });
});
});
});
@@ -0,0 +1,178 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-require-imports */
import type { TiktokenEncoding } from 'js-tiktoken/lite';
import { Tiktoken } from 'js-tiktoken/lite';
import { getEncoding, encodingForModel } from 'src/utils/tokenizer/tiktoken';
jest.mock('js-tiktoken/lite', () => ({
Tiktoken: jest.fn(),
getEncodingNameForModel: jest.fn(),
}));
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
}));
jest.mock('n8n-workflow', () => ({
jsonParse: jest.fn(),
}));
describe('tiktoken utils', () => {
const mockReadFile = require('fs/promises').readFile;
const mockJsonParse = require('n8n-workflow').jsonParse;
beforeEach(() => {
jest.clearAllMocks();
// Set up mock implementations
mockReadFile.mockImplementation(async (path: string) => {
if (path.includes('cl100k_base.json')) {
return JSON.stringify({ mockCl100kBase: 'data' });
}
if (path.includes('o200k_base.json')) {
return JSON.stringify({ mockO200kBase: 'data' });
}
throw new Error(`Unexpected file path: ${path}`);
});
// eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse
mockJsonParse.mockImplementation((content: string) => JSON.parse(content));
});
describe('getEncoding', () => {
it('should return Tiktoken instance for cl100k_base encoding', async () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = await getEncoding('cl100k_base');
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should return Tiktoken instance for o200k_base encoding', async () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = await getEncoding('o200k_base');
expect(Tiktoken).toHaveBeenCalledWith({ mockO200kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should map p50k_base to cl100k_base encoding', async () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = await getEncoding('p50k_base');
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should map r50k_base to cl100k_base encoding', async () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = await getEncoding('r50k_base');
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should map gpt2 to cl100k_base encoding', async () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = await getEncoding('gpt2');
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should map p50k_edit to cl100k_base encoding', async () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = await getEncoding('p50k_edit');
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should return cl100k_base for unknown encoding', async () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
const result = await getEncoding('unknown_encoding' as unknown as TiktokenEncoding);
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' });
expect(result).toBe(mockTiktoken);
});
it('should use cache for repeated calls with same encoding', async () => {
const mockTiktoken = {};
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
// Clear any previous calls to isolate this test
jest.clearAllMocks();
// Use a unique encoding that hasn't been cached yet
const uniqueEncoding = 'test_encoding' as TiktokenEncoding;
// First call
const result1 = await getEncoding(uniqueEncoding);
expect(Tiktoken).toHaveBeenCalledTimes(1);
expect(Tiktoken).toHaveBeenCalledWith({ mockCl100kBase: 'data' }); // Falls back to cl100k_base
// Second call - should use cache
const result2 = await getEncoding(uniqueEncoding);
expect(Tiktoken).toHaveBeenCalledTimes(1); // Still only called once
expect(result1).toBe(result2);
});
});
describe('encodingForModel', () => {
it('should call getEncodingNameForModel and return encoding for cl100k_base', async () => {
const mockGetEncodingNameForModel = require('js-tiktoken/lite').getEncodingNameForModel;
const mockTiktoken = {};
mockGetEncodingNameForModel.mockReturnValue('cl100k_base');
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
// Clear previous calls since cl100k_base might be cached from previous tests
jest.clearAllMocks();
mockGetEncodingNameForModel.mockReturnValue('cl100k_base');
const result = await encodingForModel('gpt-3.5-turbo');
expect(mockGetEncodingNameForModel).toHaveBeenCalledWith('gpt-3.5-turbo');
// Since cl100k_base was already loaded in previous tests, Tiktoken constructor
// won't be called again due to caching
expect(result).toBeTruthy();
});
it('should handle gpt-4 model with o200k_base', async () => {
const mockGetEncodingNameForModel = require('js-tiktoken/lite').getEncodingNameForModel;
const mockTiktoken = { isO200k: true };
// Use o200k_base to test a different encoding
mockGetEncodingNameForModel.mockReturnValue('o200k_base');
(Tiktoken as unknown as jest.Mock).mockReturnValue(mockTiktoken);
// Clear mocks and set up for this test
jest.clearAllMocks();
mockGetEncodingNameForModel.mockReturnValue('o200k_base');
const result = await encodingForModel('gpt-4');
expect(mockGetEncodingNameForModel).toHaveBeenCalledWith('gpt-4');
// Since o200k_base was already loaded in previous tests, we just verify the result
expect(result).toBeTruthy();
});
});
});
@@ -0,0 +1,248 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import {
estimateTokensByCharCount,
estimateTextSplitsByTokens,
estimateTokensFromStringList,
} from 'src/utils/tokenizer/token-estimator';
describe('token-estimator', () => {
describe('estimateTokensByCharCount', () => {
it('should estimate tokens for text using default model', () => {
const text = 'This is a test text with some content.';
const result = estimateTokensByCharCount(text);
// 38 characters / 4.0 (cl100k_base ratio) = 10 tokens
expect(result).toBe(10);
});
it('should estimate tokens for different models', () => {
const text = 'Test text'; // 9 characters
expect(estimateTokensByCharCount(text, 'gpt-4o')).toBe(3); // 9 / 3.8 = 2.37 -> 3
expect(estimateTokensByCharCount(text, 'gpt-4')).toBe(3); // 9 / 4.0 = 2.25 -> 3
expect(estimateTokensByCharCount(text, 'o200k_base')).toBe(3); // 9 / 3.5 = 2.57 -> 3
expect(estimateTokensByCharCount(text, 'p50k_base')).toBe(3); // 9 / 4.2 = 2.14 -> 3
});
it('should use default ratio for unknown models', () => {
const text = 'Test text with 24 chars.'; // 24 characters
const result = estimateTokensByCharCount(text, 'unknown-model');
expect(result).toBe(6); // 24 / 4.0 = 6
});
it('should handle empty text', () => {
expect(estimateTokensByCharCount('')).toBe(0);
expect(estimateTokensByCharCount('', 'gpt-4')).toBe(0);
});
it('should handle null or undefined text', () => {
expect(estimateTokensByCharCount(null as any)).toBe(0);
expect(estimateTokensByCharCount(undefined as any)).toBe(0);
});
it('should handle non-string input', () => {
expect(estimateTokensByCharCount(123 as any)).toBe(0);
expect(estimateTokensByCharCount({} as any)).toBe(0);
expect(estimateTokensByCharCount([] as any)).toBe(0);
});
it('should handle very long text', () => {
const longText = 'a'.repeat(10000);
const result = estimateTokensByCharCount(longText);
expect(result).toBe(2500); // 10000 / 4.0 = 2500
});
it('should handle invalid model ratios gracefully', () => {
// This would only happen if MODEL_CHAR_PER_TOKEN_RATIOS is corrupted
const text = 'Test text'; // 9 characters
// Since we can't mock the constant, we test with default fallback
const result = estimateTokensByCharCount(text, 'corrupted-model');
expect(result).toBe(3); // Falls back to 4.0 ratio
});
it('should round up token estimates', () => {
expect(estimateTokensByCharCount('a')).toBe(1); // 1 / 4.0 = 0.25 -> 1
expect(estimateTokensByCharCount('ab')).toBe(1); // 2 / 4.0 = 0.5 -> 1
expect(estimateTokensByCharCount('abc')).toBe(1); // 3 / 4.0 = 0.75 -> 1
expect(estimateTokensByCharCount('abcd')).toBe(1); // 4 / 4.0 = 1
expect(estimateTokensByCharCount('abcde')).toBe(2); // 5 / 4.0 = 1.25 -> 2
});
});
describe('estimateTextSplitsByTokens', () => {
it('should split text into chunks based on estimated token size', () => {
const text = 'a'.repeat(400); // 400 characters
const chunks = estimateTextSplitsByTokens(text, 25, 0); // 25 tokens = 100 chars
expect(chunks).toHaveLength(4);
expect(chunks[0]).toHaveLength(100);
expect(chunks[1]).toHaveLength(100);
expect(chunks[2]).toHaveLength(100);
expect(chunks[3]).toHaveLength(100);
});
it('should handle chunk overlap', () => {
const text = 'a'.repeat(200); // 200 characters
const chunks = estimateTextSplitsByTokens(text, 25, 5); // 25 tokens = 100 chars, 5 tokens = 20 chars overlap
expect(chunks).toHaveLength(3);
expect(chunks[0]).toBe('a'.repeat(100)); // First chunk: 0-100
expect(chunks[1]).toBe('a'.repeat(100)); // Second chunk: 80-180 (20 char overlap)
expect(chunks[2]).toBe('a'.repeat(40)); // Third chunk: 160-200
});
it('should handle text shorter than chunk size', () => {
const text = 'Short text';
const chunks = estimateTextSplitsByTokens(text, 100, 0);
expect(chunks).toHaveLength(1);
expect(chunks[0]).toBe(text);
});
it('should handle empty text', () => {
expect(estimateTextSplitsByTokens('', 10, 0)).toEqual([]);
});
it('should handle null or undefined text', () => {
expect(estimateTextSplitsByTokens(null as any, 10, 0)).toEqual([]);
expect(estimateTextSplitsByTokens(undefined as any, 10, 0)).toEqual([]);
});
it('should handle non-string input', () => {
expect(estimateTextSplitsByTokens(123 as any, 10, 0)).toEqual([]);
expect(estimateTextSplitsByTokens({} as any, 10, 0)).toEqual([]);
});
it('should handle invalid chunk size', () => {
const text = 'Test text';
expect(estimateTextSplitsByTokens(text, 0, 0)).toEqual([text]);
expect(estimateTextSplitsByTokens(text, -1, 0)).toEqual([text]);
expect(estimateTextSplitsByTokens(text, NaN, 0)).toEqual([text]);
expect(estimateTextSplitsByTokens(text, Infinity, 0)).toEqual([text]);
});
it('should handle invalid overlap', () => {
const text = 'a'.repeat(200);
// Negative overlap should be treated as 0
const chunks1 = estimateTextSplitsByTokens(text, 25, -10);
expect(chunks1).toHaveLength(2);
// Overlap larger than chunk size should be capped
const chunks2 = estimateTextSplitsByTokens(text, 25, 30); // overlap capped to 24
expect(chunks2.length).toBeGreaterThan(2);
});
it('should ensure progress even with large overlap', () => {
const text = 'a'.repeat(100);
// With overlap = chunkSize - 1, we should still make progress
const chunks = estimateTextSplitsByTokens(text, 10, 9); // 10 tokens = 40 chars, 9 tokens = 36 chars overlap
expect(chunks.length).toBeGreaterThan(1);
// Verify no infinite loop occurs
expect(chunks.length).toBeLessThan(100);
});
it('should work with different models', () => {
const text = 'a'.repeat(380); // 380 characters
const chunks = estimateTextSplitsByTokens(text, 100, 0, 'gpt-4o'); // 100 tokens * 3.8 = 380 chars
expect(chunks).toHaveLength(1);
expect(chunks[0]).toBe(text);
});
it('should use default model ratio for unknown models', () => {
const text = 'a'.repeat(400);
const chunks = estimateTextSplitsByTokens(text, 100, 0, 'unknown-model'); // Falls back to 4.0 ratio
expect(chunks).toHaveLength(1);
expect(chunks[0]).toBe(text);
});
it('should handle edge case where text length equals chunk size', () => {
const text = 'a'.repeat(100);
const chunks = estimateTextSplitsByTokens(text, 25, 0); // 25 tokens = 100 chars
expect(chunks).toHaveLength(1);
expect(chunks[0]).toBe(text);
});
it('should handle unicode text', () => {
const text = '你好世界'.repeat(25); // 100 characters (4 chars * 25)
const chunks = estimateTextSplitsByTokens(text, 25, 0);
expect(chunks.length).toBeGreaterThan(0);
expect(chunks.join('')).toBe(text);
});
it('should return single chunk on any error in catch block', () => {
const text = 'Test text';
// Since we can't easily trigger the catch block, we test the expected behavior
// The function should return [text] on error
const result = estimateTextSplitsByTokens(text, 10, 0);
expect(result.length).toBeGreaterThan(0);
});
});
describe('estimateTokensFromStringList', () => {
// Since this function uses tiktoken which requires external data files,
// we'll test it with integration-style tests that don't require mocking
it('should handle empty list', async () => {
const result = await estimateTokensFromStringList([], 'gpt-4');
expect(result).toBe(0);
});
it('should handle non-array input', async () => {
const result = await estimateTokensFromStringList(null as any, 'gpt-4');
expect(result).toBe(0);
const result2 = await estimateTokensFromStringList('not an array' as any, 'gpt-4');
expect(result2).toBe(0);
});
it('should handle null/undefined items in list', async () => {
const list = ['Valid text', null, undefined, '', 123 as any];
const result = await estimateTokensFromStringList(list, 'gpt-4');
expect(result).toEqual(2);
});
it('should estimate tokens for normal text', async () => {
const list = ['Hello world', 'Test text'];
const result = await estimateTokensFromStringList(list, 'gpt-4');
expect(result).toBeGreaterThan(0);
});
it('should use character-based estimation for repetitive content', async () => {
const list = ['a'.repeat(1500)];
const result = await estimateTokensFromStringList(list, 'gpt-4');
expect(result).toBe(375); // 1500 chars / 4.0 = 375 tokens
});
it('should handle mixed content', async () => {
const list = ['Normal text content', 'a'.repeat(1500), 'More normal text'];
const result = await estimateTokensFromStringList(list, 'gpt-4');
expect(result).toBeGreaterThan(375); // At least the repetitive content tokens
});
it('should work with different models', async () => {
const list = ['Test text for different model'];
const result1 = await estimateTokensFromStringList(list, 'gpt-4');
const result2 = await estimateTokensFromStringList(list, 'gpt-4o');
// Both should return positive values
expect(result1).toBeGreaterThan(0);
expect(result2).toBeGreaterThan(0);
});
it('should handle very long lists', async () => {
const list = Array(10000).fill('Sample text');
const result = await estimateTokensFromStringList(list, 'gpt-4');
expect(result).toBeGreaterThan(0);
});
it('should handle unicode text', async () => {
const list = ['你好世界', '🌍🌎🌏', 'مرحبا بالعالم'];
const result = await estimateTokensFromStringList(list, 'gpt-4');
expect(result).toBeGreaterThan(0);
});
});
});
@@ -0,0 +1,230 @@
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
import type { BaseLanguageModelInput } from '@langchain/core/language_models/base';
import type { BindToolsInput } from '@langchain/core/language_models/chat_models';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { BaseMessage, ContentBlock } from '@langchain/core/messages';
import { AIMessage, AIMessageChunk } from '@langchain/core/messages';
import type { ChatResult, LLMResult } from '@langchain/core/outputs';
import { ChatGenerationChunk } from '@langchain/core/outputs';
import type { Runnable } from '@langchain/core/runnables';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { fromLcMessage, toLcMessage } from '../converters/message';
import { fromLcTool } from '../converters/tool';
import type { ChatModel, ChatModelConfig } from '../types/chat-model';
import { makeN8nLlmFailedAttemptHandler } from '../utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
import { N8nLlmTracing } from '../utils/n8n-llm-tracing';
export class LangchainChatModelAdapter<
CallOptions extends ChatModelConfig = ChatModelConfig,
> extends BaseChatModel<CallOptions> {
constructor(
private chatModel: ChatModel,
private ctx?: ISupplyDataFunctions,
) {
const params = {
...(ctx
? {
callbacks: [
new N8nLlmTracing(ctx, {
tokensUsageParser: (result: LLMResult) => {
const tokenUsage = result?.llmOutput?.tokenUsage as
| AIMessage['usage_metadata']
| undefined;
const completionTokens = (tokenUsage?.output_tokens as number) ?? 0;
const promptTokens = (tokenUsage?.input_tokens as number) ?? 0;
return {
completionTokens,
promptTokens,
totalTokens: completionTokens + promptTokens,
};
},
}),
],
onFailedAttempt: makeN8nLlmFailedAttemptHandler(ctx),
}
: {}),
};
super(params);
}
_llmType(): string {
return 'n8n-chat-model';
}
async _generate(
messages: BaseMessage[],
options: this['ParsedCallOptions'],
): Promise<ChatResult> {
const transformedMessages = messages.map(fromLcMessage);
const result = await this.chatModel.generate(transformedMessages, options);
// Build content blocks for the message
const lcMessage = toLcMessage(result.message);
// Build usage metadata
const usage_metadata = result.usage
? {
input_tokens: result.usage.promptTokens ?? 0,
output_tokens: result.usage.completionTokens ?? 0,
total_tokens: result.usage.totalTokens ?? 0,
input_token_details: result.usage.inputTokenDetails
? {
cache_read: result.usage.inputTokenDetails.cacheRead,
}
: undefined,
output_token_details: result.usage.outputTokenDetails
? {
reasoning: result.usage.outputTokenDetails.reasoning,
}
: undefined,
}
: undefined;
if (AIMessage.isInstance(lcMessage)) {
lcMessage.usage_metadata = usage_metadata;
}
lcMessage.response_metadata = {
...result.providerMetadata,
model: this.chatModel.modelId,
provider: this.chatModel.provider,
};
return {
generations: [
{
text: lcMessage.text,
message: lcMessage,
},
],
llmOutput: {
id: result.id,
tokenUsage: usage_metadata,
},
};
}
async *_streamResponseChunks(
messages: BaseMessage[],
options: this['ParsedCallOptions'],
runManager?: CallbackManagerForLLMRun,
): AsyncGenerator<ChatGenerationChunk> {
const genericMessages = messages.map(fromLcMessage);
const stream = this.chatModel.stream(genericMessages, options);
for await (const chunk of stream) {
let lcChunk: ChatGenerationChunk | undefined = undefined;
if (chunk.type === 'text-delta') {
const content: ContentBlock[] = [
{
type: 'text',
text: chunk.delta,
},
];
lcChunk = new ChatGenerationChunk({
message: new AIMessageChunk({
content,
}),
text: chunk.delta,
});
} else if (chunk.type === 'tool-call-delta') {
const tool_call_chunks = [
{
type: 'tool_call_chunk' as const,
id: chunk.id,
name: chunk.name,
args: chunk.argumentsDelta,
index: 0,
},
];
lcChunk = new ChatGenerationChunk({
message: new AIMessageChunk({
content: '',
tool_call_chunks,
}),
text: '',
});
} else if (chunk.type === 'finish') {
const usage_metadata = chunk.usage
? {
input_tokens: chunk.usage.promptTokens ?? 0,
output_tokens: chunk.usage.completionTokens ?? 0,
total_tokens: chunk.usage.totalTokens ?? 0,
}
: undefined;
lcChunk = new ChatGenerationChunk({
message: new AIMessageChunk({
content: '',
usage_metadata,
response_metadata: {
finish_reason: chunk.finishReason,
},
}),
text: '',
generationInfo: {
finish_reason: chunk.finishReason,
},
});
} else if (chunk.type === 'error') {
lcChunk = new ChatGenerationChunk({
message: new AIMessageChunk({
content: '',
response_metadata: {
finish_reason: 'error',
error: chunk.error,
},
}),
text: '',
generationInfo: {
finish_reason: 'error',
error: chunk.error,
},
});
} else if (chunk.type === 'content') {
const lcMessage = toLcMessage({
role: 'assistant',
content: [chunk.content],
id: chunk.id,
});
const lcMessageChunk = new AIMessageChunk({
content: lcMessage.content,
id: lcMessage.id,
name: lcMessage.name,
});
lcChunk = new ChatGenerationChunk({
message: lcMessageChunk,
text: lcMessage.text,
});
}
if (lcChunk) {
yield lcChunk;
await runManager?.handleLLMNewToken(
lcChunk.text ?? '',
{
prompt: 0,
completion: 0,
},
undefined,
undefined,
undefined,
{ chunk: lcChunk },
);
}
}
}
bindTools(
tools: BindToolsInput[],
): Runnable<BaseLanguageModelInput, AIMessageChunk, CallOptions> {
const genericTools = tools.map(fromLcTool);
const newModel = this.chatModel.withTools(genericTools);
const newAdapter = new LangchainChatModelAdapter(newModel, this.ctx);
return newAdapter as any;
}
}
@@ -0,0 +1,30 @@
import { BaseListChatMessageHistory } from '@langchain/core/chat_history';
import type { BaseMessage } from '@langchain/core/messages';
import { fromLcMessage, toLcMessage } from '../converters/message';
import type { ChatHistory } from '../types/memory';
export class LangchainHistoryAdapter extends BaseListChatMessageHistory {
lc_namespace = ['n8n', 'ai-utilities'];
constructor(private readonly history: ChatHistory) {
super();
}
async getMessages(): Promise<BaseMessage[]> {
const messages = await this.history.getMessages();
return messages.map(toLcMessage);
}
async addMessage(message: BaseMessage): Promise<void> {
await this.history.addMessage(fromLcMessage(message));
}
async addMessages(messages: BaseMessage[]): Promise<void> {
await this.history.addMessages(messages.map(fromLcMessage));
}
async clear(): Promise<void> {
await this.history.clear();
}
}
@@ -0,0 +1,38 @@
import { BaseChatMemory as LangchainBaseChatMemory } from '@langchain/community/memory/chat_memory';
import type { InputValues, MemoryVariables, OutputValues } from '@langchain/core/memory';
import { LangchainHistoryAdapter } from './langchain-history';
import { toLcMessage } from '../converters/message';
import type { ChatMemory } from '../types/memory';
export class LangchainMemoryAdapter extends LangchainBaseChatMemory {
constructor(private readonly memory: ChatMemory) {
super({
chatHistory: new LangchainHistoryAdapter(memory.chatHistory),
returnMessages: true,
inputKey: 'input',
outputKey: 'output',
});
}
get memoryKeys(): string[] {
return ['chat_history'];
}
async loadMemoryVariables(_values: InputValues): Promise<MemoryVariables> {
const messages = await this.memory.loadMessages();
return {
chat_history: messages.map(toLcMessage),
};
}
async saveContext(inputValues: InputValues, outputValues: OutputValues): Promise<void> {
const input = String(inputValues.input ?? '');
const output = String(outputValues.output ?? '');
await this.memory.saveTurn(input, output);
}
async clear(): Promise<void> {
await this.memory.clear();
}
}
@@ -0,0 +1,3 @@
// Controls which SDK version is supported by the current n8n
// Check README.md for explanation
export const AI_NODE_SDK_VERSION: number = 1;
@@ -0,0 +1,41 @@
import type { ChatModel, ChatModelConfig } from 'src/types/chat-model';
import type { Message } from 'src/types/message';
import type { GenerateResult, StreamChunk } from 'src/types/output';
import type { Tool } from 'src/types/tool';
export abstract class BaseChatModel<TConfig extends ChatModelConfig = ChatModelConfig>
implements ChatModel<TConfig>
{
constructor(
public provider: string,
public modelId: string,
public defaultConfig?: TConfig,
protected tools: Tool[] = [],
) {}
abstract generate(messages: Message[], config?: TConfig): Promise<GenerateResult>;
abstract stream(messages: Message[], config?: TConfig): AsyncIterable<StreamChunk>;
/**
* Bind tools to the model. Returns a new instance with tools attached.
* Subclasses should override this to return their own type if needed.
*/
withTools(tools: Tool[]): ChatModel<TConfig> {
// Create a shallow copy with new tools
const newInstance = Object.create(Object.getPrototypeOf(this) as object);
Object.assign(newInstance, this);
newInstance.tools = [...this.tools, ...tools];
return newInstance;
}
/**
* Merge configuration with defaults
*/
protected mergeConfig(config?: TConfig): ChatModelConfig {
return {
...this.defaultConfig,
...config,
};
}
}
@@ -0,0 +1,376 @@
import * as LangchainMessages from '@langchain/core/messages';
import { jsonParse } from 'n8n-workflow';
import type * as N8nMessages from '../types/message';
import type { Message } from '../types/message';
function isN8nTextBlock(block: N8nMessages.MessageContent): block is N8nMessages.ContentText {
return block.type === 'text';
}
function isN8nReasoningBlock(
block: N8nMessages.MessageContent,
): block is N8nMessages.ContentReasoning {
return block.type === 'reasoning';
}
function isN8nFileBlock(block: N8nMessages.MessageContent): block is N8nMessages.ContentFile {
return block.type === 'file';
}
function isN8nToolCallBlock(
block: N8nMessages.MessageContent,
): block is N8nMessages.ContentToolCall {
return block.type === 'tool-call';
}
function isN8nInvalidToolCallBlock(
block: N8nMessages.MessageContent,
): block is N8nMessages.ContentInvalidToolCall {
return block.type === 'invalid-tool-call';
}
function isN8nToolResultBlock(
block: N8nMessages.MessageContent,
): block is N8nMessages.ContentToolResult {
return block.type === 'tool-result';
}
function isN8nCitationBlock(
block: N8nMessages.MessageContent,
): block is N8nMessages.ContentCitation {
return block.type === 'citation';
}
function isN8nProviderBlock(
block: N8nMessages.MessageContent,
): block is N8nMessages.ContentProvider {
return block.type === 'provider';
}
function fromLcRole(role: LangchainMessages.MessageType): N8nMessages.MessageRole {
switch (role) {
case 'system':
return 'system';
case 'user':
return 'user';
case 'assistant':
return 'assistant';
case 'tool':
return 'tool';
default:
return 'user';
}
}
function isTextBlock(
block: LangchainMessages.ContentBlock,
): block is LangchainMessages.ContentBlock.Text {
return block.type === 'text';
}
function isReasoningBlock(
block: LangchainMessages.ContentBlock,
): block is LangchainMessages.ContentBlock.Reasoning {
return block.type === 'reasoning';
}
function isFileBlock(
block: LangchainMessages.ContentBlock,
): block is LangchainMessages.ContentBlock.Multimodal.Standard {
return (
block.type === 'file' ||
block.type === 'audio' ||
block.type === 'video' ||
block.type === 'image' ||
block.type === 'text-plain'
);
}
function isToolCallBlock(
block: LangchainMessages.ContentBlock,
): block is LangchainMessages.ContentBlock.Tools.ToolCall {
return block.type === 'tool_call';
}
function isInvalidToolCallBlock(
block: LangchainMessages.ContentBlock,
): block is LangchainMessages.ContentBlock.Tools.InvalidToolCall {
return block.type === 'invalid_tool_call';
}
function isToolResultBlock(
block: LangchainMessages.ContentBlock,
): block is LangchainMessages.ContentBlock.Tools.ServerToolCallResult {
return block.type === 'server_tool_call_result';
}
function isCitationBlock(block: unknown): block is LangchainMessages.ContentBlock.Citation {
return (
typeof block === 'object' && block !== null && 'type' in block && block.type === 'citation'
);
}
function isNonStandardBlock(
block: LangchainMessages.ContentBlock,
): block is LangchainMessages.ContentBlock.NonStandard {
return block.type === 'non_standard';
}
export function fromLcContent(
content: string | LangchainMessages.ContentBlock | LangchainMessages.ContentBlock[],
): N8nMessages.MessageContent[] {
if (typeof content === 'string') {
return [
{
type: 'text',
text: content,
},
];
}
const blocks = Array.isArray(content) ? content : [content];
return blocks
.map((block) => {
let content: N8nMessages.MessageContent | null = null;
if (isTextBlock(block)) {
content = {
type: 'text',
text: block.text,
};
} else if (isReasoningBlock(block)) {
content = {
type: 'reasoning',
text: block.reasoning,
};
} else if (isFileBlock(block)) {
let metadata: Record<string, unknown> = {};
if (block.metadata) {
metadata = block.metadata;
}
if ('url' in block) {
metadata.url = block.url;
}
if ('fileId' in block) {
metadata.fileId = block.fileId;
}
content = {
type: 'file',
mediaType: block.mimeType!,
data: block.data!,
providerMetadata: Object.keys(metadata).length > 0 ? metadata : undefined,
};
} else if (isToolCallBlock(block)) {
content = {
type: 'tool-call',
toolCallId: block.id,
toolName: block.name,
input: JSON.stringify(block.args),
};
} else if (isInvalidToolCallBlock(block)) {
content = {
type: 'invalid-tool-call',
toolCallId: block.id,
error: block.error,
args: block.args,
name: block.name,
};
} else if (isToolResultBlock(block)) {
content = {
type: 'tool-result',
toolCallId: block.toolCallId,
result: block.output,
isError: block.status === 'error',
};
} else if (isCitationBlock(block)) {
content = {
type: 'citation',
source: block.source,
url: block.url,
title: block.title,
startIndex: block.startIndex,
endIndex: block.endIndex,
text: block.citedText,
};
} else if (isNonStandardBlock(block)) {
content = {
type: 'provider',
value: block.value,
};
}
return content;
})
.filter((content): content is N8nMessages.MessageContent => content !== null);
}
export function fromLcMessage(msg: LangchainMessages.BaseMessage): N8nMessages.Message {
if (LangchainMessages.ToolMessage.isInstance(msg)) {
const result = typeof msg.content === 'string' ? msg.content : fromLcContent(msg.content);
return {
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: msg.tool_call_id,
result,
isError: msg.status === 'error',
providerMetadata: msg.metadata,
},
],
id: msg.id,
name: msg.name,
};
}
if (LangchainMessages.AIMessage.isInstance(msg)) {
const content = fromLcContent(msg.content);
const toolsCalls = msg.tool_calls;
if (toolsCalls?.length) {
const mappedToolsCalls = toolsCalls.map<N8nMessages.ContentToolCall>((toolCall) => ({
type: 'tool-call',
toolCallId: toolCall.id,
toolName: toolCall.name,
input: JSON.stringify(toolCall.args),
providerMetadata: msg.response_metadata,
}));
content.push(...mappedToolsCalls);
}
return {
role: 'assistant',
content,
id: msg.id,
name: msg.name,
};
}
if (LangchainMessages.SystemMessage.isInstance(msg)) {
return {
role: 'system',
content: fromLcContent(msg.content),
id: msg.id,
name: msg.name,
};
}
if (LangchainMessages.HumanMessage.isInstance(msg)) {
return {
role: 'user',
content: fromLcContent(msg.content),
id: msg.id,
name: msg.name,
};
}
if (LangchainMessages.BaseMessage.isInstance(msg)) {
return {
role: fromLcRole(msg.type),
content: fromLcContent(msg.content),
id: msg.id,
name: msg.name,
};
}
throw new Error(`Provided message is not a valid Langchain message: ${JSON.stringify(msg)}`);
}
export function toLcContent(block: N8nMessages.MessageContent): LangchainMessages.ContentBlock {
if (isN8nTextBlock(block)) {
return { type: 'text', text: block.text };
}
if (isN8nReasoningBlock(block)) {
return { type: 'reasoning', reasoning: block.text };
}
if (isN8nFileBlock(block)) {
const { url, fileId, ...rest } = block.providerMetadata ?? {};
return {
type: 'file',
mimeType: block.mediaType ?? 'application/octet-stream',
data: block.data,
...(url ? { url } : {}),
...(fileId ? { fileId } : {}),
...(Object.keys(rest).length > 0 ? { metadata: rest } : {}),
} as LangchainMessages.ContentBlock.Multimodal.Standard;
}
if (isN8nToolCallBlock(block)) {
return {
type: 'tool_call',
id: block.toolCallId,
name: block.toolName,
args: jsonParse<Record<string, unknown>>(block.input, { fallbackValue: {} }),
} as LangchainMessages.ContentBlock.Tools.ToolCall;
}
if (isN8nInvalidToolCallBlock(block)) {
return {
type: 'invalid_tool_call',
id: block.toolCallId,
error: block.error,
args: block.args,
name: block.name,
} as LangchainMessages.ContentBlock.Tools.InvalidToolCall;
}
if (isN8nToolResultBlock(block)) {
return {
type: 'server_tool_call_result',
toolCallId: block.toolCallId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
output: block.result,
status: block.isError ? 'error' : 'success',
} as unknown as LangchainMessages.ContentBlock.Tools.ServerToolCallResult;
}
if (isN8nCitationBlock(block)) {
return {
type: 'citation',
source: block.source,
url: block.url,
title: block.title,
startIndex: block.startIndex,
endIndex: block.endIndex,
citedText: block.text,
} as unknown as LangchainMessages.ContentBlock;
}
if (isN8nProviderBlock(block)) {
return {
type: 'non_standard',
value: block.value,
} as LangchainMessages.ContentBlock.NonStandard;
}
throw new Error(`Failed to convert to Langchain content block: ${JSON.stringify(block)}`);
}
export function toLcMessage(message: Message): LangchainMessages.BaseMessage {
const lcContent = message.content.map(toLcContent);
switch (message.role) {
case 'system':
return new LangchainMessages.SystemMessage({
content: lcContent,
id: message.id,
name: message.name,
});
case 'user':
return new LangchainMessages.HumanMessage({
content: lcContent,
id: message.id,
name: message.name,
});
case 'assistant': {
const toolCalls: LangchainMessages.ToolCall[] = message.content
.filter(isN8nToolCallBlock)
.map((c) => ({
type: 'tool_call',
id: c.toolCallId,
name: c.toolName,
args: jsonParse<Record<string, unknown>>(c.input, { fallbackValue: {} }),
}));
const nonToolContent = lcContent.filter((c) => c.type !== 'tool_call');
return new LangchainMessages.AIMessage({
content: nonToolContent,
id: message.id,
name: message.name,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
});
}
case 'tool': {
const toolResult = message.content.find(isN8nToolResultBlock);
if (!toolResult) {
throw new Error('Tool message is missing a tool-result content block');
}
const content =
typeof toolResult.result === 'string'
? toolResult.result
: JSON.stringify(toolResult.result);
return new LangchainMessages.ToolMessage({
content,
tool_call_id: toolResult.toolCallId,
name: message.name,
status: toolResult.isError ? 'error' : 'success',
});
}
default:
return new LangchainMessages.HumanMessage({
content: lcContent,
id: message.id,
name: message.name,
});
}
}
@@ -0,0 +1,63 @@
import type { FunctionDefinition } from '@langchain/core/language_models/base';
import type * as LangchainChatModels from '@langchain/core/language_models/chat_models';
import type * as LangchainTools from '@langchain/core/tools';
import type { JSONSchema7 } from 'json-schema';
import { ZodSchema, type ZodTypeAny } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import type * as N8nTools from '../types/tool';
/**
* Convert various tool formats to N8nTool
*/
export function fromLcTool(tool: LangchainChatModels.BindToolsInput): N8nTools.Tool {
if ('schema' in tool && 'invoke' in tool) {
const structuredTool = tool as LangchainTools.StructuredTool;
return {
type: 'function',
name: structuredTool.name,
description: structuredTool.description,
inputSchema: structuredTool.schema as JSONSchema7 | ZodTypeAny,
};
}
if ('schema' in tool && 'func' in tool) {
const structuredTool = tool as LangchainTools.DynamicStructuredTool;
return {
type: 'function',
name: structuredTool.name,
description: structuredTool.description,
inputSchema: structuredTool.schema as JSONSchema7 | ZodTypeAny,
};
}
if ('name' in tool && 'schema' in tool) {
const structuredTool = tool as LangchainTools.StructuredTool;
return {
type: 'function',
name: structuredTool.name,
description: structuredTool.description,
inputSchema: structuredTool.schema as JSONSchema7 | ZodTypeAny,
};
}
if ('function' in tool && 'type' in tool && tool.type === 'function') {
const functionTool = tool.function as FunctionDefinition;
return {
type: 'function',
name: functionTool.name,
description: functionTool.description,
inputSchema: functionTool.parameters as JSONSchema7,
};
}
throw new Error(`Unable to convert tool to N8nTool: ${JSON.stringify(tool)}`);
}
export function getParametersJsonSchema(tool: N8nTools.FunctionTool): JSONSchema7 {
const schema = tool.inputSchema;
if (schema instanceof ZodSchema) {
if ('toJSONSchema' in schema && typeof schema.toJSONSchema === 'function') {
return schema.toJSONSchema();
}
return zodToJsonSchema(schema) as JSONSchema7;
}
return schema;
}
+35
View File
@@ -0,0 +1,35 @@
import type { BaseChatMemory } from '@langchain/classic/memory';
import type { BaseChatMessageHistory } from '@langchain/core/chat_history';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { BaseLLM } from '@langchain/core/language_models/llms';
import type { Tool } from '@langchain/core/tools';
function hasMethods<T>(obj: unknown, ...methodNames: Array<string | symbol>): obj is T {
return methodNames.every(
(methodName) =>
typeof obj === 'object' &&
obj !== null &&
methodName in obj &&
typeof (obj as Record<string | symbol, unknown>)[methodName] === 'function',
);
}
export function isBaseChatMemory(obj: unknown) {
return hasMethods<BaseChatMemory>(obj, 'loadMemoryVariables', 'saveContext');
}
export function isBaseChatMessageHistory(obj: unknown) {
return hasMethods<BaseChatMessageHistory>(obj, 'getMessages', 'addMessage');
}
export function isChatInstance(model: unknown): model is BaseChatModel {
const namespace = (model as BaseLLM)?.lc_namespace ?? [];
return namespace.includes('chat_models');
}
export function isToolsInstance(model: unknown): model is Tool {
const namespace = (model as Tool)?.lc_namespace ?? [];
return namespace.includes('tools');
}
+96
View File
@@ -0,0 +1,96 @@
// AI Node SDK version
export { AI_NODE_SDK_VERSION } from './ai-node-sdk-version';
// Utils
export { logWrapper } from './utils/log-wrapper';
export { logAiEvent } from './utils/log-ai-event';
export { parseSSEStream } from './utils/sse';
export {
validateEmbedQueryInput,
validateEmbedDocumentsInput,
} from './utils/embeddings-input-validation';
export { getMetadataFiltersValues, hasLongSequentialRepeat } from './utils/helpers';
export { N8nBinaryLoader } from './utils/n8n-binary-loader';
export { N8nJsonLoader } from './utils/n8n-json-loader';
export { N8nLlmTracing } from './utils/n8n-llm-tracing';
export {
estimateTokensFromStringList,
estimateTokensByCharCount,
estimateTextSplitsByTokens,
} from './utils/tokenizer/token-estimator';
export { encodingForModel, getEncoding } from './utils/tokenizer/tiktoken';
export { makeN8nLlmFailedAttemptHandler } from './utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
export {
getProxyAgent,
getNodeProxyAgent,
proxyFetch,
type AgentTimeoutOptions,
} from './utils/http-proxy-agent';
export {
getConnectionHintNoticeField,
metadataFilterField,
getBatchingOptionFields,
getTemplateNoticeField,
} from './utils/shared-fields';
export {
createToolFromNode,
createZodSchemaFromArgs,
extractFromAIParameters,
} from './utils/fromai-tool-factory';
export { createVectorStoreNode } from './utils/vector-store/createVectorStoreNode/createVectorStoreNode';
export type {
VectorStoreNodeConstructorArgs,
NodeOperationMode,
NodeMeta,
} from './utils/vector-store/createVectorStoreNode/types';
export { MemoryVectorStoreManager } from './utils/vector-store/MemoryManager/MemoryVectorStoreManager';
export {
processDocuments,
processDocument,
} from './utils/vector-store/processDocuments';
export type { ServerSentEventMessage } from './utils/sse';
// Converters
export { getParametersJsonSchema } from './converters/tool';
export { fromLcMessage, toLcMessage, toLcContent, fromLcContent } from './converters/message';
// Type guards
export {
isBaseChatMemory,
isBaseChatMessageHistory,
isChatInstance,
isToolsInstance,
} from './guards';
// Types
export type { ChatModel, ChatModelConfig } from './types/chat-model';
export type { ChatHistory, ChatMemory } from './types/memory';
export type { GenerateResult, StreamChunk, TokenUsage, FinishReason } from './types/output';
export type { Tool, ToolResult, ToolCall, ProviderTool } from './types/tool';
export type {
Message,
ContentFile,
ContentMetadata,
ContentReasoning,
ContentText,
ContentToolCall,
ContentToolResult,
MessageContent,
MessageRole,
} from './types/message';
export type { JSONArray, JSONObject, JSONValue } from './types/json';
// Chat model classes
export { LangchainChatModelAdapter } from './adapters/langchain-chat-model';
export { BaseChatModel } from './chat-model/base';
// Memory base classes
export { BaseChatHistory } from './memory/base-chat-history';
export { BaseChatMemory } from './memory/base-chat-memory';
// Memory implementations
export { WindowedChatMemory, type WindowedChatMemoryConfig } from './memory/windowed-chat-memory';
// Suppliers
export { supplyMemory, type SupplyMemoryOptions } from './suppliers/supplyMemory';
export { supplyModel, type SupplyModelOptions, type OpenAiModel } from './suppliers/supplyModel';
@@ -0,0 +1,16 @@
import type { ChatHistory } from '../types/memory';
import type { Message } from '../types/message';
export abstract class BaseChatHistory implements ChatHistory {
abstract getMessages(): Promise<Message[]>;
abstract addMessage(message: Message): Promise<void>;
async addMessages(messages: Message[]): Promise<void> {
for (const msg of messages) {
await this.addMessage(msg);
}
}
abstract clear(): Promise<void>;
}
@@ -0,0 +1,12 @@
import type { ChatHistory, ChatMemory } from '../types/memory';
import type { Message } from '../types/message';
export abstract class BaseChatMemory implements ChatMemory {
abstract readonly chatHistory: ChatHistory;
abstract loadMessages(): Promise<Message[]>;
abstract saveTurn(input: string, output: string): Promise<void>;
abstract clear(): Promise<void>;
}
@@ -0,0 +1,53 @@
import { BaseChatMemory } from './base-chat-memory';
import type { ChatHistory } from '../types/memory';
import type { Message } from '../types/message';
export interface WindowedChatMemoryConfig {
windowSize?: number;
}
/** Keeps only the last N message pairs in context. */
export class WindowedChatMemory extends BaseChatMemory {
readonly chatHistory: ChatHistory;
private readonly windowSize: number;
constructor(chatHistory: ChatHistory, config?: WindowedChatMemoryConfig) {
super();
this.chatHistory = chatHistory;
this.windowSize = config?.windowSize ?? 10;
}
async loadMessages(): Promise<Message[]> {
const allMessages = await this.chatHistory.getMessages();
if (allMessages.length === 0) {
return [];
}
const maxMessages = this.windowSize * 2;
if (allMessages.length <= maxMessages) {
return allMessages;
}
return allMessages.slice(-maxMessages);
}
async saveTurn(input: string, output: string): Promise<void> {
const humanMessage: Message = {
role: 'user',
content: [{ type: 'text', text: input }],
};
const aiMessage: Message = {
role: 'assistant',
content: [{ type: 'text', text: output }],
};
await this.chatHistory.addMessages([humanMessage, aiMessage]);
}
async clear(): Promise<void> {
await this.chatHistory.clear();
}
}
@@ -0,0 +1,23 @@
import type { ISupplyDataFunctions, SupplyData } from 'n8n-workflow';
import { LangchainMemoryAdapter } from '../adapters/langchain-memory';
import type { ChatMemory } from '../types/memory';
import { logWrapper } from '../utils/log-wrapper';
export interface SupplyMemoryOptions {
closeFunction?: () => Promise<void>;
}
export function supplyMemory(
context: ISupplyDataFunctions,
memory: ChatMemory,
options?: SupplyMemoryOptions,
): SupplyData {
const adapter = new LangchainMemoryAdapter(memory);
const wrappedAdapter = logWrapper(adapter, context);
return {
response: wrappedAdapter,
closeFunction: options?.closeFunction,
};
}
@@ -0,0 +1,96 @@
import type { ServerTool } from '@langchain/core/tools';
import { ChatOpenAI, type ClientOptions } from '@langchain/openai';
import type { ISupplyDataFunctions, SupplyData } from 'n8n-workflow';
import { LangchainChatModelAdapter } from '../adapters/langchain-chat-model';
import { BaseChatModel } from '../chat-model/base';
import type { ChatModel } from '../types/chat-model';
import type { OpenAIModelOptions } from '../types/openai';
import { makeN8nLlmFailedAttemptHandler } from '../utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
import { getProxyAgent } from '../utils/http-proxy-agent';
import { N8nLlmTracing } from '../utils/n8n-llm-tracing';
export type OpenAiModel = OpenAIModelOptions & {
type: 'openai';
};
export type SupplyModelOptions = ChatModel | OpenAiModel;
function isOpenAiModel(model: SupplyModelOptions): model is OpenAiModel {
return 'type' in model && model.type === 'openai' && !(model instanceof BaseChatModel);
}
function getOpenAiModel(ctx: ISupplyDataFunctions, model: OpenAiModel) {
const clientConfiguration: ClientOptions = {
baseURL: model.baseUrl,
};
if (model.defaultHeaders) {
clientConfiguration.defaultHeaders = model.defaultHeaders;
}
const timeout = model.timeout;
clientConfiguration.fetchOptions = {
dispatcher: getProxyAgent(model.baseUrl, {
headersTimeout: timeout,
bodyTimeout: timeout,
}),
};
const openAiModel = new ChatOpenAI({
configuration: clientConfiguration,
model: model.model,
apiKey: model.apiKey,
useResponsesApi: model.useResponsesApi,
logprobs: model.logprobs,
topLogprobs: model.topLogprobs,
supportsStrictToolCalling: model.supportsStrictToolCalling,
reasoning: model.reasoning,
zdrEnabled: model.zdrEnabled,
service_tier: model.service_tier,
promptCacheKey: model.promptCacheKey,
temperature: model.temperature,
topP: model.topP,
frequencyPenalty: model.frequencyPenalty,
presencePenalty: model.presencePenalty,
stopSequences: model.stopSequences,
maxRetries: model.maxRetries,
modelKwargs: model.additionalParams,
verbosity: model.verbosity,
streaming: model.streaming,
streamUsage: model.streamUsage,
stop: model.stop,
maxTokens: model.maxTokens,
maxCompletionTokens: model.maxCompletionTokens,
timeout: model.timeout,
callbacks: [new N8nLlmTracing(ctx)],
onFailedAttempt: makeN8nLlmFailedAttemptHandler(ctx, model.onFailedAttempt),
});
if (model.providerTools?.length) {
openAiModel.metadata = {
...openAiModel.metadata,
// Tools in metadata are read by ToolAgent and added to a list of all agent tools.
tools: model.providerTools.map<ServerTool>((tool) => ({
// openai format requires type to be the name of the tool
// langchain simply passes the tool object to openai as is
type: tool.name,
...tool.args,
})),
};
}
return openAiModel;
}
export function supplyModel(ctx: ISupplyDataFunctions, model: SupplyModelOptions): SupplyData {
if (isOpenAiModel(model)) {
const openAiModel = getOpenAiModel(ctx, model);
return {
response: openAiModel,
};
}
const adapter = new LangchainChatModelAdapter(model, ctx);
return {
response: adapter,
};
}
@@ -0,0 +1,102 @@
import type { Message } from './message';
import type { GenerateResult, StreamChunk } from './output';
import type { Tool } from './tool';
export interface ChatModelConfig {
/**
* Maximum number of tokens to generate
*/
maxTokens?: number;
/**
* Temperature setting for randomness (typically 0-2)
*/
temperature?: number;
/**
* Nucleus sampling - probability mass to consider (0-1)
*/
topP?: number;
/**
* Top-K sampling - number of top tokens to consider
*/
topK?: number;
/**
* Presence penalty to reduce repetition of information (-1 to 1)
*/
presencePenalty?: number;
/**
* Frequency penalty to reduce repetition of words/phrases (-1 to 1)
*/
frequencyPenalty?: number;
/**
* Stop sequences to halt generation
*/
stopSequences?: string[];
/**
* Seed for deterministic generation
*/
seed?: number;
/**
* Maximum number of retries on failure
*/
maxRetries?: number;
/**
* Request timeout in milliseconds
*/
timeout?: number;
/**
* Abort signal for cancellation
*/
abortSignal?: AbortSignal;
/**
* Additional HTTP headers
*/
headers?: Record<string, string | undefined>;
}
export interface ChatModel<TConfig extends ChatModelConfig = ChatModelConfig> {
/**
* Provider identifier (e.g., 'openai', 'anthropic', 'google')
*/
provider: string;
/**
* Model identifier (e.g., 'gpt-4', 'claude-3-sonnet')
*/
modelId: string;
/**
* Default configuration for the model
*/
defaultConfig?: TConfig;
/**
* Generate a completion (non-streaming)
*/
generate(messages: Message[], config?: TConfig): Promise<GenerateResult>;
/**
* Generate a completion (streaming)
*/
stream(messages: Message[], config?: TConfig): AsyncIterable<StreamChunk>;
/**
* Bind tools to the model for tool calling
*/
withTools(tools: Tool[]): ChatModel<TConfig>;
/**
* Bind structured output schema
*/
withStructuredOutput?(schema: Record<string, unknown>): ChatModel<TConfig>;
}
@@ -0,0 +1,11 @@
/**
* A JSON value can be a string, number, boolean, object, array, or null.
* JSON values can be serialized and deserialized by the JSON.stringify and JSON.parse methods.
*/
export type JSONValue = null | string | number | boolean | JSONObject | JSONArray;
export type JSONObject = {
[key: string]: JSONValue | undefined;
};
export type JSONArray = JSONValue[];
@@ -0,0 +1,15 @@
import type { Message } from './message';
export interface ChatHistory {
getMessages(): Promise<Message[]>;
addMessage(message: Message): Promise<void>;
addMessages(messages: Message[]): Promise<void>;
clear(): Promise<void>;
}
export interface ChatMemory {
loadMessages(): Promise<Message[]>;
saveTurn(input: string, output: string): Promise<void>;
clear(): Promise<void>;
readonly chatHistory: ChatHistory;
}
@@ -0,0 +1,159 @@
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
export type MessageContent =
| ContentText
| ContentToolCall
| ContentInvalidToolCall
| ContentToolResult
| ContentReasoning
| ContentFile
| ContentCitation
| ContentProvider;
export interface ContentMetadata {
providerMetadata?: Record<string, unknown>;
}
export type ContentCitation = ContentMetadata & {
type: 'citation';
/**
* Source type for the citation.
*/
source?: string;
/**
* URL of the document source
*/
url?: string;
/**
* Source document title.
*
* For example, the page title for a web page or the title of a paper.
*/
title?: string;
/**
* Start index of the **response text** for which the annotation applies.
*
*/
startIndex?: number;
/**
* End index of the **response text** for which the annotation applies.
*
*/
endIndex?: number;
/**
* Excerpt of source text being cited.
*/
text?: string;
};
export type ContentText = ContentMetadata & {
type: 'text';
/**
* The text content.
*/
text: string;
};
export type ContentReasoning = ContentMetadata & {
type: 'reasoning';
text: string;
};
export type ContentFile = ContentMetadata & {
type: 'file';
/**
* The IANA media type of the file, e.g. `image/png` or `audio/mp3`.
*
* @see https://www.iana.org/assignments/media-types/media-types.xhtml
*/
mediaType?: string;
/**
* Generated file data as base64 encoded strings or binary data.
*
* The file data should be returned without any unnecessary conversion.
* If the API returns base64 encoded strings, the file data should be returned
* as base64 encoded strings. If the API returns binary data, the file data should
* be returned as binary data.
*/
data: string | Uint8Array;
};
export type ContentToolCall = ContentMetadata & {
type: 'tool-call';
/**
* The identifier of the tool call. It must be unique across all tool calls.
*/
toolCallId?: string;
/**
* The name of the tool that should be called.
*/
toolName: string;
/**
* Stringified JSON object with the tool call arguments. Must match the
* parameters schema of the tool.
*/
input: string;
};
export type ContentToolResult = ContentMetadata & {
type: 'tool-result';
/**
* The ID of the tool call that this result is associated with.
*/
toolCallId: string;
/**
* Result of the tool call. This is a JSON-serializable object.
*/
result: any;
/**
* Optional flag if the result is an error or an error message.
*/
isError?: boolean;
};
export type ContentInvalidToolCall = ContentMetadata & {
type: 'invalid-tool-call';
/**
* The ID of the tool call that this result is associated with.
*/
toolCallId?: string;
/**
* The error message of the tool call.
*/
error?: string;
/**
* The arguments to the tool call.
*/
args?: string;
/**
* The name of the tool that was called.
*/
name?: string;
};
export type ContentProvider = ContentMetadata & {
type: 'provider';
value: Record<string, unknown>;
};
export interface Message {
role: MessageRole;
content: MessageContent[];
name?: string;
/**
* Message ID from the provider
*/
id?: string;
}
@@ -0,0 +1,142 @@
import type { ProviderTool } from './tool';
export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | null;
export type VerbosityParam = 'low' | 'medium' | 'high' | null;
export interface OpenAIModelOptions {
baseUrl: string;
/** Model name to use */
model: string;
/**
* API key to use when making requests to OpenAI.
*/
apiKey: string;
/**
* Provider-specific tools to use.
* @example
* {
* type: 'provider',
* name: 'web_search',
* args: {
* search_context_size: 'medium',
* userLocation: {
* type: "approximate",
* country: "US"
* },
* },
* }
*/
providerTools?: ProviderTool[];
defaultHeaders?: Record<string, string>;
/**
* Whether to use the responses API for all requests. If `false` the responses API will be used
* only when required in order to fulfill the request.
*/
useResponsesApi?: boolean;
/**
* Whether to return log probabilities of the output tokens or not.
* If true, returns the log probabilities of each output token returned in the content of message.
*/
logprobs?: boolean;
/**
* An integer between 0 and 5 specifying the number of most likely tokens to return at each token position,
* each with an associated log probability. logprobs must be set to true if this parameter is used.
*/
topLogprobs?: number;
/**
* Whether the model supports the `strict` argument when passing in tools.
* If `undefined` the `strict` argument will not be passed to OpenAI.
*/
supportsStrictToolCalling?: boolean;
reasoning?: {
effort?: ReasoningEffort | null;
summary?: 'auto' | 'concise' | 'detailed' | null;
};
/**
* Should be set to `true` in tenancies with Zero Data Retention
* @see https://platform.openai.com/docs/guides/your-data
*
* @default false
*/
zdrEnabled?: boolean;
/**
* Service tier to use for this request. Can be "auto", "default", or "flex" or "priority".
* Specifies the service tier for prioritization and latency optimization.
*/
service_tier?: 'auto' | 'default' | 'flex' | 'scale' | 'priority' | null;
/**
* Used by OpenAI to cache responses for similar requests to optimize your cache
* hit rates. Replaces the `user` field.
* [Learn more](https://platform.openai.com/docs/guides/prompt-caching).
*/
promptCacheKey?: string;
/** Sampling temperature to use */
temperature?: number;
/**
* Maximum number of tokens to generate in the completion. -1 returns as many
* tokens as possible given the prompt and the model's maximum context size.
*/
maxTokens?: number;
/**
* Maximum number of tokens to generate in the completion. -1 returns as many
* tokens as possible given the prompt and the model's maximum context size.
* Alias for `maxTokens` for reasoning models.
*/
maxCompletionTokens?: number;
/** Total probability mass of tokens to consider at each step */
topP?: number;
/** Penalizes repeated tokens according to frequency */
frequencyPenalty?: number;
/** Penalizes repeated tokens */
presencePenalty?: number;
/** Number of completions to generate for each prompt */
n?: number;
/** Dictionary used to adjust the probability of specific tokens being generated */
logitBias?: Record<string, number>;
/** Unique string identifier representing your end-user, which can help OpenAI to monitor and detect abuse. */
user?: string;
/** Whether to stream the results or not. Enabling disables tokenUsage reporting */
streaming?: boolean;
/**
* Whether or not to include token usage data in streamed chunks.
* @default true
*/
streamUsage?: boolean;
/** Holds any additional parameters that are valid to pass to {@link
* https://platform.openai.com/docs/api-reference/completions/create |
* `openai.createCompletion`} that are not explicitly specified on this interface
*/
additionalParams?: Record<string, unknown>;
/**
* List of stop words to use when generating
* Alias for `stopSequences`
*/
stop?: string[];
/** List of stop words to use when generating */
stopSequences?: string[];
/**
* Timeout to use when making requests to OpenAI.
*/
timeout?: number;
/**
* The verbosity of the model's response.
*/
verbosity?: VerbosityParam;
/**
* Maximum number of retries to attempt.
*/
maxRetries?: number;
/**
* Custom handler to handle failed attempts. Takes the originally thrown
* error object as input, and should itself throw an error if the input
* error is not retryable.
*/
onFailedAttempt?: (error: unknown) => void;
}
@@ -0,0 +1,65 @@
import type { ContentMetadata, Message, MessageContent } from './message';
export type FinishReason = 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other';
export type TokenUsage<T extends Record<string, unknown> = Record<string, unknown>> = {
promptTokens: number;
completionTokens: number;
totalTokens: number;
inputTokenDetails?: {
cacheRead?: number;
};
outputTokenDetails?: {
reasoning?: number;
};
additionalMetadata?: T;
};
export interface GenerateResult {
id?: string;
finishReason?: FinishReason;
usage?: TokenUsage;
/**
* The generated message
*/
message: Message;
/**
* Metadata about the response from the provider
*/
providerMetadata?: Record<string, unknown>;
rawResponse?: unknown;
}
export type StreamChunk = ContentMetadata &
(
| {
type: 'text-delta';
id?: string;
delta: string;
}
| {
type: 'reasoning-delta';
id?: string;
delta: string;
}
| {
type: 'tool-call-delta';
id?: string;
name?: string;
argumentsDelta?: string;
}
| {
type: 'finish';
finishReason: FinishReason;
usage?: TokenUsage;
}
| {
type: 'error';
error: unknown;
}
| {
type: 'content';
content: MessageContent;
id?: string;
}
);
@@ -0,0 +1,84 @@
import type { JSONSchema7 } from 'json-schema';
import type { ZodTypeAny, ZodEffects, ZodSchema } from 'zod';
export interface FunctionTool {
type: 'function';
/**
* The name of the tool/function
*/
name: string;
/**
* Description of what the tool does
*/
description?: string;
/**
* JSON or Zod schema describing the tool's parameters
*/
inputSchema: JSONSchema7 | ZodSchema<any> | ZodEffects<ZodTypeAny>;
/**
* Whether this tool should be called strictly according to schema
*/
strict?: boolean;
/**
* Provider-specific options
*/
providerOptions?: Record<string, unknown>;
}
export interface ProviderTool<TArgs extends Record<string, unknown> = Record<string, unknown>> {
type: 'provider';
name: string;
args?: TArgs;
}
export type Tool = FunctionTool | ProviderTool;
/**
* Tool call from the model
*/
export interface ToolCall {
/**
* Unique identifier for this tool call
*/
id: string;
/**
* Name of the tool being called
*/
name: string;
/**
* Arguments passed to the tool (parsed JSON)
*/
arguments: Record<string, unknown>;
/**
* Raw arguments string (before parsing)
*/
argumentsRaw?: string;
}
/**
* Result from executing a tool
*/
export interface ToolResult {
/**
* ID of the tool call this result corresponds to
*/
toolCallId: string;
/**
* Name of the tool that was called
*/
toolName: string;
/**
* Result from the tool execution
*/
result: unknown;
status: 'success' | 'error';
}
@@ -0,0 +1,50 @@
import type { INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
/**
* Validates query input for embedQuery operations.
* Throws NodeOperationError if query is invalid (undefined, null, or empty string).
*
* @param query - The query to validate
* @param node - The node for error context
* @returns The validated query string
* @throws NodeOperationError if query is invalid
*/
export function validateEmbedQueryInput(query: unknown, node: INode): string {
if (typeof query !== 'string' || query === '') {
throw new NodeOperationError(node, 'Cannot embed empty or undefined text', {
description:
'The text provided for embedding is empty or undefined. This can happen when: the input expression evaluates to undefined, the AI agent calls a tool without proper arguments, or a required field is missing.',
});
}
return query;
}
/**
* Validates documents input for embedDocuments operations.
* Throws NodeOperationError if documents array is invalid or contains invalid entries.
*
* @param documents - The documents array to validate
* @param node - The node for error context
* @returns The validated documents array
* @throws NodeOperationError if documents is not an array or contains invalid entries
*/
export function validateEmbedDocumentsInput(documents: unknown, node: INode): string[] {
if (!Array.isArray(documents)) {
throw new NodeOperationError(node, 'Documents must be an array', {
description: 'Expected an array of strings to embed.',
});
}
const invalidIndex = documents.findIndex(
(doc) => doc === undefined || doc === null || doc === '',
);
if (invalidIndex !== -1) {
throw new NodeOperationError(node, `Invalid document at index ${invalidIndex}`, {
description: `Document at index ${invalidIndex} is empty or undefined. All documents must be non-empty strings.`,
});
}
return documents;
}
@@ -0,0 +1,41 @@
const STATUS_NO_RETRY = [
400, // Bad Request
401, // Unauthorized
402, // Payment Required
403, // Forbidden
404, // Not Found
405, // Method Not Allowed
406, // Not Acceptable
407, // Proxy Authentication Required
409, // Conflict
];
/**
* This function is used as a default handler for failed attempts in all LLMs.
* It is based on a default handler from the langchain core package.
* It throws an error when it encounters a known error that should not be retried.
* @param error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const n8nDefaultFailedAttemptHandler = (error: any) => {
if (
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call
error?.message?.startsWith?.('Cancel') ||
error?.message?.startsWith?.('AbortError') ||
error?.name === 'AbortError'
) {
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (error?.code === 'ECONNABORTED') {
throw error;
}
const status =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
error?.response?.status ?? error?.status;
if (status && STATUS_NO_RETRY.includes(+status)) {
throw error;
}
};
@@ -0,0 +1,46 @@
import type { FailedAttemptHandler } from '@langchain/core/dist/utils/async_caller';
import type { ISupplyDataFunctions, JsonObject } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { n8nDefaultFailedAttemptHandler } from './n8nDefaultFailedAttemptHandler';
/**
* This function returns a custom failed attempt handler for using with LangChain models.
* It first tries to use a custom handler passed as an argument, and if that doesn't throw an error, it uses the default handler.
* It always wraps the error in a NodeApiError.
* It throws an error ONLY if there are no retries left.
*/
export const makeN8nLlmFailedAttemptHandler = (
ctx: ISupplyDataFunctions,
handler?: FailedAttemptHandler,
): FailedAttemptHandler => {
return (error: any) => {
try {
// Try custom error handler first
handler?.(error);
// If it didn't throw an error, use the default handler
n8nDefaultFailedAttemptHandler(error);
} catch (e) {
// Wrap the error in a NodeApiError
const apiError = new NodeApiError(ctx.getNode(), e as JsonObject, {
functionality: 'configuration-node',
});
throw apiError;
}
// If no error was thrown, check if it is the last retry
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (error?.retriesLeft > 0) {
return;
}
// If there are no retries left, throw the error wrapped in a NodeApiError
const apiError = new NodeApiError(ctx.getNode(), error as unknown as JsonObject, {
functionality: 'configuration-node',
});
throw apiError;
};
};
@@ -0,0 +1,75 @@
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
import type { FromAIArgument, IDataObject, INode, INodeParameters } from 'n8n-workflow';
import { generateZodSchema, traverseNodeParameters } from 'n8n-workflow';
import { z } from 'zod';
export type ToolFunc = (
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
) => Promise<string | IDataObject | IDataObject[]>;
export interface CreateToolOptions {
name: string;
description: string;
func: ToolFunc;
/**
* Extra arguments to include in the structured tool schema.
* These are added after extracting $fromAI parameters from node parameters.
*/
extraArgs?: FromAIArgument[];
}
/**
* Extracts $fromAI parameters from node parameters and returns unique arguments.
*/
export function extractFromAIParameters(nodeParameters: INodeParameters): FromAIArgument[] {
const collectedArguments: FromAIArgument[] = [];
traverseNodeParameters(nodeParameters, collectedArguments);
const uniqueArgsMap = new Map<string, FromAIArgument>();
for (const arg of collectedArguments) {
uniqueArgsMap.set(arg.key, arg);
}
return Array.from(uniqueArgsMap.values());
}
/**
* Creates a Zod schema from $fromAI arguments.
*/
export function createZodSchemaFromArgs(args: FromAIArgument[]): z.ZodObject<z.ZodRawShape> {
const schemaObj = args.reduce((acc: Record<string, z.ZodTypeAny>, placeholder) => {
acc[placeholder.key] = generateZodSchema(placeholder);
return acc;
}, {});
return z.object(schemaObj).required();
}
/**
* Creates a DynamicStructuredTool if node has $fromAI parameters,
* otherwise falls back to a simple DynamicTool.
*
* This is useful for creating AI agent tools that can extract parameters
* from node configuration using $fromAI expressions.
*/
export function createToolFromNode(
node: INode,
options: CreateToolOptions,
): DynamicStructuredTool | DynamicTool {
const { name, description, func, extraArgs = [] } = options;
const collectedArguments = extractFromAIParameters(node.parameters);
// If there are no $fromAI arguments and no extra args, fallback to simple tool
if (collectedArguments.length === 0 && extraArgs.length === 0) {
return new DynamicTool({ name, description, func });
}
// Combine collected arguments with extra arguments
const allArguments = [...collectedArguments, ...extraArgs];
const schema = createZodSchemaFromArgs(allArguments);
return new DynamicStructuredTool({ schema, name, description, func });
}
@@ -0,0 +1,75 @@
import type { IExecuteFunctions, ISupplyDataFunctions } from 'n8n-workflow';
export function getMetadataFiltersValues(
ctx: IExecuteFunctions | ISupplyDataFunctions,
itemIndex: number,
): Record<string, never> | undefined {
const options = ctx.getNodeParameter('options', itemIndex, {});
if (options.metadata) {
const { metadataValues: metadata } = options.metadata as {
metadataValues: Array<{
name: string;
value: string;
}>;
};
if (metadata.length > 0) {
return metadata.reduce((acc, { name, value }) => ({ ...acc, [name]: value }), {});
}
}
if (options.searchFilterJson) {
return ctx.getNodeParameter('options.searchFilterJson', itemIndex, '', {
ensureType: 'object',
}) as Record<string, never>;
}
return undefined;
}
/**
* Detects if a text contains a character that repeats sequentially for a specified threshold.
* This is used to prevent performance issues with tiktoken on highly repetitive content.
* @param text The text to check
* @param threshold The minimum number of sequential repeats to detect (default: 1000)
* @returns true if a character repeats sequentially for at least the threshold amount
*/
export function hasLongSequentialRepeat(text: string, threshold = 1000): boolean {
try {
// Validate inputs
if (
text === null ||
typeof text !== 'string' ||
text.length === 0 ||
threshold <= 0 ||
text.length < threshold
) {
return false;
}
// Use string iterator to avoid creating array copy (memory efficient)
const iterator = text[Symbol.iterator]();
let prev = iterator.next();
if (prev.done) {
return false;
}
let count = 1;
for (const char of iterator) {
if (char === prev.value) {
count++;
if (count >= threshold) {
return true;
}
} else {
count = 1;
prev = { value: char, done: false };
}
}
return false;
} catch (error) {
// On any error, return false to allow normal processing
return false;
}
}
@@ -0,0 +1,109 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
import proxyFromEnv from 'proxy-from-env';
import { Agent, ProxyAgent } from 'undici';
/**
* Options for configuring HTTP agent timeouts.
* These timeouts are passed to undici's Agent/ProxyAgent to override default 5-minute timeouts.
*/
export interface AgentTimeoutOptions {
headersTimeout?: number;
bodyTimeout?: number;
connectTimeout?: number;
}
// Default timeout for AI operations (1 hour)
// Aligned with EXECUTIONS_TIMEOUT_MAX to ensure AI requests don't exceed workflow execution limits
// Configurable via N8N_AI_TIMEOUT_MAX environment variable to support custom timeout requirements
const DEFAULT_TIMEOUT = parseInt(process.env.N8N_AI_TIMEOUT_MAX ?? '3600000', 10);
/**
* Resolves the proxy URL from environment variables for a given target URL.
*
* @param targetUrl - The target URL to check proxy configuration for (optional)
* @returns The proxy URL string or undefined if no proxy is configured
*
* @remarks
* There are cases where we don't know the target URL in advance (e.g. when we need to provide a proxy agent to ChatAwsBedrock).
* In such case we use a dummy URL.
* This will lead to `NO_PROXY` environment variable not being respected, but it is better than not having a proxy agent at all.
*/
function getProxyUrlFromEnv(targetUrl?: string): string {
return proxyFromEnv.getProxyForUrl(targetUrl ?? 'https://example.nonexistent/');
}
/**
* Returns an undici Agent or ProxyAgent with configured timeouts based on the environment variables and target URL.
* When target URL is not provided, NO_PROXY environment variable is not respected.
*
* @param targetUrl - The target URL to check proxy configuration for (optional)
* @param timeoutOptions - Optional timeout configuration to override defaults. When provided,
* always returns an Agent/ProxyAgent (even without proxy) to ensure timeouts are applied.
* @returns An Agent (no proxy with timeout options) or ProxyAgent (with proxy) configured with timeouts,
* or undefined if no proxy is configured and no timeout options are provided (backward compatible behavior).
*
* @remarks
* When timeoutOptions are provided, this function always returns an agent to ensure timeouts are properly configured.
* The default undici timeouts (5 minutes) are too short for many AI operations.
* When timeoutOptions are NOT provided, returns undefined if no proxy is configured (backward compatible).
*/
export function getProxyAgent(targetUrl?: string, timeoutOptions?: AgentTimeoutOptions) {
const proxyUrl = getProxyUrlFromEnv(targetUrl);
const agentOptions = {
headersTimeout: timeoutOptions?.headersTimeout ?? DEFAULT_TIMEOUT,
bodyTimeout: timeoutOptions?.bodyTimeout ?? DEFAULT_TIMEOUT,
...(timeoutOptions?.connectTimeout !== undefined && {
connectTimeout: timeoutOptions.connectTimeout,
}),
};
if (!proxyUrl) {
if (timeoutOptions) {
return new Agent(agentOptions);
}
return undefined;
}
return new ProxyAgent({ uri: proxyUrl, ...agentOptions });
}
/**
* Make a fetch() request with an Agent/ProxyAgent that has configured timeouts.
* If proxy environment variables are set, uses ProxyAgent; otherwise uses Agent.
*
* @param input - The URL to fetch
* @param init - Standard fetch RequestInit options
* @param timeoutOptions - Optional timeout configuration to override defaults
*/
export async function proxyFetch(
input: RequestInfo | URL,
init?: RequestInit,
timeoutOptions?: AgentTimeoutOptions,
): Promise<Response> {
const targetUrl = input instanceof Request ? input.url : input.toString();
const dispatcher = getProxyAgent(targetUrl, timeoutOptions);
return await fetch(input, {
...init,
// @ts-expect-error - dispatcher is an undici-specific option not in standard fetch
dispatcher,
});
}
/**
* Returns a Node.js HTTP/HTTPS proxy agent for use with AWS SDK v3 clients.
* AWS SDK v3 requires Node.js http.Agent/https.Agent instances (not undici ProxyAgent).
*
* @param targetUrl - The target URL to check proxy configuration for
* @returns HttpsProxyAgent instance or undefined if no proxy is configured
*/
export function getNodeProxyAgent(targetUrl?: string) {
const proxyUrl = getProxyUrlFromEnv(targetUrl);
if (!proxyUrl) {
return undefined;
}
return new HttpsProxyAgent(proxyUrl);
}
@@ -0,0 +1,14 @@
import type { AiEvent, IDataObject, IExecuteFunctions, ISupplyDataFunctions } from 'n8n-workflow';
import { jsonStringify } from 'n8n-workflow';
export function logAiEvent(
executeFunctions: IExecuteFunctions | ISupplyDataFunctions,
event: AiEvent,
data?: IDataObject,
) {
try {
executeFunctions.logAiEvent(event, data ? jsonStringify(data) : undefined);
} catch (error) {
executeFunctions.logger.debug(`Error logging AI event: ${event}`);
}
}
@@ -0,0 +1,487 @@
import type { BaseDocumentLoader } from '@langchain/classic/dist/document_loaders/base';
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import type { BaseCallbackConfig, Callbacks } from '@langchain/core/callbacks/manager';
import type { BaseChatMessageHistory } from '@langchain/core/chat_history';
import type { Document } from '@langchain/core/documents';
import { Embeddings } from '@langchain/core/embeddings';
import type { InputValues, MemoryVariables, OutputValues } from '@langchain/core/memory';
import type { BaseMessage } from '@langchain/core/messages';
import { BaseRetriever } from '@langchain/core/retrievers';
import { BaseDocumentCompressor } from '@langchain/core/retrievers/document_compressors';
import type { StructuredTool, Tool } from '@langchain/core/tools';
import { VectorStore } from '@langchain/core/vectorstores';
import { TextSplitter } from '@langchain/textsplitters';
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
ISupplyDataFunctions,
ITaskMetadata,
NodeConnectionType,
} from 'n8n-workflow';
import {
NodeOperationError,
NodeConnectionTypes,
parseErrorMetadata,
deepCopy,
} from 'n8n-workflow';
import { isToolsInstance, isBaseChatMemory, isBaseChatMessageHistory } from '../guards';
import {
validateEmbedQueryInput,
validateEmbedDocumentsInput,
} from './embeddings-input-validation';
import { logAiEvent } from './log-ai-event';
import { N8nBinaryLoader } from './n8n-binary-loader';
import { N8nJsonLoader } from './n8n-json-loader';
export async function callMethodAsync<T>(
this: T,
parameters: {
executeFunctions: IExecuteFunctions | ISupplyDataFunctions;
connectionType: NodeConnectionType;
currentNodeRunIndex: number;
method: (...args: any[]) => Promise<unknown>;
arguments: unknown[];
},
): Promise<unknown> {
try {
return await parameters.method.call(this, ...parameters.arguments);
} catch (e) {
const connectedNode = parameters.executeFunctions.getNode();
const error = new NodeOperationError(connectedNode, e as Error, {
functionality: 'configuration-node',
});
const metadata = parseErrorMetadata(error);
parameters.executeFunctions.addOutputData(
parameters.connectionType,
parameters.currentNodeRunIndex,
error,
metadata,
);
if (error.message) {
if (!error.description) {
error.description = error.message;
}
throw error;
}
throw new NodeOperationError(
connectedNode,
`Error on node "${connectedNode.name}" which is connected via input "${parameters.connectionType}"`,
{ functionality: 'configuration-node' },
);
}
}
export function callMethodSync<T>(
this: T,
parameters: {
executeFunctions: IExecuteFunctions;
connectionType: NodeConnectionType;
currentNodeRunIndex: number;
method: (...args: any[]) => T;
arguments: unknown[];
},
): unknown {
try {
return parameters.method.call(this, ...parameters.arguments);
} catch (e) {
const connectedNode = parameters.executeFunctions.getNode();
const error = new NodeOperationError(connectedNode, e as Error);
parameters.executeFunctions.addOutputData(
parameters.connectionType,
parameters.currentNodeRunIndex,
error,
);
throw new NodeOperationError(
connectedNode,
`Error on node "${connectedNode.name}" which is connected via input "${parameters.connectionType}"`,
{ functionality: 'configuration-node' },
);
}
}
export function logWrapper<
T extends
| Tool
| StructuredTool
| BaseChatMemory
| BaseChatMessageHistory
| BaseRetriever
| BaseDocumentCompressor
| Embeddings
| Document[]
| Document
| BaseDocumentLoader
| TextSplitter
| VectorStore
| N8nBinaryLoader
| N8nJsonLoader,
>(originalInstance: T, executeFunctions: IExecuteFunctions | ISupplyDataFunctions): T {
return new Proxy(originalInstance, {
get: (target, prop) => {
let connectionType: NodeConnectionType | undefined;
// ========== BaseChatMemory ==========
if (isBaseChatMemory(originalInstance)) {
if (prop === 'loadMemoryVariables' && 'loadMemoryVariables' in target) {
return async (values: InputValues): Promise<MemoryVariables> => {
connectionType = NodeConnectionTypes.AiMemory;
const { index } = executeFunctions.addInputData(connectionType, [
[{ json: { action: 'loadMemoryVariables', values } }],
]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [values],
})) as MemoryVariables;
const chatHistory = (response?.chat_history as BaseMessage[]) ?? response;
executeFunctions.addOutputData(connectionType, index, [
[{ json: { action: 'loadMemoryVariables', chatHistory } }],
]);
return response;
};
} else if (prop === 'saveContext' && 'saveContext' in target) {
return async (input: InputValues, output: OutputValues): Promise<MemoryVariables> => {
connectionType = NodeConnectionTypes.AiMemory;
const { index } = executeFunctions.addInputData(connectionType, [
[{ json: { action: 'saveContext', input, output } }],
]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [input, output],
})) as MemoryVariables;
const chatHistory = await target.chatHistory.getMessages();
executeFunctions.addOutputData(connectionType, index, [
[{ json: { action: 'saveContext', chatHistory } }],
]);
return response;
};
}
}
// ========== BaseChatMessageHistory ==========
if (isBaseChatMessageHistory(originalInstance)) {
if (prop === 'getMessages' && 'getMessages' in target) {
return async (): Promise<BaseMessage[]> => {
connectionType = NodeConnectionTypes.AiMemory;
const { index } = executeFunctions.addInputData(connectionType, [
[{ json: { action: 'getMessages' } }],
]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [],
})) as BaseMessage[];
const payload = { action: 'getMessages', response };
executeFunctions.addOutputData(connectionType, index, [[{ json: payload }]]);
logAiEvent(executeFunctions, 'ai-messages-retrieved-from-memory', { response });
return response;
};
} else if (prop === 'addMessage' && 'addMessage' in target) {
return async (message: BaseMessage): Promise<void> => {
connectionType = NodeConnectionTypes.AiMemory;
const payload = { action: 'addMessage', message };
const { index } = executeFunctions.addInputData(connectionType, [[{ json: payload }]]);
await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [message],
});
logAiEvent(executeFunctions, 'ai-message-added-to-memory', { message });
executeFunctions.addOutputData(connectionType, index, [[{ json: payload }]]);
};
}
}
// ========== BaseRetriever ==========
if (originalInstance instanceof BaseRetriever) {
if (prop === 'getRelevantDocuments' && 'getRelevantDocuments' in target) {
return async (
query: string,
config?: Callbacks | BaseCallbackConfig,
): Promise<Document[]> => {
connectionType = NodeConnectionTypes.AiRetriever;
const { index } = executeFunctions.addInputData(connectionType, [
[{ json: { query, config } }],
]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [query, config],
})) as Array<Document<Record<string, any>>>;
const executionId: string | undefined = response[0]?.metadata?.executionId as string;
const workflowId: string | undefined = response[0]?.metadata?.workflowId as string;
const metadata: ITaskMetadata = {};
if (executionId && workflowId) {
metadata.subExecution = {
executionId,
workflowId,
};
}
logAiEvent(executeFunctions, 'ai-documents-retrieved', { query });
executeFunctions.addOutputData(
connectionType,
index,
[[{ json: { response } }]],
metadata,
);
return response;
};
}
}
// ========== Embeddings ==========
if (originalInstance instanceof Embeddings) {
// Docs -> Embeddings
if (prop === 'embedDocuments' && 'embedDocuments' in target) {
return async (documents: string[]): Promise<number[][]> => {
// Validate documents input before embedding
const validatedDocuments = validateEmbedDocumentsInput(
documents,
executeFunctions.getNode(),
);
connectionType = NodeConnectionTypes.AiEmbedding;
const { index } = executeFunctions.addInputData(connectionType, [
[{ json: { documents: validatedDocuments } }],
]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [validatedDocuments],
})) as number[][];
logAiEvent(executeFunctions, 'ai-document-embedded');
executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]);
return response;
};
}
// Query -> Embeddings
if (prop === 'embedQuery' && 'embedQuery' in target) {
return async (query: string): Promise<number[]> => {
// Validate query input before embedding
const validatedQuery = validateEmbedQueryInput(query, executeFunctions.getNode());
connectionType = NodeConnectionTypes.AiEmbedding;
const { index } = executeFunctions.addInputData(connectionType, [
[{ json: { query: validatedQuery } }],
]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [validatedQuery],
})) as number[];
logAiEvent(executeFunctions, 'ai-query-embedded');
executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]);
return response;
};
}
}
// ========== Rerankers ==========
if (originalInstance instanceof BaseDocumentCompressor) {
if (prop === 'compressDocuments' && 'compressDocuments' in target) {
return async (documents: Document[], query: string): Promise<Document[]> => {
connectionType = NodeConnectionTypes.AiReranker;
const { index } = executeFunctions.addInputData(connectionType, [
[{ json: { query, documents } }],
]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
// compressDocuments mutates the original object
// messing up the input data logging
arguments: [deepCopy(documents), query],
})) as Document[];
logAiEvent(executeFunctions, 'ai-document-reranked', { query });
executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]);
return response;
};
}
}
// ========== N8n Loaders Process All ==========
if (
originalInstance instanceof N8nJsonLoader ||
originalInstance instanceof N8nBinaryLoader
) {
// Process All
if (prop === 'processAll' && 'processAll' in target) {
return async (items: INodeExecutionData[]): Promise<number[]> => {
connectionType = NodeConnectionTypes.AiDocument;
const { index } = executeFunctions.addInputData(connectionType, [items]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [items],
})) as number[];
executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]);
return response;
};
}
// Process Each
if (prop === 'processItem' && 'processItem' in target) {
return async (item: INodeExecutionData, itemIndex: number): Promise<number[]> => {
connectionType = NodeConnectionTypes.AiDocument;
const { index } = executeFunctions.addInputData(connectionType, [[item]]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [item, itemIndex],
})) as number[];
logAiEvent(executeFunctions, 'ai-document-processed');
executeFunctions.addOutputData(connectionType, index, [
[{ json: { response }, pairedItem: { item: itemIndex } }],
]);
return response;
};
}
}
// ========== TextSplitter ==========
if (originalInstance instanceof TextSplitter) {
if (prop === 'splitText' && 'splitText' in target) {
return async (text: string): Promise<string[]> => {
connectionType = NodeConnectionTypes.AiTextSplitter;
const { index } = executeFunctions.addInputData(connectionType, [
[{ json: { textSplitter: text } }],
]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [text],
})) as string[];
logAiEvent(executeFunctions, 'ai-text-split');
executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]);
return response;
};
}
}
// ========== Tool ==========
if (isToolsInstance(originalInstance)) {
if (prop === '_call' && '_call' in target) {
return async (query: string): Promise<string> => {
connectionType = NodeConnectionTypes.AiTool;
const inputData: IDataObject = { query };
if (target.metadata?.isFromToolkit) {
inputData.tool = {
name: target.name,
description: target.description,
};
}
const { index } = executeFunctions.addInputData(connectionType, [
[{ json: inputData }],
]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [query],
})) as string;
logAiEvent(executeFunctions, 'ai-tool-called', { ...inputData, response });
executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]);
if (typeof response === 'string') return response;
return JSON.stringify(response);
};
}
}
// ========== VectorStore ==========
if (originalInstance instanceof VectorStore) {
if (prop === 'similaritySearch' && 'similaritySearch' in target) {
return async (
query: string,
k?: number,
filter?: BiquadFilterType,
_callbacks?: Callbacks,
): Promise<Document[]> => {
connectionType = NodeConnectionTypes.AiVectorStore;
const { index } = executeFunctions.addInputData(connectionType, [
[{ json: { query, k, filter } }],
]);
const response = (await callMethodAsync.call(target, {
executeFunctions,
connectionType,
currentNodeRunIndex: index,
method: target[prop] as (...args: any[]) => Promise<unknown>,
arguments: [query, k, filter, _callbacks],
})) as Array<Document<Record<string, any>>>;
logAiEvent(executeFunctions, 'ai-vector-store-searched', { query });
executeFunctions.addOutputData(connectionType, index, [[{ json: { response } }]]);
return response;
};
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return (target as any)[prop];
},
});
}
@@ -0,0 +1,239 @@
import { JSONLoader } from '@langchain/classic/document_loaders/fs/json';
import { TextLoader } from '@langchain/classic/document_loaders/fs/text';
import { CSVLoader } from '@langchain/community/document_loaders/fs/csv';
import { DocxLoader } from '@langchain/community/document_loaders/fs/docx';
import { EPubLoader } from '@langchain/community/document_loaders/fs/epub';
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
import type { Document } from '@langchain/core/documents';
import type { TextSplitter } from '@langchain/textsplitters';
import { createWriteStream } from 'fs';
import type {
IBinaryData,
IExecuteFunctions,
INodeExecutionData,
ISupplyDataFunctions,
} from 'n8n-workflow';
import { NodeOperationError, BINARY_ENCODING } from 'n8n-workflow';
import { pipeline } from 'stream/promises';
import { file as tmpFile, type DirectoryResult } from 'tmp-promise';
import { getMetadataFiltersValues } from './helpers';
const SUPPORTED_MIME_TYPES = {
auto: ['*/*'],
pdfLoader: ['application/pdf'],
csvLoader: ['text/csv'],
epubLoader: ['application/epub+zip'],
docxLoader: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
textLoader: ['text/plain', 'text/mdx', 'text/md', 'text/markdown'],
jsonLoader: ['application/json'],
};
export class N8nBinaryLoader {
constructor(
private context: IExecuteFunctions | ISupplyDataFunctions,
private optionsPrefix = '',
private binaryDataKey = '',
private textSplitter?: TextSplitter,
) {}
async processAll(items?: INodeExecutionData[]): Promise<Document[]> {
const docs: Document[] = [];
if (!items) return [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const processedDocuments = await this.processItem(items[itemIndex], itemIndex);
docs.push(...processedDocuments);
}
return docs;
}
private async validateMimeType(
mimeType: string,
selectedLoader: keyof typeof SUPPORTED_MIME_TYPES,
): Promise<void> {
// Check if loader matches the mime-type of the data
if (selectedLoader !== 'auto' && !SUPPORTED_MIME_TYPES[selectedLoader].includes(mimeType)) {
const neededLoader = Object.keys(SUPPORTED_MIME_TYPES).find((loader) =>
SUPPORTED_MIME_TYPES[loader as keyof typeof SUPPORTED_MIME_TYPES].includes(mimeType),
);
throw new NodeOperationError(
this.context.getNode(),
`Mime type doesn't match selected loader. Please select under "Loader Type": ${neededLoader}`,
);
}
if (!Object.values(SUPPORTED_MIME_TYPES).flat().includes(mimeType)) {
throw new NodeOperationError(this.context.getNode(), `Unsupported mime type: ${mimeType}`);
}
if (
!SUPPORTED_MIME_TYPES[selectedLoader].includes(mimeType) &&
selectedLoader !== 'textLoader' &&
selectedLoader !== 'auto'
) {
throw new NodeOperationError(
this.context.getNode(),
`Unsupported mime type: ${mimeType} for selected loader: ${selectedLoader}`,
);
}
}
private async getFilePathOrBlob(
binaryData: IBinaryData,
mimeType: string,
): Promise<string | Blob> {
if (binaryData.id) {
const binaryBuffer = await this.context.helpers.binaryToBuffer(
await this.context.helpers.getBinaryStream(binaryData.id),
);
return new Blob([binaryBuffer as BlobPart], {
type: mimeType,
});
} else {
return new Blob([Buffer.from(binaryData.data, BINARY_ENCODING)], {
type: mimeType,
});
}
}
private async getLoader(
mimeType: string,
filePathOrBlob: string | Blob,
itemIndex: number,
): Promise<PDFLoader | CSVLoader | EPubLoader | DocxLoader | TextLoader | JSONLoader> {
switch (mimeType) {
case 'application/pdf':
const splitPages = this.context.getNodeParameter(
`${this.optionsPrefix}splitPages`,
itemIndex,
false,
) as boolean;
return new PDFLoader(filePathOrBlob, { splitPages });
case 'text/csv':
const column = this.context.getNodeParameter(
`${this.optionsPrefix}column`,
itemIndex,
null,
) as string;
const separator = this.context.getNodeParameter(
`${this.optionsPrefix}separator`,
itemIndex,
',',
) as string;
return new CSVLoader(filePathOrBlob, { column: column ?? undefined, separator });
case 'application/epub+zip':
// EPubLoader currently does not accept Blobs https://github.com/langchain-ai/langchainjs/issues/1623
let filePath: string;
if (filePathOrBlob instanceof Blob) {
const tmpFileData = await tmpFile({ prefix: 'epub-loader-' });
const bufferData = await filePathOrBlob.arrayBuffer();
await pipeline([new Uint8Array(bufferData)], createWriteStream(tmpFileData.path));
return new EPubLoader(tmpFileData.path);
} else {
filePath = filePathOrBlob;
}
return new EPubLoader(filePath);
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return new DocxLoader(filePathOrBlob);
case 'text/plain':
return new TextLoader(filePathOrBlob);
case 'application/json':
const pointers = this.context.getNodeParameter(
`${this.optionsPrefix}pointers`,
itemIndex,
'',
) as string;
const pointersArray = pointers.split(',').map((pointer) => pointer.trim());
return new JSONLoader(filePathOrBlob, pointersArray);
default:
return new TextLoader(filePathOrBlob);
}
}
private async loadDocuments(
loader: PDFLoader | CSVLoader | EPubLoader | DocxLoader | TextLoader | JSONLoader,
): Promise<Document[]> {
return this.textSplitter
? await this.textSplitter.splitDocuments(await loader.load())
: await loader.load();
}
private async cleanupTmpFileIfNeeded(
cleanupTmpFile: DirectoryResult['cleanup'] | undefined,
): Promise<void> {
if (cleanupTmpFile) {
await cleanupTmpFile();
}
}
async processItem(item: INodeExecutionData, itemIndex: number): Promise<Document[]> {
const docs: Document[] = [];
const binaryMode = this.context.getNodeParameter('binaryMode', itemIndex, 'allInputData');
if (binaryMode === 'allInputData') {
const binaryData = this.context.getInputData();
for (const data of binaryData) {
if (data.binary) {
const binaryDataKeys = Object.keys(data.binary);
for (const fileKey of binaryDataKeys) {
const processedDocuments = await this.processItemByKey(item, itemIndex, fileKey);
docs.push(...processedDocuments);
}
}
}
} else {
const processedDocuments = await this.processItemByKey(item, itemIndex, this.binaryDataKey);
docs.push(...processedDocuments);
}
return docs;
}
async processItemByKey(
item: INodeExecutionData,
itemIndex: number,
binaryKey: string,
): Promise<Document[]> {
const selectedLoader: keyof typeof SUPPORTED_MIME_TYPES = this.context.getNodeParameter(
'loader',
itemIndex,
'auto',
) as keyof typeof SUPPORTED_MIME_TYPES;
const docs: Document[] = [];
const metadata = getMetadataFiltersValues(this.context, itemIndex);
if (!item) return [];
const binaryData = this.context.helpers.assertBinaryData(itemIndex, binaryKey);
const { mimeType } = binaryData;
await this.validateMimeType(mimeType, selectedLoader);
const filePathOrBlob = await this.getFilePathOrBlob(binaryData, mimeType);
const cleanupTmpFile: DirectoryResult['cleanup'] | undefined = undefined;
const loader = await this.getLoader(mimeType, filePathOrBlob, itemIndex);
const loadedDoc = await this.loadDocuments(loader);
docs.push(...loadedDoc);
if (metadata) {
docs.forEach((document) => {
document.metadata = {
...document.metadata,
...metadata,
};
});
}
await this.cleanupTmpFileIfNeeded(cleanupTmpFile);
return docs;
}
}
@@ -0,0 +1,90 @@
import { JSONLoader } from '@langchain/classic/document_loaders/fs/json';
import { TextLoader } from '@langchain/classic/document_loaders/fs/text';
import type { Document } from '@langchain/core/documents';
import type { TextSplitter } from '@langchain/textsplitters';
import {
type IExecuteFunctions,
type INodeExecutionData,
type ISupplyDataFunctions,
NodeOperationError,
} from 'n8n-workflow';
import { getMetadataFiltersValues } from './helpers';
export class N8nJsonLoader {
constructor(
private context: IExecuteFunctions | ISupplyDataFunctions,
private optionsPrefix = '',
private textSplitter?: TextSplitter,
) {}
async processAll(items?: INodeExecutionData[]): Promise<Document[]> {
const docs: Document[] = [];
if (!items) return [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const processedDocuments = await this.processItem(items[itemIndex], itemIndex);
docs.push(...processedDocuments);
}
return docs;
}
async processItem(item: INodeExecutionData, itemIndex: number): Promise<Document[]> {
const mode = this.context.getNodeParameter('jsonMode', itemIndex, 'allInputData') as
| 'allInputData'
| 'expressionData';
const pointers = this.context.getNodeParameter(
`${this.optionsPrefix}pointers`,
itemIndex,
'',
) as string;
const pointersArray = pointers.split(',').map((pointer) => pointer.trim());
const metadata = getMetadataFiltersValues(this.context, itemIndex) ?? [];
if (!item) return [];
let documentLoader: JSONLoader | TextLoader | null = null;
if (mode === 'allInputData') {
const itemString = JSON.stringify(item.json);
const itemBlob = new Blob([itemString], { type: 'application/json' });
documentLoader = new JSONLoader(itemBlob, pointersArray);
}
if (mode === 'expressionData') {
const dataString = this.context.getNodeParameter('jsonData', itemIndex) as string | object;
if (typeof dataString === 'object') {
const itemBlob = new Blob([JSON.stringify(dataString)], { type: 'application/json' });
documentLoader = new JSONLoader(itemBlob, pointersArray);
}
if (typeof dataString === 'string') {
const itemBlob = new Blob([dataString], { type: 'text/plain' });
documentLoader = new TextLoader(itemBlob);
}
}
if (documentLoader === null) {
// This should never happen
throw new NodeOperationError(this.context.getNode(), 'Document loader is not initialized');
}
const docs = this.textSplitter
? await this.textSplitter.splitDocuments(await documentLoader.load())
: await documentLoader.load();
if (metadata) {
docs.forEach((doc) => {
doc.metadata = {
...doc.metadata,
...metadata,
};
});
}
return docs;
}
}
@@ -0,0 +1,235 @@
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
import type { SerializedFields } from '@langchain/core/dist/load/map_keys';
import { getModelNameForTiktoken } from '@langchain/core/language_models/base';
import type {
Serialized,
SerializedNotImplemented,
SerializedSecret,
} from '@langchain/core/load/serializable';
import type { BaseMessage } from '@langchain/core/messages';
import type { LLMResult } from '@langchain/core/outputs';
import pick from 'lodash/pick';
import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow';
import { NodeConnectionTypes, NodeError, NodeOperationError } from 'n8n-workflow';
import { logAiEvent } from './log-ai-event';
import { estimateTokensFromStringList } from './tokenizer/token-estimator';
type TokensUsageParser = (result: LLMResult) => {
completionTokens: number;
promptTokens: number;
totalTokens: number;
};
type RunDetail = {
index: number;
messages: BaseMessage[] | string[] | string;
options: SerializedSecret | SerializedNotImplemented | SerializedFields;
};
const TIKTOKEN_ESTIMATE_MODEL = 'gpt-4o';
export class N8nLlmTracing extends BaseCallbackHandler {
name = 'N8nLlmTracing';
// This flag makes sure that LangChain will wait for the handlers to finish before continuing
// This is crucial for the handleLLMError handler to work correctly (it should be called before the error is propagated to the root node)
awaitHandlers = true;
connectionType = NodeConnectionTypes.AiLanguageModel;
promptTokensEstimate = 0;
completionTokensEstimate = 0;
#parentRunIndex?: number;
/**
* A map to associate LLM run IDs to run details.
* Key: Unique identifier for each LLM run (run ID)
* Value: RunDetails object
*
*/
runsMap: Record<string, RunDetail> = {};
options = {
// Default(OpenAI format) parser
tokensUsageParser: (result: LLMResult) => {
const completionTokens = (result?.llmOutput?.tokenUsage?.completionTokens as number) ?? 0;
const promptTokens = (result?.llmOutput?.tokenUsage?.promptTokens as number) ?? 0;
return {
completionTokens,
promptTokens,
totalTokens: completionTokens + promptTokens,
};
},
errorDescriptionMapper: (error: NodeError) => error.description,
};
constructor(
private executionFunctions: ISupplyDataFunctions,
options?: {
tokensUsageParser?: TokensUsageParser;
errorDescriptionMapper?: (error: NodeError) => string;
},
) {
super();
this.options = { ...this.options, ...options };
}
async estimateTokensFromGeneration(generations: LLMResult['generations']) {
const messages = generations.flatMap((gen) => gen.map((g) => g.text));
return await this.estimateTokensFromStringList(messages);
}
async estimateTokensFromStringList(list: string[]) {
const embeddingModel = getModelNameForTiktoken(TIKTOKEN_ESTIMATE_MODEL);
return await estimateTokensFromStringList(list, embeddingModel);
}
async handleLLMEnd(output: LLMResult, runId: string) {
// The fallback should never happen since handleLLMStart should always set the run details
// but just in case, we set the index to the length of the runsMap
const runDetails = this.runsMap[runId] ?? { index: Object.keys(this.runsMap).length };
output.generations = output.generations.map((gen) =>
gen.map((g) => pick(g, ['text', 'generationInfo'])),
);
const tokenUsageEstimate = {
completionTokens: 0,
promptTokens: 0,
totalTokens: 0,
};
const tokenUsage = this.options.tokensUsageParser(output);
if (output.generations.length > 0) {
tokenUsageEstimate.completionTokens = await this.estimateTokensFromGeneration(
output.generations,
);
tokenUsageEstimate.promptTokens = this.promptTokensEstimate;
tokenUsageEstimate.totalTokens =
tokenUsageEstimate.completionTokens + this.promptTokensEstimate;
}
const response: {
response: { generations: LLMResult['generations'] };
tokenUsageEstimate?: typeof tokenUsageEstimate;
tokenUsage?: typeof tokenUsage;
} = {
response: { generations: output.generations },
};
// If the LLM response contains actual tokens usage, otherwise fallback to the estimate
if (tokenUsage.completionTokens > 0) {
response.tokenUsage = tokenUsage;
} else {
response.tokenUsageEstimate = tokenUsageEstimate;
}
const parsedMessages =
typeof runDetails.messages === 'string'
? runDetails.messages
: runDetails.messages.map((message) => {
if (typeof message === 'string') return message;
if (typeof message?.toJSON === 'function') return message.toJSON();
return message;
});
const sourceNodeRunIndex =
this.#parentRunIndex !== undefined ? this.#parentRunIndex + runDetails.index : undefined;
this.executionFunctions.addOutputData(
this.connectionType,
runDetails.index,
[[{ json: { ...response } }]],
undefined,
sourceNodeRunIndex,
);
logAiEvent(this.executionFunctions, 'ai-llm-generated-output', {
messages: parsedMessages,
options: runDetails.options,
response,
});
}
async handleLLMStart(llm: Serialized, prompts: string[], runId: string) {
const estimatedTokens = await this.estimateTokensFromStringList(prompts);
const sourceNodeRunIndex =
this.#parentRunIndex !== undefined
? this.#parentRunIndex + this.executionFunctions.getNextRunIndex()
: undefined;
const options = llm.type === 'constructor' ? llm.kwargs : llm;
const { index } = this.executionFunctions.addInputData(
this.connectionType,
[
[
{
json: {
messages: prompts,
estimatedTokens,
options,
},
},
],
],
sourceNodeRunIndex,
);
// Save the run details for later use when processing `handleLLMEnd` event
this.runsMap[runId] = {
index,
options,
messages: prompts,
};
this.promptTokensEstimate = estimatedTokens;
}
async handleLLMError(error: IDataObject | Error, runId: string, parentRunId?: string) {
const runDetails = this.runsMap[runId] ?? { index: Object.keys(this.runsMap).length };
// Filter out non-x- headers to avoid leaking sensitive information in logs
// eslint-disable-next-line no-prototype-builtins
if (typeof error === 'object' && error?.hasOwnProperty('headers')) {
const errorWithHeaders = error as { headers: Record<string, unknown> };
Object.keys(errorWithHeaders.headers).forEach((key) => {
if (!key.startsWith('x-')) {
delete errorWithHeaders.headers[key];
}
});
}
if (error instanceof NodeError) {
if (this.options.errorDescriptionMapper) {
error.description = this.options.errorDescriptionMapper(error);
}
this.executionFunctions.addOutputData(this.connectionType, runDetails.index, error);
} else {
// If the error is not a NodeError, we wrap it in a NodeOperationError
this.executionFunctions.addOutputData(
this.connectionType,
runDetails.index,
new NodeOperationError(this.executionFunctions.getNode(), error as JsonObject, {
functionality: 'configuration-node',
}),
);
}
logAiEvent(this.executionFunctions, 'ai-llm-errored', {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
error: Object.keys(error).length === 0 ? error.toString() : error,
runId,
parentRunId,
});
}
// Used to associate subsequent runs with the correct parent run in subnodes of subnodes
setParentRunIndex(runIndex: number) {
this.#parentRunIndex = runIndex;
}
}
@@ -0,0 +1,187 @@
import { NodeConnectionTypes } from 'n8n-workflow';
import type { IDisplayOptions, INodeProperties } from 'n8n-workflow';
export const metadataFilterField: INodeProperties = {
displayName: 'Metadata Filter',
name: 'metadata',
type: 'fixedCollection',
description: 'Metadata to filter the document by',
typeOptions: {
multipleValues: true,
},
default: {},
placeholder: 'Add filter field',
options: [
{
name: 'metadataValues',
displayName: 'Fields to Set',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
required: true,
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
},
],
},
],
};
export function getTemplateNoticeField(templateId: number): INodeProperties {
return {
displayName: `Save time with an <a href="/templates/${templateId}" target="_blank">example</a> of how this node works`,
name: 'notice',
type: 'notice',
default: '',
};
}
export function getBatchingOptionFields(
displayOptions: IDisplayOptions | undefined,
defaultBatchSize: number = 5,
): INodeProperties {
return {
displayName: 'Batch Processing',
name: 'batching',
type: 'collection',
placeholder: 'Add Batch Processing Option',
description: 'Batch processing options for rate limiting',
default: {},
options: [
{
displayName: 'Batch Size',
name: 'batchSize',
default: defaultBatchSize,
type: 'number',
description:
'How many items to process in parallel. This is useful for rate limiting, but might impact the log output ordering.',
},
{
displayName: 'Delay Between Batches',
name: 'delayBetweenBatches',
default: 0,
type: 'number',
description: 'Delay in milliseconds between batches. This is useful for rate limiting.',
},
],
displayOptions,
};
}
const connectionsString = {
[NodeConnectionTypes.AiAgent]: {
// Root AI view
connection: '',
locale: 'AI Agent',
},
[NodeConnectionTypes.AiChain]: {
// Root AI view
connection: '',
locale: 'AI Chain',
},
[NodeConnectionTypes.AiDocument]: {
connection: NodeConnectionTypes.AiDocument,
locale: 'Document Loader',
},
[NodeConnectionTypes.AiVectorStore]: {
connection: NodeConnectionTypes.AiVectorStore,
locale: 'Vector Store',
},
[NodeConnectionTypes.AiRetriever]: {
connection: NodeConnectionTypes.AiRetriever,
locale: 'Vector Store Retriever',
},
};
type AllowedConnectionTypes =
| typeof NodeConnectionTypes.AiAgent
| typeof NodeConnectionTypes.AiChain
| typeof NodeConnectionTypes.AiDocument
| typeof NodeConnectionTypes.AiVectorStore
| typeof NodeConnectionTypes.AiRetriever;
function determineArticle(nextWord: string): string {
// check if the next word starts with a vowel sound
const vowels = /^[aeiouAEIOU]/;
return vowels.test(nextWord) ? 'an' : 'a';
}
const getConnectionParameterString = (connectionType: string) => {
if (connectionType === '') return "data-action-parameter-creatorview='AI'";
return `data-action-parameter-connectiontype='${connectionType}'`;
};
const getAhref = (connectionType: { connection: string; locale: string }) =>
`<a class="test" data-action='openSelectiveNodeCreator'${getConnectionParameterString(
connectionType.connection,
)}'>${connectionType.locale}</a>`;
export function getConnectionHintNoticeField(
connectionTypes: AllowedConnectionTypes[],
): INodeProperties {
const groupedConnections = new Map<string, string[]>();
// group connection types by their 'connection' value
// to not create multiple links
connectionTypes.forEach((connectionType) => {
const connectionString = connectionsString[connectionType].connection;
const localeString = connectionsString[connectionType].locale;
if (!groupedConnections.has(connectionString)) {
groupedConnections.set(connectionString, [localeString]);
return;
}
groupedConnections.get(connectionString)?.push(localeString);
});
let displayName;
if (groupedConnections.size === 1) {
const [[connection, locales]] = Array.from(groupedConnections);
displayName = `This node must be connected to ${determineArticle(locales[0])} ${locales[0]
.toLowerCase()
.replace(
/^ai /,
'AI ',
)}. <a data-action='openSelectiveNodeCreator' ${getConnectionParameterString(
connection,
)}>Insert one</a>`;
} else {
const ahrefs = Array.from(groupedConnections, ([connection, locales]) => {
// If there are multiple locales, join them with ' or '
// use determineArticle to insert the correct article
const locale =
locales.length > 1
? locales
.map((localeString, index, { length }) => {
return (
(index === 0 ? `${determineArticle(localeString)} ` : '') +
(index < length - 1 ? `${localeString} or ` : localeString)
);
})
.join('')
: `${determineArticle(locales[0])} ${locales[0]}`;
return getAhref({ connection, locale });
});
displayName = `This node needs to be connected to ${ahrefs.join(' or ')}.`;
}
return {
displayName,
name: 'notice',
type: 'notice',
default: '',
typeOptions: {
containerClass: 'ndv-connection-hint-notice',
},
};
}
+160
View File
@@ -0,0 +1,160 @@
export interface ServerSentEventMessage {
/** Ignored by the client. */
comment?: string;
/** A string identifying the type of event described. */
event?: string;
/** The data field for the message. Split by new lines. */
data?: string;
/** The event ID to set the object's last event ID value. */
id?: string | number;
/** The reconnection time. */
retry?: number;
}
/**
* Parse a Server-Sent Events (SSE) stream according to the SSE specification.
* Handles multi-line data fields, all SSE field types, and proper event buffering.
*
* Features:
* - Correctly handles multi-line data fields (joined with \n)
* - Supports all SSE fields: event, data, id, retry, and comments
* - Handles partial UTF-8 sequences across chunks
* - Supports CR, LF, and CRLF line endings
* - Properly buffers incomplete lines
*
* The method does not handle the iterator closing, so the caller is responsible for closing the iterator.
*
* @param body - AsyncIterableIterator
* @returns AsyncIterable of parsed SSE messages
*
* @see https://html.spec.whatwg.org/multipage/server-sent-events.html
*/
export async function* parseSSEStream(
body: AsyncIterableIterator<Buffer | Uint8Array>,
): AsyncIterable<ServerSentEventMessage> {
const decoder = new TextDecoder();
let buffer = '';
// Current event being assembled
let currentEvent: ServerSentEventMessage = {};
let dataLines: string[] = [];
for await (const chunk of body) {
buffer += decoder.decode(chunk, { stream: true });
// Process complete lines
// SSE spec supports CR, LF, and CRLF as line terminators
const lines = buffer.split(/\r\n|\r|\n/);
// Keep the last incomplete line in the buffer
buffer = lines.pop() ?? '';
for (const line of lines) {
const event = processLine(line);
if (event) {
yield event;
}
}
}
// Stream ended - flush the decoder to get any trailing partial UTF-8 sequence
buffer += decoder.decode();
// Process any remaining buffered content
if (buffer !== '') {
const event = processLine(buffer);
if (event) {
yield event;
}
}
// Yield final event if it has content
if (hasEventContent()) {
yield finalizeEvent();
}
function processLine(line: string): ServerSentEventMessage | null {
// Empty line marks the end of an event
if (line === '') {
if (hasEventContent()) {
const event = finalizeEvent();
// Reset for next event
currentEvent = {};
dataLines = [];
return event;
}
return null;
}
// Lines starting with : are comments (ignored per spec, but we can store them)
if (line.startsWith(':')) {
currentEvent.comment = line.slice(1).trimStart();
return null;
}
// Parse field: value
const colonIndex = line.indexOf(':');
if (colonIndex === -1) {
// Field with no value (e.g., "data" alone means data: "")
processField(line, '');
return null;
}
const fieldName = line.slice(0, colonIndex);
// Remove optional single space after colon per SSE spec
let fieldValue = line.slice(colonIndex + 1);
if (fieldValue.startsWith(' ')) {
fieldValue = fieldValue.slice(1);
}
processField(fieldName, fieldValue);
return null;
}
function processField(name: string, value: string): void {
switch (name) {
case 'event': {
currentEvent.event = value;
break;
}
case 'data': {
// Data fields can be multi-line; collect them
dataLines.push(value);
break;
}
case 'id': {
// Only set id if value is not empty per SSE spec
if (value) {
// Try to parse as number, otherwise keep as string
const numId = Number(value);
currentEvent.id = Number.isNaN(numId) ? value : numId;
}
break;
}
case 'retry': {
// Retry must be a valid non-negative integer per SSE spec
const retryValue = Number.parseInt(value, 10);
if (!Number.isNaN(retryValue) && retryValue >= 0) {
currentEvent.retry = retryValue;
}
break;
}
default: {
// Unknown fields are ignored per SSE spec
break;
}
}
}
function hasEventContent(): boolean {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return !!(currentEvent.event || dataLines.length > 0 || currentEvent.id || currentEvent.retry);
}
function finalizeEvent(): ServerSentEventMessage {
// Join data lines with newline character per SSE spec
const finalData = dataLines.join('\n');
return {
...currentEvent,
data: finalData ? finalData : undefined,
};
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
import { readFile } from 'fs/promises';
import type { TiktokenBPE, TiktokenEncoding, TiktokenModel } from 'js-tiktoken/lite';
import { Tiktoken, getEncodingNameForModel } from 'js-tiktoken/lite';
import { jsonParse } from 'n8n-workflow';
import { join } from 'path';
const cache: Record<string, Promise<Tiktoken>> = {};
const loadJSONFile = async (filename: string): Promise<TiktokenBPE> => {
const filePath = join(__dirname, filename);
const content = await readFile(filePath, 'utf-8');
return await jsonParse(content);
};
export async function getEncoding(encoding: TiktokenEncoding): Promise<Tiktoken> {
if (!(encoding in cache)) {
// Create and cache the promise for loading this encoding
cache[encoding] = (async () => {
let jsonData: TiktokenBPE;
switch (encoding) {
case 'o200k_base':
jsonData = await loadJSONFile('./o200k_base.json');
break;
case 'cl100k_base':
jsonData = await loadJSONFile('./cl100k_base.json');
break;
default:
// Fall back to cl100k_base for unsupported encodings
jsonData = await loadJSONFile('./cl100k_base.json');
}
return new Tiktoken(jsonData);
})().catch((error) => {
delete cache[encoding];
throw error;
});
}
return await cache[encoding];
}
export async function encodingForModel(model: TiktokenModel): Promise<Tiktoken> {
return await getEncoding(getEncodingNameForModel(model));
}
@@ -0,0 +1,176 @@
/**
* Token estimation utilities for handling text without using tiktoken.
* This is used as a fallback when tiktoken would be too slow (e.g., with repetitive content).
*/
import type { TiktokenModel } from 'js-tiktoken';
import { encodingForModel } from './tiktoken';
import { hasLongSequentialRepeat } from '../helpers';
/**
* Model-specific average characters per token ratios.
* These are approximate values based on typical English text.
*/
const MODEL_CHAR_PER_TOKEN_RATIOS: Record<string, number> = {
'gpt-4o': 3.8,
'gpt-4': 4.0,
'gpt-3.5-turbo': 4.0,
cl100k_base: 4.0,
o200k_base: 3.5,
p50k_base: 4.2,
r50k_base: 4.2,
};
/**
* Estimates the number of tokens in a text based on character count.
* This is much faster than tiktoken but less accurate.
*
* @param text The text to estimate tokens for
* @param model The model or encoding name (optional)
* @returns Estimated number of tokens
*/
export function estimateTokensByCharCount(text: string, model: string = 'cl100k_base'): number {
try {
// Validate input
if (!text || typeof text !== 'string' || text.length === 0) {
return 0;
}
// Get the ratio for the specific model, or use default
const charsPerToken = MODEL_CHAR_PER_TOKEN_RATIOS[model] || 4.0;
// Validate ratio
if (!Number.isFinite(charsPerToken) || charsPerToken <= 0) {
// Fallback to default ratio
const estimatedTokens = Math.ceil(text.length / 4.0);
return estimatedTokens;
}
// Calculate estimated tokens
const estimatedTokens = Math.ceil(text.length / charsPerToken);
return estimatedTokens;
} catch (error) {
// Return conservative estimate on error
return Math.ceil((text?.length || 0) / 4.0);
}
}
/**
* Estimates tokens for text splitting purposes.
* Returns chunk boundaries based on character positions rather than token positions.
*
* @param text The text to split
* @param chunkSize Target chunk size in tokens
* @param chunkOverlap Overlap between chunks in tokens
* @param model The model or encoding name (optional)
* @returns Array of text chunks
*/
export function estimateTextSplitsByTokens(
text: string,
chunkSize: number,
chunkOverlap: number,
model: string = 'cl100k_base',
): string[] {
try {
// Validate inputs
if (!text || typeof text !== 'string' || text.length === 0) {
return [];
}
// Validate numeric parameters
if (!Number.isFinite(chunkSize) || chunkSize <= 0) {
// Return whole text as single chunk if invalid chunk size
return [text];
}
// Ensure overlap is valid and less than chunk size
const validOverlap =
Number.isFinite(chunkOverlap) && chunkOverlap >= 0
? Math.min(chunkOverlap, chunkSize - 1)
: 0;
const charsPerToken = MODEL_CHAR_PER_TOKEN_RATIOS[model] || 4.0;
const chunkSizeInChars = Math.floor(chunkSize * charsPerToken);
const overlapInChars = Math.floor(validOverlap * charsPerToken);
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + chunkSizeInChars, text.length);
chunks.push(text.slice(start, end));
if (end >= text.length) {
break;
}
// Move to next chunk with overlap
start = Math.max(end - overlapInChars, start + 1);
}
return chunks;
} catch (error) {
// Return text as single chunk on error
return text ? [text] : [];
}
}
/**
* Estimates the total number of tokens for a list of strings.
* Uses tiktoken for normal text but falls back to character-based estimation
* for repetitive content or on errors.
*
* @param list Array of strings to estimate tokens for
* @param model The model or encoding name to use for estimation
* @returns Total estimated number of tokens across all strings
*/
export async function estimateTokensFromStringList(
list: string[],
model: TiktokenModel,
): Promise<number> {
try {
// Validate input
if (!Array.isArray(list)) {
return 0;
}
const encoder = await encodingForModel(model);
const encodedListLength = await Promise.all(
list.map(async (text) => {
try {
// Handle null/undefined text
if (!text || typeof text !== 'string') {
return 0;
}
// Check for repetitive content
if (hasLongSequentialRepeat(text)) {
const estimatedTokens = estimateTokensByCharCount(text, model);
return estimatedTokens;
}
// Use tiktoken for normal text
try {
const tokens = encoder.encode(text);
return tokens.length;
} catch (encodingError) {
// Fall back to estimation if tiktoken fails
return estimateTokensByCharCount(text, model);
}
} catch (itemError) {
// Return 0 for individual item errors
return 0;
}
}),
);
const totalTokens = encodedListLength.reduce((acc, curr) => acc + curr, 0);
return totalTokens;
} catch (error) {
// Return 0 on complete failure
return 0;
}
}
@@ -0,0 +1,89 @@
import type { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import type { Document } from '@langchain/core/documents';
import type { IMemoryCalculator } from './types';
// Memory estimation constants
const FLOAT_SIZE_BYTES = 8; // Size of a float64 in bytes
const CHAR_SIZE_BYTES = 2; // Size of a JavaScript character in bytes(2 bytes per character in UTF-16)
const VECTOR_OVERHEAD_BYTES = 200; // Estimated overhead per vector
const EMBEDDING_DIMENSIONS = 1536; // Fixed embedding dimensions
const EMBEDDING_SIZE_BYTES = EMBEDDING_DIMENSIONS * FLOAT_SIZE_BYTES;
const AVG_METADATA_SIZE_BYTES = 100; // Average size for simple metadata
/**
* Calculates memory usage for vector stores and documents
*/
export class MemoryCalculator implements IMemoryCalculator {
/**
* Fast batch size estimation for multiple documents
*/
estimateBatchSize(documents: Document[]): number {
if (documents.length === 0) return 0;
let totalContentSize = 0;
let totalMetadataSize = 0;
// Single pass through documents for content and metadata estimation
for (const doc of documents) {
if (doc.pageContent) {
totalContentSize += doc.pageContent.length * CHAR_SIZE_BYTES;
}
// Metadata size estimation
if (doc.metadata) {
// For simple objects, estimate based on key count
const metadataKeys = Object.keys(doc.metadata).length;
if (metadataKeys > 0) {
// For each key, estimate the key name plus a typical value
// plus some overhead for object structure
totalMetadataSize += metadataKeys * AVG_METADATA_SIZE_BYTES;
}
}
}
// Fixed size components (embedding vectors and overhead)
// Each embedding is a fixed-size array of floating point numbers
const embeddingSize = documents.length * EMBEDDING_SIZE_BYTES;
// Object overhead, each vector is stored with additional JS object structure
const overhead = documents.length * VECTOR_OVERHEAD_BYTES;
// Calculate total batch size with a safety factor to avoid underestimation
const calculatedSize = totalContentSize + totalMetadataSize + embeddingSize + overhead;
return Math.ceil(calculatedSize);
}
/**
* Calculate the size of a vector store by examining its contents
*/
calculateVectorStoreSize(vectorStore: MemoryVectorStore): number {
if (!vectorStore.memoryVectors || vectorStore.memoryVectors.length === 0) {
return 0;
}
let storeSize = 0;
// Calculate size of each vector
for (const vector of vectorStore.memoryVectors) {
// Size of embedding (float64 array)
storeSize += vector.embedding.length * FLOAT_SIZE_BYTES;
// Size of content string (2 bytes per character in JS)
storeSize += vector.content ? vector.content.length * CHAR_SIZE_BYTES : 0;
// Estimate metadata size
if (vector.metadata) {
// Use a more accurate calculation for metadata
const metadataStr = JSON.stringify(vector.metadata);
storeSize += metadataStr.length * CHAR_SIZE_BYTES;
}
// Add overhead for object structure
storeSize += VECTOR_OVERHEAD_BYTES;
}
return Math.ceil(storeSize);
}
}
@@ -0,0 +1,319 @@
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import type { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import type { OpenAIEmbeddings, AzureOpenAIEmbeddings } from '@langchain/openai';
import type { Logger } from 'n8n-workflow';
import { getConfig, mbToBytes, hoursToMs } from './config';
import { MemoryCalculator } from './MemoryCalculator';
import { StoreCleanupService } from './StoreCleanupService';
import type { VectorStoreMetadata, VectorStoreStats } from './types';
/**
* Manages in-memory vector stores with memory limits and auto-cleanup
*/
export class MemoryVectorStoreManager {
private static instance: MemoryVectorStoreManager | null = null;
// Storage
protected vectorStoreBuffer: Map<string, MemoryVectorStore>;
protected storeMetadata: Map<string, VectorStoreMetadata>;
protected memoryUsageBytes: number = 0;
// Dependencies
protected memoryCalculator: MemoryCalculator;
protected cleanupService: StoreCleanupService;
protected static logger: Logger;
// Config values
protected maxMemorySizeBytes: number;
protected inactiveTtlMs: number;
// Inactive TTL cleanup timer
protected ttlCleanupIntervalId: NodeJS.Timeout | null = null;
protected constructor(
protected embeddings: Embeddings | OpenAIEmbeddings | AzureOpenAIEmbeddings,
protected logger: Logger,
) {
// Initialize storage
this.vectorStoreBuffer = new Map();
this.storeMetadata = new Map();
this.logger = logger;
const config = getConfig();
this.maxMemorySizeBytes = mbToBytes(config.maxMemoryMB);
this.inactiveTtlMs = hoursToMs(config.ttlHours);
// Initialize services
this.memoryCalculator = new MemoryCalculator();
this.cleanupService = new StoreCleanupService(
this.maxMemorySizeBytes,
this.inactiveTtlMs,
this.vectorStoreBuffer,
this.storeMetadata,
this.handleCleanup.bind(this),
);
this.setupTtlCleanup();
}
/**
* Get singleton instance
*/
static getInstance(
embeddings: Embeddings | OpenAIEmbeddings | AzureOpenAIEmbeddings,
logger: Logger,
): MemoryVectorStoreManager {
if (!MemoryVectorStoreManager.instance) {
MemoryVectorStoreManager.instance = new MemoryVectorStoreManager(embeddings, logger);
} else {
// We need to update the embeddings in the existing instance.
// This is important as embeddings instance is wrapped in a logWrapper,
// which relies on supplyDataFunctions context which changes on each workflow run
MemoryVectorStoreManager.instance.embeddings = embeddings;
MemoryVectorStoreManager.instance.vectorStoreBuffer.forEach((vectorStoreInstance) => {
vectorStoreInstance.embeddings = embeddings;
});
}
return MemoryVectorStoreManager.instance;
}
/**
* Set up timer for TTL-based cleanup
*/
private setupTtlCleanup(): void {
// Skip setup if TTL is disabled
if (this.inactiveTtlMs <= 0) {
return;
}
// Cleanup check interval (run every hour)
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
// Clear any existing interval
if (this.ttlCleanupIntervalId) {
clearInterval(this.ttlCleanupIntervalId);
}
// Setup new interval for TTL cleanup
this.ttlCleanupIntervalId = setInterval(() => {
this.cleanupService.cleanupInactiveStores();
}, CLEANUP_INTERVAL_MS);
}
/**
* Handle cleanup events from the cleanup service
*/
private handleCleanup(removedKeys: string[], freedBytes: number, reason: 'ttl' | 'memory'): void {
// Update total memory usage
this.memoryUsageBytes -= freedBytes;
// Log cleanup event
if (reason === 'ttl') {
const ttlHours = Math.round(this.inactiveTtlMs / (60 * 60 * 1000));
this.logger.info(
`TTL cleanup: removed ${removedKeys.length} inactive vector stores (${ttlHours}h TTL) to free ${Math.round(freedBytes / (1024 * 1024))}MB of memory`,
);
} else {
this.logger.info(
`Memory cleanup: removed ${removedKeys.length} oldest vector stores to free ${Math.round(freedBytes / (1024 * 1024))}MB of memory`,
);
}
}
getMemoryKeysList(): string[] {
return Array.from(this.vectorStoreBuffer.keys());
}
/**
* Get or create a vector store by key
*/
async getVectorStore(memoryKey: string): Promise<MemoryVectorStore> {
let vectorStoreInstance = this.vectorStoreBuffer.get(memoryKey);
if (!vectorStoreInstance) {
vectorStoreInstance = await MemoryVectorStore.fromExistingIndex(this.embeddings);
this.vectorStoreBuffer.set(memoryKey, vectorStoreInstance);
this.storeMetadata.set(memoryKey, {
size: 0,
createdAt: new Date(),
lastAccessed: new Date(),
});
} else {
const metadata = this.storeMetadata.get(memoryKey);
if (metadata) {
metadata.lastAccessed = new Date();
}
}
return vectorStoreInstance;
}
/**
* Reset a store's metadata when it's cleared
*/
protected clearStoreMetadata(memoryKey: string): void {
const metadata = this.storeMetadata.get(memoryKey);
if (metadata) {
this.memoryUsageBytes -= metadata.size;
metadata.size = 0;
metadata.lastAccessed = new Date();
}
}
/**
* Get memory usage in bytes
*/
getMemoryUsage(): number {
return this.memoryUsageBytes;
}
/**
* Get memory usage as a formatted string (MB)
*/
getMemoryUsageFormatted(): string {
return `${Math.round(this.memoryUsageBytes / (1024 * 1024))}MB`;
}
/**
* Recalculate memory usage from actual vector store contents
* This ensures tracking accuracy for large stores
*/
recalculateMemoryUsage(): void {
this.memoryUsageBytes = 0;
// Recalculate for each store
for (const [key, vectorStore] of this.vectorStoreBuffer.entries()) {
const storeSize = this.memoryCalculator.calculateVectorStoreSize(vectorStore);
// Update metadata
const metadata = this.storeMetadata.get(key);
if (metadata) {
metadata.size = storeSize;
this.memoryUsageBytes += storeSize;
}
}
this.logger.debug(`Recalculated vector store memory: ${this.getMemoryUsageFormatted()}`);
}
/**
* Add documents to a vector store
*/
async addDocuments(
memoryKey: string,
documents: Document[],
clearStore?: boolean,
): Promise<void> {
if (clearStore) {
this.clearStoreMetadata(memoryKey);
this.vectorStoreBuffer.delete(memoryKey);
}
// Fast batch estimation instead of per-document calculation
const estimatedAddedSize = this.memoryCalculator.estimateBatchSize(documents);
// Clean up old stores if necessary
this.cleanupService.cleanupOldestStores(estimatedAddedSize);
const vectorStoreInstance = await this.getVectorStore(memoryKey);
// Get vector count before adding documents
const vectorCountBefore = vectorStoreInstance.memoryVectors?.length || 0;
await vectorStoreInstance.addDocuments(documents);
// Update store metadata and memory tracking
const metadata = this.storeMetadata.get(memoryKey);
if (metadata) {
metadata.size += estimatedAddedSize;
metadata.lastAccessed = new Date();
this.memoryUsageBytes += estimatedAddedSize;
}
// Get updated vector count
const vectorCount = vectorStoreInstance.memoryVectors?.length || 0;
// Periodically recalculate actual memory usage to avoid drift
if (
(vectorCount > 0 && vectorCount % 100 === 0) ||
documents.length > 20 ||
(vectorCountBefore === 0 && vectorCount > 0)
) {
this.recalculateMemoryUsage();
}
// Logging memory usage
const maxMemoryMB =
this.maxMemorySizeBytes > 0
? (this.maxMemorySizeBytes / (1024 * 1024)).toFixed(0)
: 'unlimited';
this.logger.debug(
`Vector store memory: ${this.getMemoryUsageFormatted()}/${maxMemoryMB}MB (${vectorCount} vectors in ${this.vectorStoreBuffer.size} stores)`,
);
}
/**
* Get statistics about the vector store memory usage
*/
getStats(): VectorStoreStats {
const now = Date.now();
let inactiveStoreCount = 0;
// Always recalculate when getting stats to ensure accuracy
this.recalculateMemoryUsage();
const stats: VectorStoreStats = {
totalSizeBytes: this.memoryUsageBytes,
totalSizeMB: Math.round((this.memoryUsageBytes / (1024 * 1024)) * 100) / 100,
percentOfLimit:
this.maxMemorySizeBytes > 0
? Math.round((this.memoryUsageBytes / this.maxMemorySizeBytes) * 100)
: 0,
maxMemoryMB: this.maxMemorySizeBytes > 0 ? this.maxMemorySizeBytes / (1024 * 1024) : -1, // -1 indicates unlimited
storeCount: this.vectorStoreBuffer.size,
inactiveStoreCount: 0,
ttlHours: this.inactiveTtlMs > 0 ? this.inactiveTtlMs / (60 * 60 * 1000) : -1, // -1 indicates disabled
stores: {},
};
// Add stats for each store
for (const [key, metadata] of this.storeMetadata.entries()) {
const store = this.vectorStoreBuffer.get(key);
if (store) {
const lastAccessedTime = metadata.lastAccessed.getTime();
const inactiveTimeMs = now - lastAccessedTime;
const isInactive = this.cleanupService.isStoreInactive(metadata);
if (isInactive) {
inactiveStoreCount++;
}
stats.stores[key] = {
sizeBytes: metadata.size,
sizeMB: Math.round((metadata.size / (1024 * 1024)) * 100) / 100,
percentOfTotal: Math.round((metadata.size / this.memoryUsageBytes) * 100) || 0,
vectors: store.memoryVectors?.length || 0,
createdAt: metadata.createdAt.toISOString(),
lastAccessed: metadata.lastAccessed.toISOString(),
inactive: isInactive,
inactiveForHours: Math.round(inactiveTimeMs / (60 * 60 * 1000)),
};
}
}
stats.inactiveStoreCount = inactiveStoreCount;
return stats;
}
}
@@ -0,0 +1,157 @@
import type { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import type { VectorStoreMetadata, IStoreCleanupService } from './types';
/**
* Service for cleaning up vector stores based on inactivity or memory pressure
*/
export class StoreCleanupService implements IStoreCleanupService {
// Cache for oldest stores sorted by creation time
private oldestStoreKeys: string[] = [];
private lastSortTime = 0;
private readonly CACHE_TTL_MS = 5000; // 5 seconds
constructor(
private readonly maxMemorySizeBytes: number,
private readonly inactiveTtlMs: number,
private readonly vectorStores: Map<string, MemoryVectorStore>,
private readonly storeMetadata: Map<string, VectorStoreMetadata>,
private readonly onCleanup: (
removedKeys: string[],
freedBytes: number,
reason: 'ttl' | 'memory',
) => void,
) {}
/**
* Check if a store has been inactive for longer than the TTL
*/
isStoreInactive(metadata: VectorStoreMetadata): boolean {
// If TTL is disabled, nothing is considered inactive
if (this.inactiveTtlMs <= 0) {
return false;
}
const now = Date.now();
const lastAccessedTime = metadata.lastAccessed.getTime();
return now - lastAccessedTime > this.inactiveTtlMs;
}
/**
* Remove vector stores that haven't been accessed for longer than TTL
*/
cleanupInactiveStores(): void {
// Skip if TTL is disabled
if (this.inactiveTtlMs <= 0) {
return;
}
let freedBytes = 0;
const removedStores: string[] = [];
// Find and remove inactive stores
for (const [key, metadata] of this.storeMetadata.entries()) {
if (this.isStoreInactive(metadata)) {
// Remove this inactive store
this.vectorStores.delete(key);
freedBytes += metadata.size;
removedStores.push(key);
}
}
// Remove from metadata after iteration to avoid concurrent modification
for (const key of removedStores) {
this.storeMetadata.delete(key);
}
// Invalidate cache if we removed any stores
if (removedStores.length > 0) {
this.oldestStoreKeys = [];
this.onCleanup(removedStores, freedBytes, 'ttl');
}
}
/**
* Remove the oldest vector stores to free up memory
*/
cleanupOldestStores(requiredBytes: number): void {
// Skip if memory limit is disabled
if (this.maxMemorySizeBytes <= 0) {
return;
}
// Calculate current total memory usage
let currentMemoryUsage = 0;
for (const metadata of this.storeMetadata.values()) {
currentMemoryUsage += metadata.size;
}
// First, try to clean up inactive stores
this.cleanupInactiveStores();
// Recalculate memory usage after inactive cleanup
currentMemoryUsage = 0;
for (const metadata of this.storeMetadata.values()) {
currentMemoryUsage += metadata.size;
}
// If no more cleanup needed, return early
if (currentMemoryUsage + requiredBytes <= this.maxMemorySizeBytes) {
return;
}
const now = Date.now();
// Reuse cached ordering if available and not stale
if (this.oldestStoreKeys.length === 0 || now - this.lastSortTime > this.CACHE_TTL_MS) {
// Collect and sort store keys by age
const stores: Array<[string, number]> = [];
for (const [key, metadata] of this.storeMetadata.entries()) {
stores.push([key, metadata.createdAt.getTime()]);
}
// Sort by creation time (oldest first)
stores.sort((a, b) => a[1] - b[1]);
// Extract just the keys
this.oldestStoreKeys = stores.map(([key]) => key);
this.lastSortTime = now;
}
let freedBytes = 0;
const removedStores: string[] = [];
// Remove stores in order until we have enough space
for (const key of this.oldestStoreKeys) {
// Skip if store no longer exists
if (!this.storeMetadata.has(key)) continue;
// Stop if we've freed enough space
if (currentMemoryUsage - freedBytes + requiredBytes <= this.maxMemorySizeBytes) {
break;
}
const metadata = this.storeMetadata.get(key);
if (metadata) {
this.vectorStores.delete(key);
freedBytes += metadata.size;
removedStores.push(key);
}
}
// Remove from metadata after iteration to avoid concurrent modification
for (const key of removedStores) {
this.storeMetadata.delete(key);
}
// Update our cache if we removed stores
if (removedStores.length > 0) {
// Filter out removed stores from cached keys
this.oldestStoreKeys = this.oldestStoreKeys.filter((key) => !removedStores.includes(key));
this.onCleanup(removedStores, freedBytes, 'memory');
}
}
}
@@ -0,0 +1,51 @@
import type { MemoryVectorStoreConfig } from './types';
// Defaults
const DEFAULT_MAX_MEMORY_MB = -1;
const DEFAULT_INACTIVE_TTL_HOURS = -1;
/**
* Helper function to get the configuration from environment variables
*/
export function getConfig(): MemoryVectorStoreConfig {
// Get memory limit from env var or use default
let maxMemoryMB = DEFAULT_MAX_MEMORY_MB;
if (process.env.N8N_VECTOR_STORE_MAX_MEMORY) {
const parsed = parseInt(process.env.N8N_VECTOR_STORE_MAX_MEMORY, 10);
if (!isNaN(parsed)) {
maxMemoryMB = parsed;
}
}
// Get TTL from env var or use default
let ttlHours = DEFAULT_INACTIVE_TTL_HOURS;
if (process.env.N8N_VECTOR_STORE_TTL_HOURS) {
const parsed = parseInt(process.env.N8N_VECTOR_STORE_TTL_HOURS, 10);
if (!isNaN(parsed)) {
ttlHours = parsed;
}
}
return {
maxMemoryMB,
ttlHours,
};
}
/**
* Convert memory size from MB to bytes
*/
export function mbToBytes(mb: number): number {
// -1 - "unlimited"
if (mb <= 0) return -1;
return mb * 1024 * 1024;
}
/**
* Convert TTL from hours to milliseconds
*/
export function hoursToMs(hours: number): number {
// -1 - "disabled"
if (hours <= 0) return -1;
return hours * 60 * 60 * 1000;
}
@@ -0,0 +1,202 @@
import type { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import { Document } from '@langchain/core/documents';
import { mock } from 'jest-mock-extended';
import { MemoryCalculator } from '../MemoryCalculator';
function createTestEmbedding(dimensions = 1536, initialValue = 0.1, multiplier = 1): number[] {
return new Array(dimensions).fill(initialValue).map((value) => value * multiplier);
}
describe('MemoryCalculator', () => {
let calculator: MemoryCalculator;
beforeEach(() => {
calculator = new MemoryCalculator();
});
describe('estimateBatchSize', () => {
it('should return 0 for empty document arrays', () => {
const size = calculator.estimateBatchSize([]);
expect(size).toBe(0);
});
it('should calculate size for simple documents', () => {
const documents = [
new Document({ pageContent: 'Hello, world!', metadata: { simple: 'value' } }),
];
const size = calculator.estimateBatchSize(documents);
expect(size).toBeGreaterThan(0);
// The size should account for the content, metadata, embedding size, and overhead
const simpleCase = calculator.estimateBatchSize([
new Document({ pageContent: '', metadata: {} }),
]);
const withContent = calculator.estimateBatchSize([
new Document({ pageContent: 'test content', metadata: {} }),
]);
const withMetadata = calculator.estimateBatchSize([
new Document({ pageContent: '', metadata: { key: 'value' } }),
]);
// Content should increase size
expect(withContent).toBeGreaterThan(simpleCase);
// Metadata should increase size
expect(withMetadata).toBeGreaterThan(simpleCase);
});
it('should account for content length in size calculation', () => {
const shortDoc = new Document({
pageContent: 'Short content',
metadata: {},
});
const longDoc = new Document({
pageContent: 'A'.repeat(1000),
metadata: {},
});
const shortSize = calculator.estimateBatchSize([shortDoc]);
const longSize = calculator.estimateBatchSize([longDoc]);
// Long content should result in a larger size estimate
expect(longSize).toBeGreaterThan(shortSize);
expect(longSize - shortSize).toBeGreaterThan(1000);
});
it('should account for metadata complexity in size calculation', () => {
const simpleMetadata = new Document({
pageContent: '',
metadata: { simple: 'value' },
});
const complexMetadata = new Document({
pageContent: '',
metadata: {
nested: {
objects: {
with: {
many: {
levels: [1, 2, 3, 4, 5],
andArray: ['a', 'b', 'c', 'd', 'e'],
},
},
},
},
moreKeys: 'moreValues',
evenMore: 'data',
},
});
const simpleSize = calculator.estimateBatchSize([simpleMetadata]);
const complexSize = calculator.estimateBatchSize([complexMetadata]);
// Complex metadata should result in a larger size estimate
expect(complexSize).toBeGreaterThan(simpleSize);
});
it('should scale with the number of documents', () => {
const doc = new Document({ pageContent: 'Sample content', metadata: { key: 'value' } });
const singleSize = calculator.estimateBatchSize([doc]);
const doubleSize = calculator.estimateBatchSize([doc, doc]);
const tripleSize = calculator.estimateBatchSize([doc, doc, doc]);
// Size should scale roughly linearly with document count
expect(doubleSize).toBeGreaterThan(singleSize * 1.5); // Allow for some overhead
expect(tripleSize).toBeGreaterThan(doubleSize * 1.3); // Allow for some overhead
});
});
describe('calculateVectorStoreSize', () => {
it('should return 0 for empty vector stores', () => {
const mockVectorStore = mock<MemoryVectorStore>();
const size = calculator.calculateVectorStoreSize(mockVectorStore);
expect(size).toBe(0);
});
it('should calculate size for vector stores with content', () => {
const mockVectorStore = mock<MemoryVectorStore>();
mockVectorStore.memoryVectors = [
{
embedding: createTestEmbedding(), // Using the helper function
content: 'Document content',
metadata: { simple: 'value' },
},
];
const size = calculator.calculateVectorStoreSize(mockVectorStore);
// Size should account for the embedding, content, metadata, and overhead
expect(size).toBeGreaterThan(1536 * 8); // At least the size of the embedding in bytes
});
it('should account for vector count in size calculation', () => {
const singleVector = mock<MemoryVectorStore>();
singleVector.memoryVectors = [
{
embedding: createTestEmbedding(),
content: 'Content',
metadata: {},
},
];
const multiVector = mock<MemoryVectorStore>();
multiVector.memoryVectors = [
{
embedding: createTestEmbedding(),
content: 'Content',
metadata: {},
},
{
embedding: createTestEmbedding(),
content: 'Content',
metadata: {},
},
{
embedding: createTestEmbedding(),
content: 'Content',
metadata: {},
},
];
const singleSize = calculator.calculateVectorStoreSize(singleVector);
const multiSize = calculator.calculateVectorStoreSize(multiVector);
// Multi-vector store should be about 3x the size
expect(multiSize).toBeGreaterThan(singleSize * 2.5);
expect(multiSize).toBeLessThan(singleSize * 3.5);
});
it('should handle vectors with no content or metadata', () => {
const vectorStore = mock<MemoryVectorStore>();
vectorStore.memoryVectors = [
{
embedding: createTestEmbedding(),
content: '',
metadata: {},
},
];
const size = calculator.calculateVectorStoreSize(vectorStore);
// Size should still be positive (at least the embedding size)
expect(size).toBeGreaterThan(1536 * 8);
});
it('should handle null or undefined vector arrays', () => {
const nullVectorStore = mock<MemoryVectorStore>();
nullVectorStore.memoryVectors = [];
const undefinedVectorStore = mock<MemoryVectorStore>();
undefinedVectorStore.memoryVectors = [];
expect(calculator.calculateVectorStoreSize(nullVectorStore)).toBe(0);
expect(calculator.calculateVectorStoreSize(undefinedVectorStore)).toBe(0);
});
});
});
@@ -0,0 +1,278 @@
import type { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import { Document } from '@langchain/core/documents';
import type { OpenAIEmbeddings } from '@langchain/openai';
import { mock } from 'jest-mock-extended';
import type { Logger } from 'n8n-workflow';
import * as configModule from '../config';
import { MemoryVectorStoreManager } from '../MemoryVectorStoreManager';
function createTestEmbedding(dimensions = 1536, initialValue = 0.1, multiplier = 1): number[] {
return new Array(dimensions).fill(initialValue).map((value) => value * multiplier);
}
jest.mock('@langchain/classic/vectorstores/memory', () => {
return {
MemoryVectorStore: {
fromExistingIndex: jest.fn().mockImplementation(() => {
return {
embeddings: null,
addDocuments: jest.fn(),
memoryVectors: [],
};
}),
},
};
});
describe('MemoryVectorStoreManager', () => {
let logger: Logger;
// Reset the singleton instance before each test
beforeEach(() => {
jest.clearAllMocks();
logger = mock<Logger>();
MemoryVectorStoreManager['instance'] = null;
jest.useFakeTimers();
// Mock the config
jest.spyOn(configModule, 'getConfig').mockReturnValue({
maxMemoryMB: 100,
ttlHours: 168,
});
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('should create an instance of MemoryVectorStoreManager', () => {
const embeddings = mock<OpenAIEmbeddings>();
const instance = MemoryVectorStoreManager.getInstance(embeddings, logger);
expect(instance).toBeInstanceOf(MemoryVectorStoreManager);
});
it('should return existing instance', () => {
const embeddings = mock<OpenAIEmbeddings>();
const instance1 = MemoryVectorStoreManager.getInstance(embeddings, logger);
const instance2 = MemoryVectorStoreManager.getInstance(embeddings, logger);
expect(instance1).toBe(instance2);
});
it('should update embeddings in existing instance', () => {
const embeddings1 = mock<OpenAIEmbeddings>();
const embeddings2 = mock<OpenAIEmbeddings>();
const instance = MemoryVectorStoreManager.getInstance(embeddings1, logger);
MemoryVectorStoreManager.getInstance(embeddings2, logger);
expect(instance['embeddings']).toBe(embeddings2);
});
it('should update embeddings in existing vector store instances', async () => {
const embeddings1 = mock<OpenAIEmbeddings>();
const embeddings2 = mock<OpenAIEmbeddings>();
const instance1 = MemoryVectorStoreManager.getInstance(embeddings1, logger);
await instance1.getVectorStore('test');
const instance2 = MemoryVectorStoreManager.getInstance(embeddings2, logger);
const vectorStoreInstance2 = await instance2.getVectorStore('test');
expect(vectorStoreInstance2.embeddings).toBe(embeddings2);
});
it('should set up the TTL cleanup interval', () => {
jest.spyOn(global, 'setInterval');
const embeddings = mock<OpenAIEmbeddings>();
MemoryVectorStoreManager.getInstance(embeddings, logger);
expect(setInterval).toHaveBeenCalled();
});
it('should not set up the TTL cleanup interval when TTL is disabled', () => {
jest.spyOn(configModule, 'getConfig').mockReturnValue({
maxMemoryMB: 100,
ttlHours: -1, // TTL disabled
});
jest.spyOn(global, 'setInterval');
const embeddings = mock<OpenAIEmbeddings>();
MemoryVectorStoreManager.getInstance(embeddings, logger);
expect(setInterval).not.toHaveBeenCalled();
});
it('should track memory usage when adding documents', async () => {
const embeddings = mock<OpenAIEmbeddings>();
const instance = MemoryVectorStoreManager.getInstance(embeddings, logger);
const calculatorSpy = jest
.spyOn(instance['memoryCalculator'], 'estimateBatchSize')
.mockReturnValue(1024 * 1024); // Mock 1MB size
const documents = [new Document({ pageContent: 'test document', metadata: { test: 'value' } })];
await instance.addDocuments('test-key', documents);
expect(calculatorSpy).toHaveBeenCalledWith(documents);
expect(instance.getMemoryUsage()).toBe(1024 * 1024); // Should be 1MB
});
it('should clear store metadata when clearing store', async () => {
const embeddings = mock<OpenAIEmbeddings>();
const instance = MemoryVectorStoreManager.getInstance(embeddings, logger);
// Directly set memory usage to 0 to start with a clean state
instance['memoryUsageBytes'] = 0;
// Add documents to create a store
const docs = [new Document({ pageContent: 'test', metadata: {} })];
jest.spyOn(instance['memoryCalculator'], 'estimateBatchSize').mockReturnValue(1000);
await instance.addDocuments('test-key', docs);
expect(instance.getMemoryUsage()).toBe(1000);
// Directly access the metadata to verify clearing works
const metadataSizeBefore = instance['storeMetadata'].get('test-key')?.size;
expect(metadataSizeBefore).toBe(1000);
// Now clear the store by calling the private method directly
instance['clearStoreMetadata']('test-key');
// Verify metadata was reset
const metadataSizeAfter = instance['storeMetadata'].get('test-key')?.size;
expect(metadataSizeAfter).toBe(0);
// The memory usage should be reduced
expect(instance.getMemoryUsage()).toBe(0);
});
it('should request cleanup when adding documents that would exceed memory limit', async () => {
const embeddings = mock<OpenAIEmbeddings>();
const instance = MemoryVectorStoreManager.getInstance(embeddings, logger);
// Spy on the cleanup service
const cleanupSpy = jest.spyOn(instance['cleanupService'], 'cleanupOldestStores');
// Set up a large document batch
const documents = [new Document({ pageContent: 'test', metadata: {} })];
jest.spyOn(instance['memoryCalculator'], 'estimateBatchSize').mockReturnValue(50 * 1024 * 1024); // 50MB
await instance.addDocuments('test-key', documents);
expect(cleanupSpy).toHaveBeenCalledWith(50 * 1024 * 1024);
});
it('should recalculate memory usage periodically', async () => {
const embeddings = mock<OpenAIEmbeddings>();
const instance = MemoryVectorStoreManager.getInstance(embeddings, logger);
// Mock methods and spies
const recalcSpy = jest.spyOn(instance, 'recalculateMemoryUsage');
const mockVectorStore = mock<MemoryVectorStore>();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
mockVectorStore.memoryVectors = new Array(100).fill({
embedding: createTestEmbedding(),
content: 'test',
metadata: {},
});
// Mock the getVectorStore to return our mock
jest.spyOn(instance, 'getVectorStore').mockResolvedValue(mockVectorStore);
jest.spyOn(instance['memoryCalculator'], 'estimateBatchSize').mockReturnValue(1000);
// Add a large batch of documents
const documents = new Array(21).fill(new Document({ pageContent: 'test', metadata: {} }));
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
await instance.addDocuments('test-key', documents);
expect(recalcSpy).toHaveBeenCalled();
});
it('should provide accurate stats about vector stores', async () => {
const embeddings = mock<OpenAIEmbeddings>();
const instance = MemoryVectorStoreManager.getInstance(embeddings, logger);
// Create mock vector stores
const mockVectorStore1 = mock<MemoryVectorStore>();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
mockVectorStore1.memoryVectors = new Array(50).fill({
embedding: createTestEmbedding(),
content: 'test1',
metadata: {},
});
const mockVectorStore2 = mock<MemoryVectorStore>();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
mockVectorStore2.memoryVectors = new Array(30).fill({
embedding: createTestEmbedding(),
content: 'test2',
metadata: {},
});
// Mock internal state
instance['vectorStoreBuffer'].set('store1', mockVectorStore1);
instance['vectorStoreBuffer'].set('store2', mockVectorStore2);
// Set metadata for the stores
instance['storeMetadata'].set('store1', {
size: 1024 * 1024, // 1MB
createdAt: new Date(Date.now() - 3600000), // 1 hour ago
lastAccessed: new Date(Date.now() - 1800000), // 30 minutes ago
});
instance['storeMetadata'].set('store2', {
size: 512 * 1024, // 0.5MB
createdAt: new Date(Date.now() - 7200000), // 2 hours ago
lastAccessed: new Date(Date.now() - 3600000), // 1 hour ago
});
// Set memory usage
instance['memoryUsageBytes'] = 1024 * 1024 + 512 * 1024;
const stats = instance.getStats();
expect(stats.storeCount).toBe(2);
expect(stats.totalSizeBytes).toBeGreaterThan(0);
expect(Object.keys(stats.stores)).toContain('store1');
expect(Object.keys(stats.stores)).toContain('store2');
expect(stats.stores.store1.vectors).toBe(50);
expect(stats.stores.store2.vectors).toBe(30);
});
it('should list all vector stores', async () => {
const embeddings = mock<OpenAIEmbeddings>();
const instance = MemoryVectorStoreManager.getInstance(embeddings, logger);
const mockVectorStore1 = mock<MemoryVectorStore>();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
mockVectorStore1.memoryVectors = new Array(50).fill({
embedding: createTestEmbedding(),
content: 'test1',
metadata: {},
});
const mockVectorStore2 = mock<MemoryVectorStore>();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
mockVectorStore2.memoryVectors = new Array(30).fill({
embedding: createTestEmbedding(),
content: 'test2',
metadata: {},
});
// Mock internal state
instance['vectorStoreBuffer'].set('store1', mockVectorStore1);
instance['vectorStoreBuffer'].set('store2', mockVectorStore2);
const list = instance.getMemoryKeysList();
expect(list).toHaveLength(2);
expect(list[0]).toBe('store1');
expect(list[1]).toBe('store2');
});
});
@@ -0,0 +1,288 @@
import type { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import { mock } from 'jest-mock-extended';
import { StoreCleanupService } from '../StoreCleanupService';
import type { VectorStoreMetadata } from '../types';
describe('StoreCleanupService', () => {
// Setup test data
let vectorStores: Map<string, MemoryVectorStore>;
let storeMetadata: Map<string, VectorStoreMetadata>;
let onCleanupMock: jest.Mock;
// Utility to add a test store with given age
const addTestStore = (
key: string,
sizeBytes: number,
createdHoursAgo: number,
accessedHoursAgo: number,
) => {
const mockStore = mock<MemoryVectorStore>();
vectorStores.set(key, mockStore);
const now = Date.now();
storeMetadata.set(key, {
size: sizeBytes,
createdAt: new Date(now - createdHoursAgo * 3600000),
lastAccessed: new Date(now - accessedHoursAgo * 3600000),
});
};
beforeEach(() => {
vectorStores = new Map();
storeMetadata = new Map();
onCleanupMock = jest.fn();
});
describe('TTL-based cleanup', () => {
it('should identify inactive stores correctly', () => {
const service = new StoreCleanupService(
100 * 1024 * 1024, // 100MB max
24 * 3600 * 1000, // 24 hours TTL
vectorStores,
storeMetadata,
onCleanupMock,
);
// Create test metadata
const recentMetadata: VectorStoreMetadata = {
size: 1024,
createdAt: new Date(Date.now() - 48 * 3600 * 1000), // 48 hours ago
lastAccessed: new Date(Date.now() - 12 * 3600 * 1000), // 12 hours ago
};
const inactiveMetadata: VectorStoreMetadata = {
size: 1024,
createdAt: new Date(Date.now() - 48 * 3600 * 1000), // 48 hours ago
lastAccessed: new Date(Date.now() - 36 * 3600 * 1000), // 36 hours ago
};
// Test the inactive check
expect(service.isStoreInactive(recentMetadata)).toBe(false);
expect(service.isStoreInactive(inactiveMetadata)).toBe(true);
});
it('should never identify stores as inactive when TTL is disabled', () => {
const service = new StoreCleanupService(
100 * 1024 * 1024, // 100MB max
-1, // TTL disabled
vectorStores,
storeMetadata,
onCleanupMock,
);
// Create very old metadata
const veryOldMetadata: VectorStoreMetadata = {
size: 1024,
createdAt: new Date(Date.now() - 365 * 24 * 3600 * 1000), // 1 year ago
lastAccessed: new Date(Date.now() - 365 * 24 * 3600 * 1000), // 1 year ago
};
// Should never be inactive when TTL is disabled
expect(service.isStoreInactive(veryOldMetadata)).toBe(false);
});
it('should clean up inactive stores', () => {
const service = new StoreCleanupService(
100 * 1024 * 1024, // 100MB max
24 * 3600 * 1000, // 24 hours TTL
vectorStores,
storeMetadata,
onCleanupMock,
);
// Add active and inactive stores
addTestStore('active1', 1024 * 1024, 48, 12); // 48 hours old, accessed 12 hours ago
addTestStore('active2', 2048 * 1024, 72, 20); // 72 hours old, accessed 20 hours ago
addTestStore('inactive1', 3072 * 1024, 100, 30); // 100 hours old, accessed 30 hours ago
addTestStore('inactive2', 4096 * 1024, 120, 48); // 120 hours old, accessed 48 hours ago
// Run cleanup
service.cleanupInactiveStores();
// Check which stores were cleaned up
expect(vectorStores.has('active1')).toBe(true);
expect(vectorStores.has('active2')).toBe(true);
expect(vectorStores.has('inactive1')).toBe(false);
expect(vectorStores.has('inactive2')).toBe(false);
// Metadata should also be cleaned up
expect(storeMetadata.has('active1')).toBe(true);
expect(storeMetadata.has('active2')).toBe(true);
expect(storeMetadata.has('inactive1')).toBe(false);
expect(storeMetadata.has('inactive2')).toBe(false);
// Check callback was called correctly
expect(onCleanupMock).toHaveBeenCalledWith(
expect.arrayContaining(['inactive1', 'inactive2']),
7168 * 1024, // sum of inactive store sizes
'ttl',
);
});
it('should not run TTL cleanup when disabled', () => {
const service = new StoreCleanupService(
100 * 1024 * 1024, // 100MB max
-1, // TTL disabled
vectorStores,
storeMetadata,
onCleanupMock,
);
// Add all "inactive" stores
addTestStore('store1', 1024 * 1024, 48, 30);
addTestStore('store2', 2048 * 1024, 72, 48);
// Run cleanup
service.cleanupInactiveStores();
// Nothing should be cleaned up
expect(vectorStores.size).toBe(2);
expect(storeMetadata.size).toBe(2);
expect(onCleanupMock).not.toHaveBeenCalled();
});
});
describe('Memory-based cleanup', () => {
it('should clean up oldest stores to make room for new data', () => {
const maxMemoryBytes = 10 * 1024 * 1024; // 10MB
const service = new StoreCleanupService(
maxMemoryBytes,
24 * 3600 * 1000, // 24 hours TTL
vectorStores,
storeMetadata,
onCleanupMock,
);
// Add stores with different creation times
addTestStore('newest', 2 * 1024 * 1024, 1, 1); // 2MB, 1 hour old
addTestStore('newer', 3 * 1024 * 1024, 2, 1); // 3MB, 2 hours old
addTestStore('older', 3 * 1024 * 1024, 3, 1); // 3MB, 3 hours old
addTestStore('oldest', 2 * 1024 * 1024, 4, 1); // 2MB, 4 hours old
// Current total: 10MB
// Try to add 5MB more
service.cleanupOldestStores(5 * 1024 * 1024);
// Should have removed oldest and older (5MB total)
expect(vectorStores.has('newest')).toBe(true);
expect(vectorStores.has('newer')).toBe(true);
expect(vectorStores.has('older')).toBe(false);
expect(vectorStores.has('oldest')).toBe(false);
// Check callback
expect(onCleanupMock).toHaveBeenCalledWith(
expect.arrayContaining(['older', 'oldest']),
5 * 1024 * 1024,
'memory',
);
});
it('should run TTL cleanup before memory cleanup', () => {
const maxMemoryBytes = 10 * 1024 * 1024; // 10MB
const service = new StoreCleanupService(
maxMemoryBytes,
24 * 3600 * 1000, // 24 hours TTL
vectorStores,
storeMetadata,
onCleanupMock,
);
// Add a mix of active and inactive stores
addTestStore('active-newest', 2 * 1024 * 1024, 1, 1); // 2MB, active
addTestStore('active-older', 3 * 1024 * 1024, 3, 12); // 3MB, active
addTestStore('inactive', 3 * 1024 * 1024, 3, 30); // 3MB, inactive (30h)
addTestStore('active-oldest', 2 * 1024 * 1024, 4, 20); // 2MB, active
// Total: 10MB, with 3MB inactive
// Try to add 5MB more
service.cleanupOldestStores(5 * 1024 * 1024);
// Should have removed inactive first, then active-oldest (5MB total)
expect(vectorStores.has('active-newest')).toBe(true);
expect(vectorStores.has('active-older')).toBe(true);
expect(vectorStores.has('inactive')).toBe(false);
expect(vectorStores.has('active-oldest')).toBe(false);
// Check callbacks
expect(onCleanupMock).toHaveBeenCalledTimes(2);
// First call for TTL cleanup
expect(onCleanupMock).toHaveBeenNthCalledWith(1, ['inactive'], 3 * 1024 * 1024, 'ttl');
// Second call for memory cleanup
expect(onCleanupMock).toHaveBeenNthCalledWith(
2,
['active-oldest'],
2 * 1024 * 1024,
'memory',
);
});
it('should not perform memory cleanup when limit is disabled', () => {
const service = new StoreCleanupService(
-1, // Memory limit disabled
24 * 3600 * 1000, // 24 hours TTL
vectorStores,
storeMetadata,
onCleanupMock,
);
// Add some stores
addTestStore('store1', 5 * 1024 * 1024, 1, 1);
addTestStore('store2', 10 * 1024 * 1024, 2, 1);
// Try to add a lot more data
service.cleanupOldestStores(100 * 1024 * 1024);
// Nothing should be cleaned up
expect(vectorStores.size).toBe(2);
expect(storeMetadata.size).toBe(2);
expect(onCleanupMock).not.toHaveBeenCalled();
});
it('should handle empty stores during cleanup', () => {
const service = new StoreCleanupService(
10 * 1024 * 1024, // 10MB
24 * 3600 * 1000, // 24 hours TTL
vectorStores,
storeMetadata,
onCleanupMock,
);
service.cleanupOldestStores(5 * 1024 * 1024);
service.cleanupInactiveStores();
expect(onCleanupMock).not.toHaveBeenCalled();
});
it('should update the cache when stores are removed', () => {
const service = new StoreCleanupService(
10 * 1024 * 1024, // 10MB
24 * 3600 * 1000, // 24 hours TTL
vectorStores,
storeMetadata,
onCleanupMock,
);
// Add test stores
addTestStore('newest', 2 * 1024 * 1024, 1, 1);
addTestStore('middle', 3 * 1024 * 1024, 3, 1);
addTestStore('oldest', 4 * 1024 * 1024, 5, 1);
// Trigger a cleanup that will remove only the oldest store
service.cleanupOldestStores(4 * 1024 * 1024); // 4MB
// Verify removal
expect(vectorStores.has('oldest')).toBe(false);
expect(vectorStores.has('middle')).toBe(true);
expect(vectorStores.has('newest')).toBe(true);
// Check that the cache was updated correctly
const cacheKeys = service['oldestStoreKeys'];
expect(cacheKeys.includes('oldest')).toBe(false);
expect(cacheKeys.includes('middle')).toBe(true);
expect(cacheKeys.includes('newest')).toBe(true);
});
});
});
@@ -0,0 +1,74 @@
import { getConfig, mbToBytes, hoursToMs } from '../config';
describe('Vector Store Config', () => {
// Store original environment
const originalEnv = { ...process.env };
// Restore original environment after each test
afterEach(() => {
process.env = { ...originalEnv };
});
describe('getConfig', () => {
it('should return default values when no environment variables set', () => {
// Clear relevant environment variables
delete process.env.N8N_VECTOR_STORE_MAX_MEMORY;
delete process.env.N8N_VECTOR_STORE_TTL_HOURS;
const config = getConfig();
expect(config.maxMemoryMB).toBe(-1);
expect(config.ttlHours).toBe(-1);
});
it('should use values from environment variables when set', () => {
process.env.N8N_VECTOR_STORE_MAX_MEMORY = '200';
process.env.N8N_VECTOR_STORE_TTL_HOURS = '24';
const config = getConfig();
expect(config.maxMemoryMB).toBe(200);
expect(config.ttlHours).toBe(24);
});
it('should handle invalid environment variable values', () => {
// Set invalid values (non-numeric)
process.env.N8N_VECTOR_STORE_MAX_MEMORY = 'invalid';
process.env.N8N_VECTOR_STORE_TTL_HOURS = 'notanumber';
const config = getConfig();
// Should use default values for invalid inputs
expect(config.maxMemoryMB).toBe(-1);
expect(config.ttlHours).toBe(-1);
});
});
describe('mbToBytes', () => {
it('should convert MB to bytes', () => {
expect(mbToBytes(1)).toBe(1024 * 1024);
expect(mbToBytes(5)).toBe(5 * 1024 * 1024);
expect(mbToBytes(100)).toBe(100 * 1024 * 1024);
});
it('should handle zero and negative values', () => {
expect(mbToBytes(0)).toBe(-1);
expect(mbToBytes(-1)).toBe(-1);
expect(mbToBytes(-10)).toBe(-1);
});
});
describe('hoursToMs', () => {
it('should convert hours to milliseconds', () => {
expect(hoursToMs(1)).toBe(60 * 60 * 1000);
expect(hoursToMs(24)).toBe(24 * 60 * 60 * 1000);
expect(hoursToMs(168)).toBe(168 * 60 * 60 * 1000);
});
it('should handle zero and negative values', () => {
expect(hoursToMs(0)).toBe(-1);
expect(hoursToMs(-1)).toBe(-1);
expect(hoursToMs(-24)).toBe(-1);
});
});
});
@@ -0,0 +1,70 @@
import type { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import type { Document } from '@langchain/core/documents';
/**
* Configuration options for the memory vector store
*/
export interface MemoryVectorStoreConfig {
/**
* Maximum memory size in MB, -1 to disable
*/
maxMemoryMB: number;
/**
* TTL for inactive stores in hours, -1 to disable
*/
ttlHours: number;
}
/**
* Vector store metadata for tracking usage
*/
export interface VectorStoreMetadata {
size: number;
createdAt: Date;
lastAccessed: Date;
}
/**
* Per-store statistics for reporting
*/
export interface StoreStats {
sizeBytes: number;
sizeMB: number;
percentOfTotal: number;
vectors: number;
createdAt: string;
lastAccessed: string;
inactive?: boolean;
inactiveForHours?: number;
}
/**
* Overall vector store statistics
*/
export interface VectorStoreStats {
totalSizeBytes: number;
totalSizeMB: number;
percentOfLimit: number;
maxMemoryMB: number;
storeCount: number;
inactiveStoreCount: number;
ttlHours: number;
stores: Record<string, StoreStats>;
}
/**
* Service for calculating memory usage
*/
export interface IMemoryCalculator {
estimateBatchSize(documents: Document[]): number;
calculateVectorStoreSize(vectorStore: MemoryVectorStore): number;
}
/**
* Service for cleaning up vector stores
*/
export interface IStoreCleanupService {
cleanupInactiveStores(): void;
cleanupOldestStores(requiredBytes: number): void;
}
@@ -0,0 +1,208 @@
## Overview
`createVectorStoreNode` is a factory function that generates n8n nodes for vector store operations. It abstracts the common functionality needed for vector stores while allowing specific implementations to focus only on their unique aspects.
## Purpose
The function provides a standardized way to:
1. Create vector store nodes with consistent UIs
2. Handle different operation modes (load, insert, retrieve, update, retrieve-as-tool)
3. Process documents and embeddings
4. Maintain connection to LLM services
## Architecture
```
/createVectorStoreNode/ # Create Vector Store Node
/constants.ts # Constants like operation modes and descriptions
/types.ts # TypeScript interfaces and types
/utils.ts # Utility functions for node configuration
/createVectorStoreNode.ts # Main factory function
/processDocuments.ts # Document processing helpers
/operations/ # Operation-specific logic
/loadOperation.ts # Handles 'load' mode
/insertOperation.ts # Handles 'insert' mode
/updateOperation.ts # Handles 'update' mode
/retrieveOperation.ts # Handles 'retrieve' mode
/retrieveAsToolOperation.ts # Handles 'retrieve-as-tool' mode
```
## Usage
To create a new vector store node:
```typescript
import { createVectorStoreNode } from './createVectorStoreNode';
export class MyVectorStoreNode {
static description = createVectorStoreNode({
meta: {
displayName: 'My Vector Store',
name: 'myVectorStore',
description: 'Operations for My Vector Store',
docsUrl: 'https://docs.example.com/my-vector-store',
icon: 'file:myIcon.svg',
// Optional: specify which operations this vector store supports
operationModes: ['load', 'insert', 'update','retrieve', 'retrieve-as-tool'],
},
sharedFields: [
// Fields shown in all operation modes
],
loadFields: [
// Fields specific to 'load' operation
],
insertFields: [
// Fields specific to 'insert' operation
],
retrieveFields: [
// Fields specific to 'retrieve' operation
],
// Functions to implement
getVectorStoreClient: async (context, filter, embeddings, itemIndex) => {
// Create and return vector store instance
},
populateVectorStore: async (context, embeddings, documents, itemIndex) => {
// Insert documents into vector store
},
// Optional: cleanup function - called in finally blocks after operations
releaseVectorStoreClient: (vectorStore) => {
// Release resources such as database connections or external clients
// For example, in PGVector: vectorStore.client?.release();
},
});
}
```
## Operation Modes
### 1. `load` Mode
- Retrieves documents from the vector store based on a query
- Embeds the query and performs similarity search
- Returns ranked documents with their similarity scores
### 2. `insert` Mode
- Processes documents from input
- Embeds and stores documents in the vector store
- Returns serialized documents with metadata
- Supports batched processing with configurable embedding batch size
### 3. `retrieve` Mode
- Returns the vector store instance for use with AI nodes
- Allows LLMs to query the vector store directly
- Used with chains and retrievers
### 4. `retrieve-as-tool` Mode
- Creates a tool that wraps the vector store
- Allows AI agents to use the vector store as a tool
- Returns documents in a format digestible by agents
### 5. `update` Mode (optional)
- Updates existing documents in the vector store by ID
- Requires the vector store to support document updates
- Only enabled if included in `operationModes`
- Uses `addDocuments` method with an `ids` array to update specific documents
- Processes a single document per item and applies it to the specified ID
- Validates that only one document is being updated per operation
## Key Components
### 1. NodeConstructorArgs Interface
Defines the configuration and callbacks that specific vector store implementations must provide:
> **Note:** In node version 1.1+, the `populateVectorStore` function must handle receiving multiple documents at once for batch processing.
```typescript
interface VectorStoreNodeConstructorArgs<T extends VectorStore> {
meta: NodeMeta; // Node metadata (name, description, etc.)
methods?: { ... }; // Optional methods for list searches
sharedFields: INodeProperties[]; // Fields shown in all modes
insertFields?: INodeProperties[]; // Fields specific to insert mode
loadFields?: INodeProperties[]; // Fields specific to load mode
retrieveFields?: INodeProperties[]; // Fields specific to retrieve mode
updateFields?: INodeProperties[]; // Fields specific to update mode
// Core implementation functions
populateVectorStore: Function; // Store documents in vector store (accepts batches in v1.1+)
getVectorStoreClient: Function; // Get vector store instance
releaseVectorStoreClient?: Function; // Clean up resources
}
```
### 2. Operation Handlers
Each operation mode has its own handler module with a well-defined interface:
```typescript
// Example: loadOperation.ts
export async function handleLoadOperation<T extends VectorStore>(
context: IExecuteFunctions,
args: VectorStoreNodeConstructorArgs<T>,
embeddings: Embeddings,
itemIndex: number
): Promise<INodeExecutionData[]>
// Example: insertOperation.ts (v1.1+)
export async function handleInsertOperation<T extends VectorStore>(
context: IExecuteFunctions,
args: VectorStoreNodeConstructorArgs<T>,
embeddings: Embeddings
): Promise<INodeExecutionData[]>
```
### 3. Document Processing
The `processDocument` function standardizes how documents are handled:
```typescript
const { processedDocuments, serializedDocuments } = await processDocument(
documentInput,
itemData,
itemIndex
);
```
## Implementation Details
### Error Handling and Resource Management
Each operation handler includes error handling with proper resource cleanup. The `releaseVectorStoreClient` function is called in a `finally` block to ensure resources are released even if an error occurs:
```typescript
try {
// Operation logic
} finally {
// Release resources even if an error occurs
args.releaseVectorStoreClient?.(vectorStore);
}
```
#### When releaseVectorStoreClient is called:
- After completing a similarity search in `loadOperation`
- As part of the `closeFunction` in `retrieveOperation` to release resources when they're no longer needed
- After each tool use in `retrieveAsToolOperation`
- After updating documents in `updateOperation`
- After inserting documents in `insertOperation`
This design ensures proper resource management, which is especially important for database-backed vector stores (like PGVector) that need to return connections to a pool. Without proper cleanup, prolonged usage could lead to resource leaks or connection pool exhaustion.
### Dynamic Tool Creation
For the `retrieve-as-tool` mode, a DynamicTool is created that exposes vector store functionality:
```typescript
const vectorStoreTool = new DynamicTool({
name: toolName,
description: toolDescription,
func: async (input) => {
// Search vector store with input
// ...
},
});
```
## Performance Considerations
1. **Resource Management**: Each operation properly handles resource cleanup with `releaseVectorStoreClient`.
2. **Batched Processing**: The `insert` operation processes documents in configurable batches. In node version 1.1+, a single embedding operation is performed for all documents in a batch, significantly improving performance by reducing API calls.
3. **Metadata Filtering**: Filters can be applied during search operations to reduce result sets.
4. **Execution Cancellation**: The code checks for cancellation signals to stop processing when needed.
@@ -0,0 +1,336 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] = `
{
"builderHint": {
"inputs": {
"ai_document": {
"displayOptions": {
"show": {
"mode": [
"insert",
],
},
},
"required": true,
},
"ai_embedding": {
"required": true,
},
"ai_reranker": {
"displayOptions": {
"show": {
"mode": [
"load",
"retrieve",
"retrieve-as-tool",
],
"useReranker": [
true,
],
},
},
"required": true,
},
},
},
"codex": {
"categories": [
"AI",
],
"resources": {
"primaryDocumentation": [
{
"url": undefined,
},
],
},
"subcategories": {
"AI": [
"Vector Stores",
"Tools",
"Root Nodes",
],
"Tools": [
"Other Tools",
],
"Vector Stores": [
"Other Vector Stores",
],
},
},
"credentials": undefined,
"defaults": {
"name": undefined,
},
"description": undefined,
"displayName": undefined,
"group": [
"transform",
],
"icon": undefined,
"iconColor": undefined,
"inputs": "={{
((parameters) => {
const mode = parameters?.mode;
const useReranker = parameters?.useReranker;
const inputs = [{ displayName: "Embedding", type: "ai_embedding", required: true, maxConnections: 1}]
if (['load', 'retrieve', 'retrieve-as-tool'].includes(mode) && useReranker) {
inputs.push({ displayName: "Reranker", type: "ai_reranker", required: true, maxConnections: 1})
}
if (mode === 'retrieve-as-tool') {
return inputs;
}
if (['insert', 'load', 'update'].includes(mode)) {
inputs.push({ displayName: "", type: "main"})
}
if (['insert'].includes(mode)) {
inputs.push({ displayName: "Document", type: "ai_document", required: true, maxConnections: 1})
}
return inputs
})($parameter)
}}",
"name": "mockConstructor",
"outputs": "={{
((parameters) => {
const mode = parameters?.mode ?? 'retrieve';
if (mode === 'retrieve-as-tool') {
return [{ displayName: "Tool", type: "ai_tool"}]
}
if (mode === 'retrieve') {
return [{ displayName: "Vector Store", type: "ai_vectorStore"}]
}
return [{ displayName: "", type: "main"}]
})($parameter)
}}",
"properties": [
{
"default": "",
"displayName": "Tip: Get a feel for vector stores in n8n with our",
"name": "ragStarterCallout",
"type": "callout",
"typeOptions": {
"calloutAction": {
"label": "RAG starter template",
"templateId": "rag-starter-template",
"type": "openSampleWorkflowTemplate",
},
},
},
{
"default": "retrieve",
"displayName": "Operation Mode",
"name": "mode",
"noDataExpression": true,
"options": [
{
"action": "Get ranked documents from vector store",
"description": "Get many ranked documents from vector store for query",
"name": "Get Many",
"value": "load",
},
{
"action": "Add documents to vector store",
"description": "Insert documents into vector store",
"name": "Insert Documents",
"value": "insert",
},
{
"action": "Retrieve documents for Chain/Tool as Vector Store",
"description": "Retrieve documents from vector store to be used as vector store with AI nodes",
"name": "Retrieve Documents (As Vector Store for Chain/Tool)",
"outputConnectionType": "ai_vectorStore",
"value": "retrieve",
},
{
"action": "Retrieve documents for AI Agent as Tool",
"description": "Retrieve documents from vector store to be used as tool with AI nodes",
"name": "Retrieve Documents (As Tool for AI Agent)",
"outputConnectionType": "ai_tool",
"value": "retrieve-as-tool",
},
],
"type": "options",
},
{
"default": "",
"displayName": "This node must be connected to a vector store retriever. <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='ai_retriever'>Insert one</a>",
"displayOptions": {
"show": {
"mode": [
"retrieve",
],
},
},
"name": "notice",
"type": "notice",
"typeOptions": {
"containerClass": "ndv-connection-hint-notice",
},
},
{
"default": "",
"description": "Name of the vector store",
"displayName": "Name",
"displayOptions": {
"show": {
"@version": [
{
"_cnd": {
"lte": 1.2,
},
},
],
"mode": [
"retrieve-as-tool",
],
},
},
"name": "toolName",
"placeholder": "e.g. company_knowledge_base",
"required": true,
"type": "string",
"validateType": "string-alphanumeric",
},
{
"default": "",
"description": "Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often",
"displayName": "Description",
"displayOptions": {
"show": {
"mode": [
"retrieve-as-tool",
],
},
},
"name": "toolDescription",
"placeholder": "e.g. undefined",
"required": true,
"type": "string",
"typeOptions": {
"rows": 2,
},
},
{
"default": 200,
"description": "Number of documents to embed in a single batch",
"displayName": "Embedding Batch Size",
"displayOptions": {
"show": {
"@version": [
{
"_cnd": {
"gte": 1.1,
},
},
],
"mode": [
"insert",
],
},
},
"name": "embeddingBatchSize",
"type": "number",
},
{
"default": "",
"description": "Search prompt to retrieve matching documents from the vector store using similarity-based ranking",
"displayName": "Prompt",
"displayOptions": {
"show": {
"mode": [
"load",
],
},
},
"name": "prompt",
"required": true,
"type": "string",
},
{
"default": 4,
"description": "Number of top results to fetch from vector store",
"displayName": "Limit",
"displayOptions": {
"show": {
"mode": [
"load",
"retrieve-as-tool",
],
},
},
"name": "topK",
"type": "number",
},
{
"default": true,
"description": "Whether or not to include document metadata",
"displayName": "Include Metadata",
"displayOptions": {
"show": {
"mode": [
"load",
"retrieve-as-tool",
],
},
},
"name": "includeDocumentMetadata",
"type": "boolean",
},
{
"default": false,
"description": "Whether or not to rerank results",
"displayName": "Rerank Results",
"displayOptions": {
"show": {
"mode": [
"load",
"retrieve",
"retrieve-as-tool",
],
},
},
"name": "useReranker",
"type": "boolean",
},
{
"default": "",
"description": "ID of an embedding entry",
"displayName": "ID",
"displayOptions": {
"show": {
"mode": [
"update",
],
},
},
"name": "id",
"required": true,
"type": "string",
},
{
"displayOptions": {
"show": {
"mode": [
"load",
"retrieve-as-tool",
],
},
},
"name": "loadField",
},
],
"version": [
1,
1.1,
1.2,
1.3,
],
}
`;
@@ -0,0 +1,185 @@
import type { VectorStore } from '@langchain/core/vectorstores';
import type { INodeProperties } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { DEFAULT_OPERATION_MODES } from '../constants';
import type { VectorStoreNodeConstructorArgs, NodeOperationMode } from '../types';
import {
transformDescriptionForOperationMode,
isUpdateSupported,
getOperationModeOptions,
} from '../utils';
describe('Vector Store Utilities', () => {
describe('transformDescriptionForOperationMode', () => {
const testFields: INodeProperties[] = [
{
displayName: 'Test Field 1',
name: 'testField1',
type: 'string',
default: '',
},
{
displayName: 'Test Field 2',
name: 'testField2',
type: 'number',
default: 0,
},
];
it('should add displayOptions for a single mode', () => {
const result = transformDescriptionForOperationMode(testFields, 'load');
expect(result).toHaveLength(2);
expect(result[0].displayOptions).toEqual({ show: { mode: ['load'] } });
expect(result[1].displayOptions).toEqual({ show: { mode: ['load'] } });
});
it('should add displayOptions for multiple modes', () => {
const result = transformDescriptionForOperationMode(testFields, ['load', 'insert']);
expect(result).toHaveLength(2);
expect(result[0].displayOptions).toEqual({ show: { mode: ['load', 'insert'] } });
expect(result[1].displayOptions).toEqual({ show: { mode: ['load', 'insert'] } });
});
it('should preserve other properties of the fields', () => {
const result = transformDescriptionForOperationMode(testFields, 'load');
expect(result[0].displayName).toBe('Test Field 1');
expect(result[0].name).toBe('testField1');
expect(result[0].type).toBe('string');
expect(result[0].default).toBe('');
expect(result[1].displayName).toBe('Test Field 2');
expect(result[1].name).toBe('testField2');
expect(result[1].type).toBe('number');
expect(result[1].default).toBe(0);
});
});
describe('isUpdateSupported', () => {
it('should return true when update is in operationModes', () => {
const args = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Test description',
docsUrl: 'https://example.com',
icon: 'file:test.svg',
operationModes: ['load', 'insert', 'update'] as NodeOperationMode[],
},
sharedFields: [],
getVectorStoreClient: jest.fn(),
populateVectorStore: jest.fn(),
} as unknown as VectorStoreNodeConstructorArgs<VectorStore>;
expect(isUpdateSupported(args)).toBe(true);
});
it('should return false when update is not in operationModes', () => {
const args = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Test description',
docsUrl: 'https://example.com',
icon: 'file:test.svg',
operationModes: ['load', 'insert'] as NodeOperationMode[],
},
sharedFields: [],
getVectorStoreClient: jest.fn(),
populateVectorStore: jest.fn(),
} as unknown as VectorStoreNodeConstructorArgs<VectorStore>;
expect(isUpdateSupported(args)).toBe(false);
});
it('should return false when operationModes is undefined', () => {
const args = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Test description',
docsUrl: 'https://example.com',
icon: 'file:test.svg',
},
sharedFields: [],
getVectorStoreClient: jest.fn(),
populateVectorStore: jest.fn(),
} as unknown as VectorStoreNodeConstructorArgs<VectorStore>;
expect(isUpdateSupported(args)).toBe(false);
});
});
describe('getOperationModeOptions', () => {
it('should return options for specified operation modes', () => {
const args = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Test description',
docsUrl: 'https://example.com',
icon: 'file:test.svg',
operationModes: ['load', 'insert'] as NodeOperationMode[],
},
sharedFields: [],
getVectorStoreClient: jest.fn(),
populateVectorStore: jest.fn(),
} as unknown as VectorStoreNodeConstructorArgs<VectorStore>;
const result = getOperationModeOptions(args);
expect(result).toHaveLength(2);
expect(result[0].value).toBe('load');
expect(result[1].value).toBe('insert');
});
it('should return default operation modes when not specified', () => {
const args = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Test description',
docsUrl: 'https://example.com',
icon: 'file:test.svg',
},
sharedFields: [],
getVectorStoreClient: jest.fn(),
populateVectorStore: jest.fn(),
} as unknown as VectorStoreNodeConstructorArgs<VectorStore>;
const result = getOperationModeOptions(args);
expect(result).toHaveLength(DEFAULT_OPERATION_MODES.length);
DEFAULT_OPERATION_MODES.forEach((mode) => {
expect(result.some((option) => option.value === mode)).toBe(true);
});
});
it('should include output connection type properties from OPERATION_MODE_DESCRIPTIONS', () => {
const args = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Test description',
docsUrl: 'https://example.com',
icon: 'file:test.svg',
operationModes: ['retrieve', 'retrieve-as-tool'] as NodeOperationMode[],
},
sharedFields: [],
getVectorStoreClient: jest.fn(),
populateVectorStore: jest.fn(),
} as unknown as VectorStoreNodeConstructorArgs<VectorStore>;
const result = getOperationModeOptions(args);
const retrieveOption = result.find((option) => option.value === 'retrieve');
const retrieveAsToolOption = result.find((option) => option.value === 'retrieve-as-tool');
expect(retrieveOption?.outputConnectionType).toBe(NodeConnectionTypes.AiVectorStore);
expect(retrieveAsToolOption?.outputConnectionType).toBe(NodeConnectionTypes.AiTool);
});
});
});
@@ -0,0 +1,46 @@
import { NodeConnectionTypes } from 'n8n-workflow';
import type { INodePropertyOptions } from 'n8n-workflow';
import type { NodeOperationMode } from './types';
export const DEFAULT_OPERATION_MODES: NodeOperationMode[] = [
'load',
'insert',
'retrieve',
'retrieve-as-tool',
];
export const OPERATION_MODE_DESCRIPTIONS: INodePropertyOptions[] = [
{
name: 'Get Many',
value: 'load',
description: 'Get many ranked documents from vector store for query',
action: 'Get ranked documents from vector store',
},
{
name: 'Insert Documents',
value: 'insert',
description: 'Insert documents into vector store',
action: 'Add documents to vector store',
},
{
name: 'Retrieve Documents (As Vector Store for Chain/Tool)',
value: 'retrieve',
description: 'Retrieve documents from vector store to be used as vector store with AI nodes',
action: 'Retrieve documents for Chain/Tool as Vector Store',
outputConnectionType: NodeConnectionTypes.AiVectorStore,
},
{
name: 'Retrieve Documents (As Tool for AI Agent)',
value: 'retrieve-as-tool',
description: 'Retrieve documents from vector store to be used as tool with AI nodes',
action: 'Retrieve documents for AI Agent as Tool',
outputConnectionType: NodeConnectionTypes.AiTool,
},
{
name: 'Update Documents',
value: 'update',
description: 'Update documents in vector store by ID',
action: 'Update vector store documents',
},
];
@@ -0,0 +1,412 @@
/* eslint-disable n8n-local-rules/no-uncaught-json-parse */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { DynamicTool } from '@langchain/classic/tools';
import type { DocumentInterface } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import type { VectorStore } from '@langchain/core/vectorstores';
import { mock } from 'jest-mock-extended';
import type {
IExecuteFunctions,
ISupplyDataFunctions,
NodeParameterValueType,
INodeExecutionData,
} from 'n8n-workflow';
import { createVectorStoreNode } from './createVectorStoreNode';
import type { VectorStoreNodeConstructorArgs } from './types';
jest.mock('../../log-wrapper', () => ({
logWrapper: jest.fn().mockImplementation((val: DynamicTool) => ({ logWrapped: val })),
}));
jest.mock('../../helpers', () => ({
getMetadataFiltersValues: jest.fn().mockReturnValue(undefined),
}));
jest.mock('../../log-ai-event', () => ({
logAiEvent: jest.fn(),
}));
const DEFAULT_PARAMETERS = {
options: {},
useReranker: false,
topK: 1,
};
const MOCK_DOCUMENTS: Array<[DocumentInterface, number]> = [
[
{
pageContent: 'first page',
metadata: {
id: 123,
},
},
0,
],
[
{
pageContent: 'second page',
metadata: {
id: 567,
},
},
0,
],
];
const MOCK_SEARCH_VALUE = 'search value';
const MOCK_EMBEDDED_SEARCH_VALUE = [1, 2, 3];
describe('createVectorStoreNode', () => {
const vectorStore = mock<VectorStore>({
similaritySearchVectorWithScore: jest.fn().mockResolvedValue(MOCK_DOCUMENTS),
});
const vectorStoreNodeArgs = mock<VectorStoreNodeConstructorArgs>({
sharedFields: [],
insertFields: [],
loadFields: [
{
name: 'loadField',
},
],
retrieveFields: [],
updateFields: [],
getVectorStoreClient: jest.fn().mockReturnValue(vectorStore),
});
const embeddings = mock<Embeddings>({
embedQuery: jest.fn().mockResolvedValue(MOCK_EMBEDDED_SEARCH_VALUE),
});
const context = mock<ISupplyDataFunctions>({
getNodeParameter: jest.fn(),
getInputConnectionData: jest.fn().mockReturnValue(embeddings),
});
describe('retrieve mode', () => {
it('supplies vector store as data', async () => {
// ARRANGE
const parameters: Record<string, NodeParameterValueType | object> = {
...DEFAULT_PARAMETERS,
mode: 'retrieve',
};
context.getNodeParameter.mockImplementation(
(parameterName: string): NodeParameterValueType | object => parameters[parameterName],
);
// ACT
const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs);
const nodeType = new VectorStoreNodeType();
const data = await nodeType.supplyData.call(context, 1);
const wrappedVectorStore = (data.response as { logWrapped: VectorStore }).logWrapped;
// ASSERT
expect(nodeType.description).toMatchSnapshot();
expect(wrappedVectorStore).toEqual(vectorStore);
expect(vectorStoreNodeArgs.getVectorStoreClient).toHaveBeenCalled();
});
});
describe('retrieve-as-tool mode', () => {
it('supplies DynamicTool that queries vector store and returns documents with metadata on version <= 1.2', async () => {
// ARRANGE
const parameters: Record<string, NodeParameterValueType> = {
...DEFAULT_PARAMETERS,
mode: 'retrieve-as-tool',
description: 'tool description',
toolName: 'tool name',
includeDocumentMetadata: true,
};
context.getNode.mockReturnValueOnce({
id: 'testNode',
typeVersion: 1.2,
name: 'Test Tool',
type: 'testVectorStore',
parameters,
position: [0, 0],
});
context.getNodeParameter.mockImplementation(
(parameterName: string): NodeParameterValueType | object => parameters[parameterName],
);
// ACT
const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs);
const nodeType = new VectorStoreNodeType();
const data = await nodeType.supplyData.call(context, 1);
const tool = (data.response as { logWrapped: DynamicTool }).logWrapped;
const output = await tool?.func(MOCK_SEARCH_VALUE);
// ASSERT
expect(tool?.getName()).toEqual(parameters.toolName);
expect(tool?.description).toEqual(parameters.toolDescription);
expect(embeddings.embedQuery).toHaveBeenCalledWith(MOCK_SEARCH_VALUE);
expect(vectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
MOCK_EMBEDDED_SEARCH_VALUE,
parameters.topK,
parameters.filter,
);
expect(output).toEqual([
{ type: 'text', text: JSON.stringify(MOCK_DOCUMENTS[0][0]) },
{ type: 'text', text: JSON.stringify(MOCK_DOCUMENTS[1][0]) },
]);
});
it('supplies DynamicTool that queries vector store and returns documents with metadata on version > 1.2', async () => {
// ARRANGE
const parameters: Record<string, NodeParameterValueType> = {
...DEFAULT_PARAMETERS,
mode: 'retrieve-as-tool',
description: 'tool description',
includeDocumentMetadata: true,
};
context.getNode.mockReturnValueOnce({
id: 'testNode',
typeVersion: 1.3,
name: 'Test Tool',
type: 'testVectorStore',
parameters,
position: [0, 0],
});
context.getNodeParameter.mockImplementation(
(parameterName: string): NodeParameterValueType | object => parameters[parameterName],
);
// ACT
const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs);
const nodeType = new VectorStoreNodeType();
const data = await nodeType.supplyData.call(context, 1);
const tool = (data.response as { logWrapped: DynamicTool }).logWrapped;
const output = await tool?.func(MOCK_SEARCH_VALUE);
// ASSERT
expect(tool?.getName()).toEqual('Test_Tool');
expect(tool?.description).toEqual(parameters.toolDescription);
expect(embeddings.embedQuery).toHaveBeenCalledWith(MOCK_SEARCH_VALUE);
expect(vectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
MOCK_EMBEDDED_SEARCH_VALUE,
parameters.topK,
parameters.filter,
);
expect(output).toEqual([
{ type: 'text', text: JSON.stringify(MOCK_DOCUMENTS[0][0]) },
{ type: 'text', text: JSON.stringify(MOCK_DOCUMENTS[1][0]) },
]);
});
it('supplies DynamicTool that queries vector store and returns documents without metadata', async () => {
// ARRANGE
const parameters: Record<string, NodeParameterValueType> = {
...DEFAULT_PARAMETERS,
mode: 'retrieve-as-tool',
description: 'tool description',
includeDocumentMetadata: false,
};
context.getNode.mockReturnValueOnce({
id: 'testNode',
typeVersion: 1.3,
name: 'Test Tool',
type: 'testVectorStore',
parameters,
position: [0, 0],
});
context.getNodeParameter.mockImplementation(
(parameterName: string): NodeParameterValueType | object => parameters[parameterName],
);
// ACT
const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs);
const nodeType = new VectorStoreNodeType();
const data = await nodeType.supplyData.call(context, 1);
const tool = (data.response as { logWrapped: DynamicTool }).logWrapped;
const output = await tool?.func(MOCK_SEARCH_VALUE);
// ASSERT
expect(tool?.getName()).toEqual('Test_Tool');
expect(tool?.description).toEqual(parameters.toolDescription);
expect(embeddings.embedQuery).toHaveBeenCalledWith(MOCK_SEARCH_VALUE);
expect(vectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
MOCK_EMBEDDED_SEARCH_VALUE,
parameters.topK,
parameters.filter,
);
expect(output).toEqual([
{ type: 'text', text: JSON.stringify({ pageContent: MOCK_DOCUMENTS[0][0].pageContent }) },
{ type: 'text', text: JSON.stringify({ pageContent: MOCK_DOCUMENTS[1][0].pageContent }) },
]);
});
});
describe('execute mode', () => {
const executeContext = mock<IExecuteFunctions>({
getNodeParameter: jest.fn(),
getInputConnectionData: jest.fn().mockReturnValue(embeddings),
getInputData: jest.fn(),
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('retrieve-as-tool mode in execute context', () => {
it('should execute retrieve-as-tool and return documents with metadata', async () => {
// ARRANGE
const parameters: Record<string, NodeParameterValueType> = {
...DEFAULT_PARAMETERS,
mode: 'retrieve-as-tool',
includeDocumentMetadata: true,
};
const inputData: INodeExecutionData[] = [
{
json: { input: MOCK_SEARCH_VALUE },
pairedItem: { item: 0 },
},
];
executeContext.getNodeParameter.mockImplementation(
(parameterName: string): NodeParameterValueType | object => parameters[parameterName],
);
executeContext.getInputData.mockReturnValue(inputData);
// ACT
const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs);
const nodeType = new VectorStoreNodeType();
const result = await nodeType.execute.call(executeContext);
// ASSERT
expect(result).toHaveLength(1); // One output array
expect(result[0][0]?.json?.response).toHaveLength(2); // Two documents returned
expect(result[0][0]).toEqual({
json: {
response: [
{
type: 'text',
text: JSON.stringify({
pageContent: 'first page',
metadata: { id: 123 },
}),
},
{
type: 'text',
text: JSON.stringify({
pageContent: 'second page',
metadata: { id: 567 },
}),
},
],
},
pairedItem: { item: 0 },
});
expect(embeddings.embedQuery).toHaveBeenCalledWith(MOCK_SEARCH_VALUE);
expect(vectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
MOCK_EMBEDDED_SEARCH_VALUE,
parameters.topK,
undefined, // filter
);
});
it('should execute retrieve-as-tool and return documents without metadata', async () => {
// ARRANGE
const parameters: Record<string, NodeParameterValueType> = {
...DEFAULT_PARAMETERS,
mode: 'retrieve-as-tool',
includeDocumentMetadata: false,
};
const inputData: INodeExecutionData[] = [
{
json: { input: MOCK_SEARCH_VALUE },
pairedItem: { item: 0 },
},
];
executeContext.getNodeParameter.mockImplementation(
(parameterName: string): NodeParameterValueType | object => parameters[parameterName],
);
executeContext.getInputData.mockReturnValue(inputData);
// ACT
const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs);
const nodeType = new VectorStoreNodeType();
const result = await nodeType.execute.call(executeContext);
// ASSERT
expect(result[0][0].json.response).toHaveLength(2);
const response = result[0][0].json.response as Array<{ pageContent: string }>;
const doc0 = JSON.parse(response[0].pageContent);
const doc1 = JSON.parse(response[1].pageContent);
expect(doc0).not.toHaveProperty('metadata');
expect(doc0).toEqual({ pageContent: 'first page' });
expect(doc1).toEqual({ pageContent: 'second page' });
});
it('should process multiple input items', async () => {
// ARRANGE
const parameters: Record<string, NodeParameterValueType> = {
...DEFAULT_PARAMETERS,
mode: 'retrieve-as-tool',
includeDocumentMetadata: true,
};
const inputData: INodeExecutionData[] = [
{
json: { input: 'first query' },
pairedItem: { item: 0 },
},
{
json: { input: 'second query' },
pairedItem: { item: 1 },
},
];
executeContext.getNodeParameter.mockImplementation(
(parameterName: string): NodeParameterValueType | object => parameters[parameterName],
);
executeContext.getInputData.mockReturnValue(inputData);
// ACT
const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs);
const nodeType = new VectorStoreNodeType();
const result = await nodeType.execute.call(executeContext);
// ASSERT
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(2); // One result item per input query
// Check that embedQuery was called for both input queries
expect(embeddings.embedQuery).toHaveBeenCalledTimes(2);
expect(embeddings.embedQuery).toHaveBeenNthCalledWith(1, 'first query');
expect(embeddings.embedQuery).toHaveBeenNthCalledWith(2, 'second query');
// Check pairedItem references and that each result contains both documents
expect(result[0][0].pairedItem).toEqual({ item: 0 });
expect(result[0][0].json.response).toHaveLength(2); // 2 documents for first query
expect(result[0][1].pairedItem).toEqual({ item: 1 });
expect(result[0][1].json.response).toHaveLength(2); // 2 documents for second query
});
it('should throw error for unsupported mode in execute', async () => {
// ARRANGE
const parameters: Record<string, NodeParameterValueType> = {
...DEFAULT_PARAMETERS,
mode: 'retrieve', // This mode is not supported in execute
};
executeContext.getNodeParameter.mockImplementation(
(parameterName: string): NodeParameterValueType | object => parameters[parameterName],
);
// ACT & ASSERT
const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs);
const nodeType = new VectorStoreNodeType();
await expect(nodeType.execute.call(executeContext)).rejects.toThrow(
'Only the "load", "update", "insert", and "retrieve-as-tool" operation modes are supported with execute',
);
});
});
});
});
@@ -0,0 +1,360 @@
import type { Embeddings } from '@langchain/core/embeddings';
import type { VectorStore } from '@langchain/core/vectorstores';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeTypeDescription,
SupplyData,
ISupplyDataFunctions,
INodeType,
INodeProperties,
} from 'n8n-workflow';
// Import custom types
import {
handleLoadOperation,
handleInsertOperation,
handleUpdateOperation,
handleRetrieveOperation,
handleRetrieveAsToolOperation,
handleRetrieveAsToolExecuteOperation,
} from './operations';
import type { NodeOperationMode, VectorStoreNodeConstructorArgs } from './types';
// Import utility functions
import { transformDescriptionForOperationMode, getOperationModeOptions } from './utils';
import { getConnectionHintNoticeField } from '../../shared-fields';
const ragStarterCallout: INodeProperties = {
displayName: 'Tip: Get a feel for vector stores in n8n with our',
name: 'ragStarterCallout',
type: 'callout',
typeOptions: {
calloutAction: {
label: 'RAG starter template',
type: 'openSampleWorkflowTemplate',
templateId: 'rag-starter-template',
},
},
default: '',
};
/**
* Creates a vector store node with the given configuration
* This factory function produces a complete node class that implements all vector store operations
*/
export const createVectorStoreNode = <T extends VectorStore = VectorStore>(
args: VectorStoreNodeConstructorArgs<T>,
) =>
class VectorStoreNodeType implements INodeType {
description: INodeTypeDescription = {
displayName: args.meta.displayName,
name: args.meta.name,
description: args.meta.description,
icon: args.meta.icon,
iconColor: args.meta.iconColor,
group: ['transform'],
// 1.2 has changes to VectorStoreInMemory node.
// 1.3 drops `toolName` and uses node name as the tool name.
version: [1, 1.1, 1.2, 1.3],
defaults: {
name: args.meta.displayName,
},
codex: {
categories: args.meta.categories ?? ['AI'],
subcategories: args.meta.subcategories ?? {
AI: ['Vector Stores', 'Tools', 'Root Nodes'],
'Vector Stores': ['Other Vector Stores'],
Tools: ['Other Tools'],
},
resources: {
primaryDocumentation: [
{
url: args.meta.docsUrl,
},
],
},
},
builderHint: {
...args.meta.builderHint,
inputs: {
ai_embedding: { required: true },
ai_document: {
required: true,
displayOptions: { show: { mode: ['insert'] } },
},
ai_reranker: {
required: true,
displayOptions: {
show: { mode: ['load', 'retrieve', 'retrieve-as-tool'], useReranker: [true] },
},
},
},
},
credentials: args.meta.credentials,
inputs: `={{
((parameters) => {
const mode = parameters?.mode;
const useReranker = parameters?.useReranker;
const inputs = [{ displayName: "Embedding", type: "${NodeConnectionTypes.AiEmbedding}", required: true, maxConnections: 1}]
if (['load', 'retrieve', 'retrieve-as-tool'].includes(mode) && useReranker) {
inputs.push({ displayName: "Reranker", type: "${NodeConnectionTypes.AiReranker}", required: true, maxConnections: 1})
}
if (mode === 'retrieve-as-tool') {
return inputs;
}
if (['insert', 'load', 'update'].includes(mode)) {
inputs.push({ displayName: "", type: "${NodeConnectionTypes.Main}"})
}
if (['insert'].includes(mode)) {
inputs.push({ displayName: "Document", type: "${NodeConnectionTypes.AiDocument}", required: true, maxConnections: 1})
}
return inputs
})($parameter)
}}`,
outputs: `={{
((parameters) => {
const mode = parameters?.mode ?? 'retrieve';
if (mode === 'retrieve-as-tool') {
return [{ displayName: "Tool", type: "${NodeConnectionTypes.AiTool}"}]
}
if (mode === 'retrieve') {
return [{ displayName: "Vector Store", type: "${NodeConnectionTypes.AiVectorStore}"}]
}
return [{ displayName: "", type: "${NodeConnectionTypes.Main}"}]
})($parameter)
}}`,
properties: [
ragStarterCallout,
{
displayName: 'Operation Mode',
name: 'mode',
type: 'options',
noDataExpression: true,
default: 'retrieve',
options: getOperationModeOptions(args),
},
{
...getConnectionHintNoticeField([NodeConnectionTypes.AiRetriever]),
displayOptions: {
show: {
mode: ['retrieve'],
},
},
},
{
displayName: 'Name',
name: 'toolName',
type: 'string',
default: '',
required: true,
description: 'Name of the vector store',
placeholder: 'e.g. company_knowledge_base',
validateType: 'string-alphanumeric',
displayOptions: {
show: {
'@version': [{ _cnd: { lte: 1.2 } }],
mode: ['retrieve-as-tool'],
},
},
},
{
displayName: 'Description',
name: 'toolDescription',
type: 'string',
default: '',
required: true,
typeOptions: { rows: 2 },
description:
'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often',
placeholder: `e.g. ${args.meta.description}`,
displayOptions: {
show: {
mode: ['retrieve-as-tool'],
},
},
},
...args.sharedFields,
{
displayName: 'Embedding Batch Size',
name: 'embeddingBatchSize',
type: 'number',
default: 200,
description: 'Number of documents to embed in a single batch',
displayOptions: {
show: {
mode: ['insert'],
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
},
...transformDescriptionForOperationMode(args.insertFields ?? [], 'insert'),
// Prompt and topK are always used for the load operation
{
displayName: 'Prompt',
name: 'prompt',
type: 'string',
default: '',
required: true,
description:
'Search prompt to retrieve matching documents from the vector store using similarity-based ranking',
displayOptions: {
show: {
mode: ['load'],
},
},
},
{
displayName: 'Limit',
name: 'topK',
type: 'number',
default: 4,
description: 'Number of top results to fetch from vector store',
displayOptions: {
show: {
mode: ['load', 'retrieve-as-tool'],
},
},
},
{
displayName: 'Include Metadata',
name: 'includeDocumentMetadata',
type: 'boolean',
default: true,
description: 'Whether or not to include document metadata',
displayOptions: {
show: {
mode: ['load', 'retrieve-as-tool'],
},
},
},
{
displayName: 'Rerank Results',
name: 'useReranker',
type: 'boolean',
default: false,
description: 'Whether or not to rerank results',
displayOptions: {
show: {
mode: ['load', 'retrieve', 'retrieve-as-tool'],
},
},
},
// ID is always used for update operation
{
displayName: 'ID',
name: 'id',
type: 'string',
default: '',
required: true,
description: 'ID of an embedding entry',
displayOptions: {
show: {
mode: ['update'],
},
},
},
...transformDescriptionForOperationMode(args.loadFields ?? [], [
'load',
'retrieve-as-tool',
]),
...transformDescriptionForOperationMode(args.retrieveFields ?? [], 'retrieve'),
...transformDescriptionForOperationMode(args.updateFields ?? [], 'update'),
],
};
methods = args.methods;
/**
* Method to execute the node in regular workflow mode
* Supports 'load', 'insert', and 'update' operation modes
*/
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const mode = this.getNodeParameter('mode', 0) as NodeOperationMode;
// Get the embeddings model connected to this node
const embeddings = (await this.getInputConnectionData(
NodeConnectionTypes.AiEmbedding,
0,
)) as Embeddings;
// Handle each operation mode with dedicated modules
if (mode === 'load') {
const items = this.getInputData(0);
const resultData = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const docs = await handleLoadOperation(this, args, embeddings, itemIndex);
resultData.push(...docs);
}
return [resultData];
}
if (mode === 'insert') {
const resultData = await handleInsertOperation(this, args, embeddings);
return [resultData];
}
if (mode === 'update') {
const resultData = await handleUpdateOperation(this, args, embeddings);
return [resultData];
}
if (mode === 'retrieve-as-tool') {
const items = this.getInputData(0);
const resultData = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const docs = await handleRetrieveAsToolExecuteOperation(
this,
args,
embeddings,
itemIndex,
);
resultData.push(...docs);
}
return [resultData];
}
throw new NodeOperationError(
this.getNode(),
'Only the "load", "update", "insert", and "retrieve-as-tool" operation modes are supported with execute',
);
}
/**
* Method to supply data to AI nodes
* Supports 'retrieve' and 'retrieve-as-tool' operation modes
*/
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const mode = this.getNodeParameter('mode', 0) as NodeOperationMode;
// Get the embeddings model connected to this node
const embeddings = (await this.getInputConnectionData(
NodeConnectionTypes.AiEmbedding,
0,
)) as Embeddings;
// Handle each supply data operation mode with dedicated modules
if (mode === 'retrieve') {
return await handleRetrieveOperation(this, args, embeddings, itemIndex);
}
if (mode === 'retrieve-as-tool') {
return await handleRetrieveAsToolOperation(this, args, embeddings, itemIndex);
}
throw new NodeOperationError(
this.getNode(),
'Only the "retrieve" and "retrieve-as-tool" operation mode is supported to supply data',
);
}
};
@@ -0,0 +1,305 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import type { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import type { VectorStore } from '@langchain/core/vectorstores';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { logAiEvent } from '../../../../log-ai-event';
import type { N8nBinaryLoader } from '../../../../n8n-binary-loader';
import type { N8nJsonLoader } from '../../../../n8n-json-loader';
import type { VectorStoreNodeConstructorArgs } from '../../types';
import { handleInsertOperation } from '../insertOperation';
// Mock processDocument function
jest.mock('../../../processDocuments', () => ({
processDocument: jest.fn().mockImplementation((_documentInput, _itemData, itemIndex: number) => {
const mockProcessed = [
{
pageContent: `processed content ${itemIndex}`,
metadata: { source: 'test' },
} as Document,
];
const mockSerialized = [
{
json: {
pageContent: `processed content ${itemIndex}`,
metadata: { source: 'test' },
},
pairedItem: { item: itemIndex },
},
];
return {
processedDocuments: mockProcessed,
serializedDocuments: mockSerialized,
};
}),
}));
// Mock helper functions
jest.mock('../../../../log-ai-event', () => ({
logAiEvent: jest.fn(),
}));
jest.mock('../../../../n8n-binary-loader', () => ({
N8nBinaryLoader: class {},
}));
jest.mock('../../../../n8n-json-loader', () => ({
N8nJsonLoader: class {},
}));
// Helper functions for testing
function createMockAbortSignal(aborted = false): AbortSignal {
return {
aborted,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
onabort: null,
reason: undefined,
throwIfAborted: jest.fn(),
} as unknown as AbortSignal;
}
// Create a mock implementation for getNodeParameter
function createNodeParameterMock(batchSize?: number) {
return (paramName: string, _: number, fallbackValue: any) => {
if (paramName === 'embeddingBatchSize' && batchSize !== undefined) {
return batchSize;
}
return fallbackValue;
};
}
describe('handleInsertOperation', () => {
let mockContext: MockProxy<IExecuteFunctions>;
let mockEmbeddings: MockProxy<Embeddings>;
let mockVectorStore: MockProxy<VectorStore>;
let mockArgs: VectorStoreNodeConstructorArgs<VectorStore>;
let mockInputItems: INodeExecutionData[];
let mockJsonLoader: MockProxy<N8nJsonLoader>;
beforeEach(() => {
// Mock input items
mockInputItems = [
{ json: { text: 'test document 1' } },
{ json: { text: 'test document 2' } },
{ json: { text: 'test document 3' } },
];
// Setup context mock
mockContext = mock<IExecuteFunctions>();
mockContext.getInputData.mockReturnValue(mockInputItems);
// Create a mock AbortSignal
const mockAbortSignal = createMockAbortSignal(false);
mockContext.getExecutionCancelSignal.mockReturnValue(mockAbortSignal);
mockContext.getInputConnectionData.mockResolvedValue(mockJsonLoader);
mockContext.getNode.mockReturnValue({
typeVersion: 1.1,
id: '',
name: '',
type: '',
position: [0, 0],
parameters: {},
});
// Setup embeddings mock
mockEmbeddings = mock<Embeddings>();
// Setup JSON loader mock
mockJsonLoader = mock<N8nJsonLoader>();
// Setup vector store mock
mockVectorStore = mock<VectorStore>();
// Setup args mock
mockArgs = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Vector store for testing',
docsUrl: 'https://example.com',
icon: 'file:testIcon.svg',
},
sharedFields: [],
getVectorStoreClient: jest.fn().mockResolvedValue(mockVectorStore),
populateVectorStore: jest.fn().mockResolvedValue(undefined),
releaseVectorStoreClient: jest.fn(),
};
});
afterEach(() => {
jest.clearAllMocks();
});
it('should process all input items and populate vector store', async () => {
const result = await handleInsertOperation(mockContext, mockArgs, mockEmbeddings);
// Should get document input from connection
expect(mockContext.getInputConnectionData).toHaveBeenCalledWith(
NodeConnectionTypes.AiDocument,
0,
);
// Should process each item
expect(result).toHaveLength(3);
// Should call populateVectorStore for each item
expect(mockArgs.populateVectorStore).toHaveBeenCalledTimes(1);
// Should log AI event for each item
expect(logAiEvent).toHaveBeenCalledTimes(3);
expect(logAiEvent).toHaveBeenCalledWith(mockContext, 'ai-vector-store-populated');
});
it('should stop processing if execution is cancelled', async () => {
// Create mock AbortSignals for each call
const notAbortedSignal = createMockAbortSignal(false);
const abortedSignal = createMockAbortSignal(true);
// Mock execution being cancelled after first item
mockContext.getExecutionCancelSignal
.mockReturnValueOnce(notAbortedSignal)
.mockReturnValueOnce(abortedSignal);
await handleInsertOperation(mockContext, mockArgs, mockEmbeddings);
// Should only process the first item
expect(mockArgs.populateVectorStore).toHaveBeenCalledTimes(1);
expect(logAiEvent).toHaveBeenCalledTimes(1);
});
it('should handle different document input types', async () => {
// Test with Binary Loader
const mockBinaryLoader = mock<N8nBinaryLoader>();
mockContext.getInputConnectionData.mockResolvedValueOnce(mockBinaryLoader);
await handleInsertOperation(mockContext, mockArgs, mockEmbeddings);
// Test with Document Array
const mockDocuments = [{ pageContent: 'test content', metadata: {} } as Document];
mockContext.getInputConnectionData.mockResolvedValueOnce(mockDocuments);
await handleInsertOperation(mockContext, mockArgs, mockEmbeddings);
// Both calls should process all items
expect(mockArgs.populateVectorStore).toHaveBeenCalledTimes(2);
});
it('should pass the correct documents to populateVectorStore', async () => {
await handleInsertOperation(mockContext, mockArgs, mockEmbeddings);
// Check that populateVectorStore is called once with all documents
expect(mockArgs.populateVectorStore).toHaveBeenCalledTimes(1);
expect(mockArgs.populateVectorStore).toHaveBeenCalledWith(
mockContext,
mockEmbeddings,
expect.arrayContaining([
expect.objectContaining({
pageContent: 'processed content 0',
metadata: { source: 'test' },
}),
expect.objectContaining({
pageContent: 'processed content 1',
metadata: { source: 'test' },
}),
expect.objectContaining({
pageContent: 'processed content 2',
metadata: { source: 'test' },
}),
]),
0,
);
});
it('should batch documents when node version is 1.1 and above', async () => {
// Create more documents to test batching
const manyItems = Array(10)
.fill(null)
.map((_, i) => ({
json: { text: `test document ${i}` },
}));
mockContext.getInputData.mockReturnValue(manyItems);
// Set smaller batch size
mockContext.getNodeParameter.mockImplementation(createNodeParameterMock(3));
await handleInsertOperation(mockContext, mockArgs, mockEmbeddings);
// Should call populateVectorStore multiple times based on batch size
expect(mockArgs.populateVectorStore).toHaveBeenCalledTimes(4); // 10 documents with batch size 3 = 4 batches
});
it('should run populateVectorStore for each item when node version is 1', async () => {
// Set node version to 1
mockContext.getNode.mockReturnValue({
typeVersion: 1,
id: '',
name: '',
type: '',
position: [0, 0],
parameters: {},
});
await handleInsertOperation(mockContext, mockArgs, mockEmbeddings);
// Should run populateVectorStore for each item
expect(mockArgs.populateVectorStore).toHaveBeenCalledTimes(3);
// Should call populateVectorStore for each item with index parameter
expect(mockArgs.populateVectorStore).toHaveBeenNthCalledWith(
1,
mockContext,
mockEmbeddings,
expect.arrayContaining([
expect.objectContaining({
pageContent: 'processed content 0',
metadata: { source: 'test' },
}),
]),
0,
);
expect(mockArgs.populateVectorStore).toHaveBeenNthCalledWith(
2,
mockContext,
mockEmbeddings,
expect.arrayContaining([
expect.objectContaining({
pageContent: 'processed content 1',
metadata: { source: 'test' },
}),
]),
1,
);
expect(mockArgs.populateVectorStore).toHaveBeenNthCalledWith(
3,
mockContext,
mockEmbeddings,
expect.arrayContaining([
expect.objectContaining({
pageContent: 'processed content 2',
metadata: { source: 'test' },
}),
]),
2,
);
});
it('should use default batch size of 200 when not specified', async () => {
// Test fallback behavior (undefined means use fallback value)
mockContext.getNodeParameter.mockImplementation(createNodeParameterMock());
await handleInsertOperation(mockContext, mockArgs, mockEmbeddings);
// With only 3 documents and default batch size of 200, should only call once
expect(mockArgs.populateVectorStore).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,247 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import type { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import type { BaseDocumentCompressor } from '@langchain/core/retrievers/document_compressors';
import type { VectorStore } from '@langchain/core/vectorstores';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { logAiEvent } from '../../../../log-ai-event';
import type { VectorStoreNodeConstructorArgs } from '../../types';
import { handleLoadOperation } from '../loadOperation';
// Mock helper functions from external modules
jest.mock('../../../../helpers', () => ({
getMetadataFiltersValues: jest.fn().mockReturnValue({ testFilter: 'value' }),
}));
jest.mock('../../../../log-ai-event', () => ({
logAiEvent: jest.fn(),
}));
describe('handleLoadOperation', () => {
let mockContext: MockProxy<IExecuteFunctions>;
let mockEmbeddings: MockProxy<Embeddings>;
let mockVectorStore: MockProxy<VectorStore>;
let mockReranker: MockProxy<BaseDocumentCompressor>;
let mockArgs: VectorStoreNodeConstructorArgs<VectorStore>;
let nodeParameters: Record<string, any>;
beforeEach(() => {
nodeParameters = {
prompt: 'test search query',
topK: 3,
includeDocumentMetadata: true,
useReranker: false,
};
mockContext = mock<IExecuteFunctions>();
mockContext.getNodeParameter.mockImplementation((parameterName, _itemIndex, fallbackValue) => {
if (typeof parameterName !== 'string') return fallbackValue;
return nodeParameters[parameterName] ?? fallbackValue;
});
mockEmbeddings = mock<Embeddings>();
mockEmbeddings.embedQuery.mockResolvedValue([0.1, 0.2, 0.3]);
mockVectorStore = mock<VectorStore>();
mockVectorStore.similaritySearchVectorWithScore.mockResolvedValue([
[{ pageContent: 'test content 1', metadata: { test: 'metadata 1' } } as Document, 0.95],
[{ pageContent: 'test content 2', metadata: { test: 'metadata 2' } } as Document, 0.85],
[{ pageContent: 'test content 3', metadata: { test: 'metadata 3' } } as Document, 0.75],
]);
mockReranker = mock<BaseDocumentCompressor>();
mockReranker.compressDocuments.mockResolvedValue([
{
pageContent: 'test content 2',
metadata: { test: 'metadata 2', relevanceScore: 0.98 },
} as Document,
{
pageContent: 'test content 1',
metadata: { test: 'metadata 1', relevanceScore: 0.92 },
} as Document,
{
pageContent: 'test content 3',
metadata: { test: 'metadata 3', relevanceScore: 0.88 },
} as Document,
]);
mockContext.getInputConnectionData.mockResolvedValue(mockReranker);
mockArgs = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Vector store for testing',
docsUrl: 'https://example.com',
icon: 'file:testIcon.svg',
},
sharedFields: [],
getVectorStoreClient: jest.fn().mockResolvedValue(mockVectorStore),
populateVectorStore: jest.fn().mockResolvedValue(undefined),
releaseVectorStoreClient: jest.fn(),
};
});
afterEach(() => {
jest.clearAllMocks();
});
it('should retrieve documents from vector store with similarity search', async () => {
const result = await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(mockArgs.getVectorStoreClient).toHaveBeenCalledWith(
mockContext,
undefined,
mockEmbeddings,
0,
);
expect(mockEmbeddings.embedQuery).toHaveBeenCalledWith('test search query');
expect(mockVectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
[0.1, 0.2, 0.3],
3,
{ testFilter: 'value' },
);
expect(result).toHaveLength(3);
});
it('should include document metadata when includeDocumentMetadata is true', async () => {
const result = await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(result[0].json.document).toHaveProperty('metadata');
expect((result[0].json?.document as IDataObject)?.metadata).toEqual({ test: 'metadata 1' });
expect((result[0].json?.document as IDataObject)?.pageContent).toEqual('test content 1');
expect(result[0].json?.score).toEqual(0.95);
});
it('should exclude document metadata when includeDocumentMetadata is false', async () => {
nodeParameters.includeDocumentMetadata = false;
const result = await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(result[0].json?.document).not.toHaveProperty('metadata');
expect((result[0].json?.document as IDataObject)?.pageContent).toEqual('test content 1');
expect(result[0].json?.score).toEqual(0.95);
});
it('should use the topK parameter to limit results', async () => {
nodeParameters.topK = 2;
await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(mockVectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
expect.anything(),
2,
expect.anything(),
);
});
it('should properly set pairedItem property in results', async () => {
const result = await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
result.forEach((item) => {
expect(item).toHaveProperty('pairedItem');
expect(item.pairedItem).toEqual({ item: 0 });
});
});
it('should log AI event with query after search is complete', async () => {
await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(logAiEvent).toHaveBeenCalledWith(mockContext, 'ai-vector-store-searched', {
query: 'test search query',
});
});
it('should release vector store client even if an error occurs', async () => {
mockVectorStore.similaritySearchVectorWithScore.mockRejectedValue(new Error('Test error'));
await expect(handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0)).rejects.toThrow(
'Test error',
);
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
});
describe('reranking functionality', () => {
beforeEach(() => {
nodeParameters.useReranker = true;
});
it('should use reranker when useReranker is true', async () => {
const result = await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(mockContext.getInputConnectionData).toHaveBeenCalledWith(
NodeConnectionTypes.AiReranker,
0,
);
expect(mockReranker.compressDocuments).toHaveBeenCalledWith(
[
{ pageContent: 'test content 1', metadata: { test: 'metadata 1' } },
{ pageContent: 'test content 2', metadata: { test: 'metadata 2' } },
{ pageContent: 'test content 3', metadata: { test: 'metadata 3' } },
],
'test search query',
);
expect(result).toHaveLength(3);
});
it('should return reranked documents with relevance scores', async () => {
const result = await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
// First result should be the reranked first document (was second in original order)
expect((result[0].json?.document as IDataObject)?.pageContent).toEqual('test content 2');
expect(result[0].json?.score).toEqual(0.98);
// Second result should be the reranked second document (was first in original order)
expect((result[1].json?.document as IDataObject)?.pageContent).toEqual('test content 1');
expect(result[1].json?.score).toEqual(0.92);
// Third result should be the reranked third document
expect((result[2].json?.document as IDataObject)?.pageContent).toEqual('test content 3');
expect(result[2].json?.score).toEqual(0.88);
});
it('should remove relevanceScore from metadata after reranking', async () => {
const result = await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
// Check that relevanceScore is not included in the metadata
expect((result[0].json?.document as IDataObject)?.metadata).toEqual({ test: 'metadata 2' });
expect((result[1].json?.document as IDataObject)?.metadata).toEqual({ test: 'metadata 1' });
expect((result[2].json?.document as IDataObject)?.metadata).toEqual({ test: 'metadata 3' });
});
it('should handle reranking with includeDocumentMetadata false', async () => {
nodeParameters.includeDocumentMetadata = false;
const result = await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(result[0].json?.document).not.toHaveProperty('metadata');
expect((result[0].json?.document as IDataObject)?.pageContent).toEqual('test content 2');
expect(result[0].json?.score).toEqual(0.98);
});
it('should not call reranker when useReranker is false', async () => {
nodeParameters.useReranker = false;
await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(mockContext.getInputConnectionData).not.toHaveBeenCalled();
expect(mockReranker.compressDocuments).not.toHaveBeenCalled();
});
it('should release vector store client even if reranking fails', async () => {
mockReranker.compressDocuments.mockRejectedValue(new Error('Reranking failed'));
await expect(handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0)).rejects.toThrow(
'Reranking failed',
);
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
});
});
});
@@ -0,0 +1,139 @@
import type { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import type { VectorStore } from '@langchain/core/vectorstores';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, ISupplyDataFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { VectorStoreNodeConstructorArgs } from '../../types';
import { handleLoadOperation } from '../loadOperation';
import { handleRetrieveAsToolOperation } from '../retrieveAsToolOperation';
import { handleRetrieveOperation } from '../retrieveOperation';
import { handleUpdateOperation } from '../updateOperation';
describe('Vector Store Operation Handlers', () => {
let mockContext: MockProxy<IExecuteFunctions & ISupplyDataFunctions>;
let mockEmbeddings: MockProxy<Embeddings>;
let mockVectorStore: MockProxy<VectorStore>;
let mockArgs: VectorStoreNodeConstructorArgs<VectorStore>;
let nodeParameters: Record<string, any>;
beforeEach(() => {
nodeParameters = {
mode: 'load',
prompt: 'test query',
topK: 3,
includeDocumentMetadata: true,
toolName: 'test_tool',
toolDescription: 'Test tool description',
};
mockContext = mock<IExecuteFunctions & ISupplyDataFunctions>();
mockContext.getNode.mockReturnValue({
id: 'testNode',
typeVersion: 1.3,
name: 'Test Tool',
type: 'testVectorStore',
parameters: nodeParameters,
position: [0, 0],
});
mockContext.getNodeParameter.mockImplementation((parameterName, _itemIndex, fallbackValue) => {
if (typeof parameterName !== 'string') return fallbackValue;
return nodeParameters[parameterName] ?? fallbackValue;
});
mockContext.getInputData.mockReturnValue([{ json: { test: 'data' } }]);
mockEmbeddings = mock<Embeddings>();
mockEmbeddings.embedQuery.mockResolvedValue([0.1, 0.2, 0.3]);
mockVectorStore = mock<VectorStore>();
mockVectorStore.similaritySearchVectorWithScore.mockResolvedValue([
[{ pageContent: 'test content', metadata: { test: 'metadata' } } as Document, 0.95],
[{ pageContent: 'test content 2', metadata: { test: 'metadata 2' } } as Document, 0.85],
]);
mockArgs = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Vector store for testing',
docsUrl: 'https://example.com',
icon: 'file:testIcon.svg',
operationModes: ['load', 'insert', 'retrieve', 'retrieve-as-tool', 'update'],
},
sharedFields: [],
getVectorStoreClient: jest.fn().mockResolvedValue(mockVectorStore),
populateVectorStore: jest.fn().mockResolvedValue(undefined),
releaseVectorStoreClient: jest.fn(),
};
});
describe('handleLoadOperation', () => {
it('should properly process load operation', async () => {
const result = await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(mockArgs.getVectorStoreClient).toHaveBeenCalledTimes(1);
expect(mockEmbeddings.embedQuery).toHaveBeenCalledWith('test query');
expect(mockVectorStore.similaritySearchVectorWithScore).toHaveBeenCalled();
expect(result).toHaveLength(2);
expect(result[0].json).toHaveProperty('document');
expect(result[0].json).toHaveProperty('score');
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
});
it('should exclude metadata when includeDocumentMetadata is false', async () => {
nodeParameters.includeDocumentMetadata = false;
const result = await handleLoadOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(result[0].json.document).not.toHaveProperty('metadata');
});
});
describe('handleUpdateOperation', () => {
it('should throw error when update is not supported', async () => {
mockArgs.meta.operationModes = ['load', 'insert'];
await expect(handleUpdateOperation(mockContext, mockArgs, mockEmbeddings)).rejects.toThrow(
NodeOperationError,
);
});
});
describe('handleRetrieveOperation', () => {
it('should return vector store with log wrapper and close function', async () => {
const result = await handleRetrieveOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(result).toHaveProperty('response');
expect(result).toHaveProperty('closeFunction');
});
});
describe('handleRetrieveAsToolOperation', () => {
it('should return a tool with the correct name and description on version <= 1.2', async () => {
mockContext.getNode.mockReturnValueOnce({
id: 'testNode',
typeVersion: 1.2,
name: 'Test Tool',
type: 'testVectorStore',
parameters: nodeParameters,
position: [0, 0],
});
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(result).toHaveProperty('response');
expect(result.response).toHaveProperty('name', 'test_tool');
expect(result.response).toHaveProperty('description', 'Test tool description');
});
it('should return a tool with the correct name and description on version > 1.2', async () => {
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(result).toHaveProperty('response');
expect(result.response).toHaveProperty('name', 'Test_Tool');
expect(result.response).toHaveProperty('description', 'Test tool description');
});
});
});
@@ -0,0 +1,428 @@
/* eslint-disable n8n-local-rules/no-uncaught-json-parse */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import type { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import type { BaseDocumentCompressor } from '@langchain/core/retrievers/document_compressors';
import type { VectorStore } from '@langchain/core/vectorstores';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { logAiEvent } from '../../../../log-ai-event';
import type { VectorStoreNodeConstructorArgs } from '../../types';
import { handleRetrieveAsToolExecuteOperation } from '../retrieveAsToolExecuteOperation';
// Mock helper functions from external modules
jest.mock('../../../../helpers', () => ({
getMetadataFiltersValues: jest.fn().mockReturnValue({ testFilter: 'value' }),
}));
jest.mock('../../../../log-ai-event', () => ({
logAiEvent: jest.fn(),
}));
describe('handleRetrieveAsToolExecuteOperation', () => {
let mockContext: MockProxy<IExecuteFunctions>;
let mockEmbeddings: MockProxy<Embeddings>;
let mockVectorStore: MockProxy<VectorStore>;
let mockReranker: MockProxy<BaseDocumentCompressor>;
let mockArgs: VectorStoreNodeConstructorArgs<VectorStore>;
let nodeParameters: Record<string, any>;
let inputData: INodeExecutionData[];
beforeEach(() => {
nodeParameters = {
topK: 3,
includeDocumentMetadata: true,
useReranker: false,
};
inputData = [
{
json: { input: 'test search query' },
pairedItem: { item: 0 },
},
];
mockContext = mock<IExecuteFunctions>();
mockContext.getNodeParameter.mockImplementation((parameterName, _itemIndex, fallbackValue) => {
if (typeof parameterName !== 'string') return fallbackValue;
return nodeParameters[parameterName] ?? fallbackValue;
});
mockContext.getInputData.mockReturnValue(inputData);
mockEmbeddings = mock<Embeddings>();
mockEmbeddings.embedQuery.mockResolvedValue([0.1, 0.2, 0.3]);
mockVectorStore = mock<VectorStore>();
mockVectorStore.similaritySearchVectorWithScore.mockResolvedValue([
[{ pageContent: 'test content 1', metadata: { test: 'metadata 1' } } as Document, 0.95],
[{ pageContent: 'test content 2', metadata: { test: 'metadata 2' } } as Document, 0.85],
[{ pageContent: 'test content 3', metadata: { test: 'metadata 3' } } as Document, 0.75],
]);
mockReranker = mock<BaseDocumentCompressor>();
mockReranker.compressDocuments.mockResolvedValue([
{
pageContent: 'test content 2',
metadata: { test: 'metadata 2', relevanceScore: 0.98 },
} as Document,
{
pageContent: 'test content 1',
metadata: { test: 'metadata 1', relevanceScore: 0.92 },
} as Document,
{
pageContent: 'test content 3',
metadata: { test: 'metadata 3', relevanceScore: 0.88 },
} as Document,
]);
mockContext.getInputConnectionData.mockResolvedValue(mockReranker);
mockArgs = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Vector store for testing',
docsUrl: 'https://example.com',
icon: 'file:testIcon.svg',
},
sharedFields: [],
getVectorStoreClient: jest.fn().mockResolvedValue(mockVectorStore),
populateVectorStore: jest.fn().mockResolvedValue(undefined),
releaseVectorStoreClient: jest.fn(),
};
});
afterEach(() => {
jest.clearAllMocks();
});
it('should retrieve documents from vector store using query from input data', async () => {
const result = await handleRetrieveAsToolExecuteOperation(
mockContext,
mockArgs,
mockEmbeddings,
0,
);
expect(mockArgs.getVectorStoreClient).toHaveBeenCalledWith(
mockContext,
undefined,
mockEmbeddings,
0,
);
expect(mockEmbeddings.embedQuery).toHaveBeenCalledWith('test search query');
expect(mockVectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
[0.1, 0.2, 0.3],
3,
{ testFilter: 'value' },
);
expect(result).toHaveLength(1);
expect(result[0].json.response).toHaveLength(3);
const response = result[0].json.response as Array<{ type: string; text: string }>;
expect(response[0]).toEqual({
type: 'text',
text: JSON.stringify({
pageContent: 'test content 1',
metadata: { test: 'metadata 1' },
}),
});
expect(result[0].pairedItem).toEqual({ item: 0 });
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
expect(logAiEvent).toHaveBeenCalledWith(mockContext, 'ai-vector-store-searched', {
input: 'test search query',
});
});
it('should throw error when input data does not contain query', async () => {
inputData[0].json = { notQuery: 'some value' };
await expect(
handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0),
).rejects.toThrow('Input data must contain a "input" field with the search query');
});
it('should throw error when query is not a string', async () => {
inputData[0].json = { input: 123 };
await expect(
handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0),
).rejects.toThrow('Input data must contain a "input" field with the search query');
});
it('should throw error when query is empty string', async () => {
inputData[0].json = { input: '' };
await expect(
handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0),
).rejects.toThrow('Input data must contain a "input" field with the search query');
});
it('should include metadata when includeDocumentMetadata is true', async () => {
const result = await handleRetrieveAsToolExecuteOperation(
mockContext,
mockArgs,
mockEmbeddings,
0,
);
expect(result).toHaveLength(1);
expect(result[0].json.response).toHaveLength(3);
const response = result[0].json.response as Array<{ type: string; text: string }>;
const firstDoc = JSON.parse(response[0].text);
expect(firstDoc).toHaveProperty('metadata');
expect(firstDoc.metadata).toEqual({ test: 'metadata 1' });
});
it('should exclude metadata when includeDocumentMetadata is false', async () => {
nodeParameters.includeDocumentMetadata = false;
const result = await handleRetrieveAsToolExecuteOperation(
mockContext,
mockArgs,
mockEmbeddings,
0,
);
expect(result).toHaveLength(1);
expect(result[0].json.response).toHaveLength(3);
const response = result[0].json.response as Array<{ pageContent: string }>;
const firstDoc = JSON.parse(response[0].pageContent);
expect(firstDoc).not.toHaveProperty('metadata');
expect(firstDoc).toEqual({
pageContent: 'test content 1',
});
});
it('should limit results based on topK parameter', async () => {
nodeParameters.topK = 1;
await handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(mockVectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
expect.anything(),
1,
expect.anything(),
);
});
it('should use topK default value when not provided', async () => {
delete nodeParameters.topK;
await handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(mockVectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
expect.anything(),
4, // default value
expect.anything(),
);
});
it('should release vector store client even if search fails', async () => {
mockVectorStore.similaritySearchVectorWithScore.mockRejectedValueOnce(
new Error('Search failed'),
);
await expect(
handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0),
).rejects.toThrow('Search failed');
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
});
describe('reranking functionality', () => {
beforeEach(() => {
nodeParameters.useReranker = true;
});
it('should use reranker when useReranker is true', async () => {
await handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(mockContext.getInputConnectionData).toHaveBeenCalledWith(
NodeConnectionTypes.AiReranker,
0,
);
expect(mockReranker.compressDocuments).toHaveBeenCalledWith(
[
{ pageContent: 'test content 1', metadata: { test: 'metadata 1' } },
{ pageContent: 'test content 2', metadata: { test: 'metadata 2' } },
{ pageContent: 'test content 3', metadata: { test: 'metadata 3' } },
],
'test search query',
);
});
it('should return reranked documents in the correct order', async () => {
const result = await handleRetrieveAsToolExecuteOperation(
mockContext,
mockArgs,
mockEmbeddings,
0,
);
expect(result).toHaveLength(1);
expect(result[0].json.response).toHaveLength(3);
const response = result[0].json.response as Array<{ type: string; text: string }>;
// First result should be the reranked first document (was second in original order)
const doc0 = JSON.parse(response[0].text);
expect(doc0.pageContent).toEqual('test content 2');
expect(doc0.metadata).toEqual({ test: 'metadata 2' });
// Second result should be the reranked second document (was first in original order)
const doc1 = JSON.parse(response[1].text);
expect(doc1.pageContent).toEqual('test content 1');
expect(doc1.metadata).toEqual({ test: 'metadata 1' });
// Third result should be the reranked third document
const doc2 = JSON.parse(response[2].text);
expect(doc2.pageContent).toEqual('test content 3');
expect(doc2.metadata).toEqual({ test: 'metadata 3' });
});
it('should handle reranking with includeDocumentMetadata false', async () => {
nodeParameters.includeDocumentMetadata = false;
const result = await handleRetrieveAsToolExecuteOperation(
mockContext,
mockArgs,
mockEmbeddings,
0,
);
expect(result).toHaveLength(1);
expect(result[0].json.response).toHaveLength(3);
const response = result[0].json.response as Array<{ pageContent: string }>;
// Should maintain reranked order but exclude metadata
const doc0 = JSON.parse(response[0].pageContent);
expect(doc0).toEqual({ pageContent: 'test content 2' });
const doc1 = JSON.parse(response[1].pageContent);
expect(doc1).toEqual({ pageContent: 'test content 1' });
const doc2 = JSON.parse(response[2].pageContent);
expect(doc2).toEqual({ pageContent: 'test content 3' });
});
it('should not call reranker when useReranker is false', async () => {
nodeParameters.useReranker = false;
await handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(mockContext.getInputConnectionData).not.toHaveBeenCalled();
expect(mockReranker.compressDocuments).not.toHaveBeenCalled();
});
it('should release vector store client even if reranking fails', async () => {
mockReranker.compressDocuments.mockRejectedValueOnce(new Error('Reranking failed'));
await expect(
handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0),
).rejects.toThrow('Reranking failed');
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
});
it('should properly handle relevanceScore from reranker metadata', async () => {
// Mock reranker to return documents with relevanceScore in different metadata structure
mockReranker.compressDocuments.mockResolvedValueOnce([
{
pageContent: 'test content 2',
metadata: { test: 'metadata 2', relevanceScore: 0.98, otherField: 'value' },
} as Document,
{
pageContent: 'test content 1',
metadata: { test: 'metadata 1', relevanceScore: 0.92 },
} as Document,
]);
const result = await handleRetrieveAsToolExecuteOperation(
mockContext,
mockArgs,
mockEmbeddings,
0,
);
expect(result).toHaveLength(1);
expect(result[0].json.response).toHaveLength(2);
const response = result[0].json.response as Array<{ type: string; text: string }>;
// Check that relevanceScore is used but metadata is preserved without relevanceScore
const doc0 = JSON.parse(response[0].text);
expect(doc0.metadata).toEqual({ test: 'metadata 2', otherField: 'value' });
expect(doc0.metadata).not.toHaveProperty('relevanceScore');
const doc1 = JSON.parse(response[1].text);
expect(doc1.metadata).toEqual({ test: 'metadata 1' });
expect(doc1.metadata).not.toHaveProperty('relevanceScore');
});
it('should not use reranker when no documents are found', async () => {
mockVectorStore.similaritySearchVectorWithScore.mockResolvedValueOnce([]);
const result = await handleRetrieveAsToolExecuteOperation(
mockContext,
mockArgs,
mockEmbeddings,
0,
);
expect(mockContext.getInputConnectionData).not.toHaveBeenCalled();
expect(mockReranker.compressDocuments).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0].json.response).toHaveLength(0);
});
});
describe('empty result handling', () => {
it('should return empty array when vector store returns no documents', async () => {
mockVectorStore.similaritySearchVectorWithScore.mockResolvedValueOnce([]);
const result = await handleRetrieveAsToolExecuteOperation(
mockContext,
mockArgs,
mockEmbeddings,
0,
);
expect(result).toHaveLength(1);
expect(result[0].json.response).toHaveLength(0);
expect(logAiEvent).toHaveBeenCalledWith(mockContext, 'ai-vector-store-searched', {
input: 'test search query',
});
});
});
describe('error handling', () => {
it('should release client resources when embedQuery fails', async () => {
mockEmbeddings.embedQuery.mockRejectedValueOnce(new Error('Embedding failed'));
await expect(
handleRetrieveAsToolExecuteOperation(mockContext, mockArgs, mockEmbeddings, 0),
).rejects.toThrow('Embedding failed');
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
});
it('should handle missing releaseVectorStoreClient function gracefully', async () => {
delete mockArgs.releaseVectorStoreClient;
const result = await handleRetrieveAsToolExecuteOperation(
mockContext,
mockArgs,
mockEmbeddings,
0,
);
expect(result).toHaveLength(1);
expect(result[0].json.response).toHaveLength(3);
// Should not throw error when releaseVectorStoreClient is undefined
});
});
});
@@ -0,0 +1,350 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable n8n-local-rules/no-uncaught-json-parse */
import { type DynamicTool, DynamicStructuredTool } from '@langchain/classic/tools';
import type { Document } from '@langchain/core/documents';
import type { Embeddings } from '@langchain/core/embeddings';
import type { BaseDocumentCompressor } from '@langchain/core/retrievers/document_compressors';
import type { VectorStore } from '@langchain/core/vectorstores';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { logWrapper } from '../../../../log-wrapper';
import type { VectorStoreNodeConstructorArgs } from '../../types';
import { handleRetrieveAsToolOperation } from '../retrieveAsToolOperation';
// Mock the helper functions
jest.mock('../../../../helpers', () => ({
getMetadataFiltersValues: jest.fn().mockReturnValue({ testFilter: 'value' }),
}));
jest.mock('../../../../log-wrapper', () => ({
logWrapper: jest.fn().mockImplementation((obj) => obj),
}));
describe('handleRetrieveAsToolOperation', () => {
let mockContext: MockProxy<ISupplyDataFunctions>;
let mockEmbeddings: MockProxy<Embeddings>;
let mockVectorStore: MockProxy<VectorStore>;
let mockReranker: MockProxy<BaseDocumentCompressor>;
let mockArgs: VectorStoreNodeConstructorArgs<VectorStore>;
let nodeParameters: Record<string, any>;
beforeEach(() => {
nodeParameters = {
toolName: 'test_knowledge_base',
toolDescription: 'Search the test knowledge base',
topK: 3,
includeDocumentMetadata: true,
useReranker: false,
};
mockContext = mock<ISupplyDataFunctions>();
mockContext.getNode.mockReturnValue({
id: 'testNode',
typeVersion: 1.3,
name: 'Test Knowledge Base',
type: 'testVectorStore',
parameters: nodeParameters,
position: [0, 0],
});
mockContext.getNodeParameter.mockImplementation((parameterName, _itemIndex, fallbackValue) => {
if (typeof parameterName !== 'string') return fallbackValue;
return nodeParameters[parameterName] ?? fallbackValue;
});
mockEmbeddings = mock<Embeddings>();
mockEmbeddings.embedQuery.mockResolvedValue([0.1, 0.2, 0.3]);
mockVectorStore = mock<VectorStore>();
mockVectorStore.similaritySearchVectorWithScore.mockResolvedValue([
[{ pageContent: 'test content 1', metadata: { test: 'metadata 1' } } as Document, 0.95],
[{ pageContent: 'test content 2', metadata: { test: 'metadata 2' } } as Document, 0.85],
]);
mockReranker = mock<BaseDocumentCompressor>();
mockReranker.compressDocuments.mockResolvedValue([
{
pageContent: 'test content 2',
metadata: { test: 'metadata 2', relevanceScore: 0.98 },
} as Document,
{
pageContent: 'test content 1',
metadata: { test: 'metadata 1', relevanceScore: 0.92 },
} as Document,
]);
mockContext.getInputConnectionData.mockResolvedValue(mockReranker);
mockArgs = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Vector store for testing',
docsUrl: 'https://example.com',
icon: 'file:testIcon.svg',
},
sharedFields: [],
getVectorStoreClient: jest.fn().mockResolvedValue(mockVectorStore),
populateVectorStore: jest.fn().mockResolvedValue(undefined),
releaseVectorStoreClient: jest.fn(),
};
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create a structured tool with the correct name and description on version <= 1.2', async () => {
mockContext.getNode.mockReturnValueOnce({
id: 'testNode',
typeVersion: 1.2,
name: 'Test Knowledge Base',
type: 'testVectorStore',
parameters: nodeParameters,
position: [0, 0],
});
const result = (await handleRetrieveAsToolOperation(
mockContext,
mockArgs,
mockEmbeddings,
0,
)) as {
response: DynamicStructuredTool;
};
expect(result).toHaveProperty('response');
// Tool is always a DynamicStructuredTool because we always add 'input' as extraArg
expect(result.response).toBeInstanceOf(DynamicStructuredTool);
expect(result.response.name).toBe('test_knowledge_base');
expect(result.response.description).toBe('Search the test knowledge base');
// Check logWrapper was called
expect(logWrapper).toHaveBeenCalledWith(expect.any(DynamicStructuredTool), mockContext);
});
it('should create a structured tool with the correct name and description on version > 1.2', async () => {
const result = (await handleRetrieveAsToolOperation(
mockContext,
mockArgs,
mockEmbeddings,
0,
)) as {
response: DynamicStructuredTool;
};
expect(result).toHaveProperty('response');
// Tool is always a DynamicStructuredTool because we always add 'input' as extraArg
expect(result.response).toBeInstanceOf(DynamicStructuredTool);
expect(result.response.name).toBe('Test_Knowledge_Base');
expect(result.response.description).toBe('Search the test knowledge base');
// Check logWrapper was called
expect(logWrapper).toHaveBeenCalledWith(expect.any(DynamicStructuredTool), mockContext);
});
it('should create a tool that can search the vector store', async () => {
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicTool;
// Invoke the tool's function
const toolResult = await tool.func('test query');
// Check vector store client was initialized
expect(mockArgs.getVectorStoreClient).toHaveBeenCalledWith(
mockContext,
undefined,
mockEmbeddings,
0,
);
// Check query was embedded
expect(mockEmbeddings.embedQuery).toHaveBeenCalledWith('test query');
// Check vector store was searched
expect(mockVectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
[0.1, 0.2, 0.3],
3,
{ testFilter: 'value' },
);
// Check tool returns formatted results
expect(toolResult).toHaveLength(2);
expect(toolResult[0]).toHaveProperty('type', 'text');
expect(toolResult[0]).toHaveProperty('text');
// Check vector store client was released
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
});
it('should include metadata in results when includeDocumentMetadata is true', async () => {
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicTool;
const toolResult = await tool.func('test query');
// Parse the JSON text to verify it includes metadata
const parsedFirst = JSON.parse(toolResult[0].text);
expect(parsedFirst).toHaveProperty('pageContent', 'test content 1');
expect(parsedFirst).toHaveProperty('metadata', { test: 'metadata 1' });
});
it('should exclude metadata in results when includeDocumentMetadata is false', async () => {
nodeParameters.includeDocumentMetadata = false;
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicTool;
const toolResult = await tool.func('test query');
// Parse the JSON text to verify it excludes metadata
const parsedFirst = JSON.parse(toolResult[0].text);
expect(parsedFirst).toHaveProperty('pageContent', 'test content 1');
expect(parsedFirst).not.toHaveProperty('metadata');
});
it('should limit results based on topK parameter', async () => {
nodeParameters.topK = 1;
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicTool;
await tool.func('test query');
expect(mockVectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith(
expect.anything(),
1,
expect.anything(),
);
});
it('should release vector store client even if search fails', async () => {
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicTool;
// Make the search fail
mockVectorStore.similaritySearchVectorWithScore.mockRejectedValueOnce(
new Error('Search failed'),
);
await expect(tool.func('test query')).rejects.toThrow('Search failed');
// Should still release the client
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
});
describe('reranking functionality', () => {
beforeEach(() => {
nodeParameters.useReranker = true;
});
it('should use reranker when useReranker is true', async () => {
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicTool;
await tool.func('test query');
expect(mockContext.getInputConnectionData).toHaveBeenCalledWith(
NodeConnectionTypes.AiReranker,
0,
);
expect(mockReranker.compressDocuments).toHaveBeenCalledWith(
[
{ pageContent: 'test content 1', metadata: { test: 'metadata 1' } },
{ pageContent: 'test content 2', metadata: { test: 'metadata 2' } },
],
'test query',
);
});
it('should return reranked documents in the correct order', async () => {
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicTool;
const toolResult = await tool.func('test query');
expect(toolResult).toHaveLength(2);
// First result should be the reranked first document (was second in original order)
const parsedFirst = JSON.parse(toolResult[0].text);
expect(parsedFirst.pageContent).toEqual('test content 2');
expect(parsedFirst.metadata).toEqual({ test: 'metadata 2' });
// Second result should be the reranked second document (was first in original order)
const parsedSecond = JSON.parse(toolResult[1].text);
expect(parsedSecond.pageContent).toEqual('test content 1');
expect(parsedSecond.metadata).toEqual({ test: 'metadata 1' });
});
it('should handle reranking with includeDocumentMetadata false', async () => {
nodeParameters.includeDocumentMetadata = false;
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicTool;
const toolResult = await tool.func('test query');
// Parse the JSON text to verify it excludes metadata but maintains reranked order
const parsedFirst = JSON.parse(toolResult[0].text);
expect(parsedFirst).toHaveProperty('pageContent', 'test content 2');
expect(parsedFirst).not.toHaveProperty('metadata');
const parsedSecond = JSON.parse(toolResult[1].text);
expect(parsedSecond).toHaveProperty('pageContent', 'test content 1');
expect(parsedSecond).not.toHaveProperty('metadata');
});
it('should not call reranker when useReranker is false', async () => {
nodeParameters.useReranker = false;
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicTool;
await tool.func('test query');
expect(mockContext.getInputConnectionData).not.toHaveBeenCalled();
expect(mockReranker.compressDocuments).not.toHaveBeenCalled();
});
it('should release vector store client even if reranking fails', async () => {
mockReranker.compressDocuments.mockRejectedValueOnce(new Error('Reranking failed'));
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicTool;
await expect(tool.func('test query')).rejects.toThrow('Reranking failed');
// Should still release the client
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
});
it('should properly handle relevanceScore from reranker metadata', async () => {
// Mock reranker to return documents with relevanceScore in different metadata structure
mockReranker.compressDocuments.mockResolvedValueOnce([
{
pageContent: 'test content 2',
metadata: { test: 'metadata 2', relevanceScore: 0.98, otherField: 'value' },
} as Document,
{
pageContent: 'test content 1',
metadata: { test: 'metadata 1', relevanceScore: 0.92 },
} as Document,
]);
const result = await handleRetrieveAsToolOperation(mockContext, mockArgs, mockEmbeddings, 0);
const tool = result.response as DynamicStructuredTool;
// DynamicStructuredTool expects an object with 'input' key
const toolResult = await tool.invoke({ input: 'test query' });
// Check that relevanceScore is used but not included in the final metadata
const parsedFirst = JSON.parse(toolResult[0].text);
expect(parsedFirst.pageContent).toEqual('test content 2');
expect(parsedFirst.metadata).toEqual({ test: 'metadata 2', otherField: 'value' });
expect(parsedFirst.metadata).not.toHaveProperty('relevanceScore');
});
});
});
@@ -0,0 +1,138 @@
import type { Embeddings } from '@langchain/core/embeddings';
import type { BaseDocumentCompressor } from '@langchain/core/retrievers/document_compressors';
import type { VectorStore } from '@langchain/core/vectorstores';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { logWrapper } from '../../../../log-wrapper';
import type { VectorStoreNodeConstructorArgs } from '../../types';
import { handleRetrieveOperation } from '../retrieveOperation';
// Mock helper functions
jest.mock('../../../../helpers', () => ({
getMetadataFiltersValues: jest.fn().mockReturnValue({ testFilter: 'value' }),
}));
jest.mock('../../../../log-wrapper', () => ({
logWrapper: jest.fn().mockImplementation((obj) => obj),
}));
describe('handleRetrieveOperation', () => {
let mockContext: MockProxy<ISupplyDataFunctions>;
let mockEmbeddings: MockProxy<Embeddings>;
let mockVectorStore: MockProxy<VectorStore>;
let mockReranker: MockProxy<BaseDocumentCompressor>;
let mockArgs: VectorStoreNodeConstructorArgs<VectorStore>;
beforeEach(() => {
mockContext = mock<ISupplyDataFunctions>();
mockContext.getNodeParameter.mockReturnValue(false); // Default useReranker to false
mockEmbeddings = mock<Embeddings>();
mockVectorStore = mock<VectorStore>();
mockReranker = mock<BaseDocumentCompressor>();
mockArgs = {
meta: {
displayName: 'Test Vector Store',
name: 'testVectorStore',
description: 'Vector store for testing',
docsUrl: 'https://example.com',
icon: 'file:testIcon.svg',
},
sharedFields: [],
getVectorStoreClient: jest.fn().mockResolvedValue(mockVectorStore),
populateVectorStore: jest.fn().mockResolvedValue(undefined),
releaseVectorStoreClient: jest.fn(),
};
});
afterEach(() => {
jest.clearAllMocks();
});
it('should retrieve vector store with metadata filters', async () => {
const result = await handleRetrieveOperation(mockContext, mockArgs, mockEmbeddings, 0);
// Should get vector store client with filters
expect(mockArgs.getVectorStoreClient).toHaveBeenCalledWith(
mockContext,
{ testFilter: 'value' },
mockEmbeddings,
0,
);
// Result should contain vector store and close function
expect(result).toHaveProperty('response', mockVectorStore);
expect(result).toHaveProperty('closeFunction');
// Should wrap vector store with logWrapper
expect(logWrapper).toHaveBeenCalledWith(mockVectorStore, mockContext);
});
it('should create a closeFunction that releases the vector store client', async () => {
const result = await handleRetrieveOperation(mockContext, mockArgs, mockEmbeddings, 0);
// Call the closeFunction
await result.closeFunction!();
// Should release the vector store client
expect(mockArgs.releaseVectorStoreClient).toHaveBeenCalledWith(mockVectorStore);
});
it('should handle vector store client when no releaseVectorStoreClient is provided', async () => {
// Remove releaseVectorStoreClient method
mockArgs.releaseVectorStoreClient = undefined;
const result = await handleRetrieveOperation(mockContext, mockArgs, mockEmbeddings, 0);
// Call the closeFunction - should not throw error even with no release method
await expect(result.closeFunction!()).resolves.not.toThrow();
});
it('should retrieve vector store without reranker when useReranker is false', async () => {
mockContext.getNodeParameter.mockReturnValue(false);
const result = await handleRetrieveOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('useReranker', 0, false);
expect(mockArgs.getVectorStoreClient).toHaveBeenCalledWith(
mockContext,
{ testFilter: 'value' },
mockEmbeddings,
0,
);
// Result should contain vector store and close function
expect(result).toHaveProperty('response', mockVectorStore);
expect(result).toHaveProperty('closeFunction');
// Should not try to get reranker input connection
expect(mockContext.getInputConnectionData).not.toHaveBeenCalled();
});
it('should retrieve vector store with reranker when useReranker is true', async () => {
mockContext.getNodeParameter.mockReturnValue(true);
mockContext.getInputConnectionData.mockResolvedValue(mockReranker);
const result = await handleRetrieveOperation(mockContext, mockArgs, mockEmbeddings, 0);
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('useReranker', 0, false);
expect(mockContext.getInputConnectionData).toHaveBeenCalledWith(
NodeConnectionTypes.AiReranker,
0,
);
expect(result.response).toEqual({
reranker: mockReranker,
vectorStore: mockVectorStore,
});
expect(result).toHaveProperty('closeFunction');
});
});

Some files were not shown because too many files have changed in this diff Show More