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
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:
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
...require('../../../jest.config'),
|
||||
collectCoverageFrom: ['src/**/*.ts'],
|
||||
coveragePathIgnorePatterns: ['src/index.ts'],
|
||||
};
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
.tmp
|
||||
tmp
|
||||
dist
|
||||
npm-debug.log*
|
||||
yarn.lock
|
||||
.vscode/launch.json
|
||||
.env
|
||||
@@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+66
@@ -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();
|
||||
});
|
||||
});
|
||||
+65
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
};
|
||||
+46
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+319
@@ -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;
|
||||
}
|
||||
}
|
||||
+157
@@ -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;
|
||||
}
|
||||
+202
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+278
@@ -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');
|
||||
});
|
||||
});
|
||||
+288
@@ -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.
|
||||
|
||||
+336
@@ -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,
|
||||
],
|
||||
}
|
||||
`;
|
||||
+185
@@ -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',
|
||||
},
|
||||
];
|
||||
+412
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+360
@@ -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',
|
||||
);
|
||||
}
|
||||
};
|
||||
+305
@@ -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);
|
||||
});
|
||||
});
|
||||
+247
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+139
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+428
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
+350
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+138
@@ -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
Reference in New Issue
Block a user