mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
test: Add tests to ai-utilities (#25443)
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
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 fetch(url, {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
...headers,
|
||||
Authorization: 'Bearer test-api-key',
|
||||
},
|
||||
});
|
||||
const json = await response.json();
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: json,
|
||||
};
|
||||
},
|
||||
openStream: async (
|
||||
method: string,
|
||||
url: string,
|
||||
body?: object,
|
||||
headers?: Record<string, string>,
|
||||
) => {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
...headers,
|
||||
Authorization: 'Bearer test-api-key',
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: response.body as ReadableStream<Uint8Array<ArrayBufferLike>>,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { createAgent, HumanMessage } from 'langchain';
|
||||
import nock from 'nock';
|
||||
|
||||
import {
|
||||
createMockHttpRequests,
|
||||
createSSEStream,
|
||||
mockFinalResponse,
|
||||
mockStreamFinalResponseEvents,
|
||||
mockStreamToolCallEvents,
|
||||
mockToolCallResponse,
|
||||
weatherTool,
|
||||
} from './openai.fixtures';
|
||||
import { OpenAIChatModel } from '../examples/models/openai';
|
||||
import { LangchainAdapter } from '../src/adapters/langchain-chat-model';
|
||||
|
||||
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 LangchainAdapter(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 LangchainAdapter(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);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
...require('../../../jest.config'),
|
||||
collectCoverageFrom: ['src/**/*.ts'],
|
||||
collectCoverageFrom: ['src/**/*.ts', 'integration-tests/**/*.ts'],
|
||||
setupFilesAfterEnv: ['jest-expect-message'],
|
||||
coveragePathIgnorePatterns: ['examples'],
|
||||
};
|
||||
|
||||
@@ -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 { LangchainAdapter } 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 LangchainAdapter(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 LangchainAdapter(chatModel);
|
||||
|
||||
expect(N8nLlmTracing).not.toHaveBeenCalled();
|
||||
expect(makeN8nLlmFailedAttemptHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_llmType', () => {
|
||||
it('returns "n8n-chat-model"', () => {
|
||||
const adapter = new LangchainAdapter(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 LangchainAdapter(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 LangchainAdapter(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 LangchainAdapter(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 LangchainAdapter(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 LangchainAdapter(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 LangchainAdapter(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 LangchainAdapter(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(LangchainAdapter);
|
||||
expect(bound).not.toBe(adapter);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -408,7 +408,9 @@ describe('message round-trip: LC -> n8n -> LC', () => {
|
||||
it('should round-trip an AIMessage with tool_calls', () => {
|
||||
const original = new AIMessage({
|
||||
content: 'Let me search for that.',
|
||||
tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'weather' } }],
|
||||
tool_calls: [
|
||||
{ type: 'tool_call', id: 'call-1', name: 'search', args: { query: 'weather' } },
|
||||
],
|
||||
});
|
||||
|
||||
const result = roundTrip(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' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
describe('empty', () => {
|
||||
it('should be empty', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
import { supplyModel } from 'src/suppliers/supplyModel';
|
||||
|
||||
const mockLangchainAdapterInstance = { __brand: 'LangchainAdapter' };
|
||||
|
||||
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', () => ({
|
||||
LangchainAdapter: jest.fn().mockImplementation(() => mockLangchainAdapterInstance),
|
||||
}));
|
||||
|
||||
const { ChatOpenAI } = jest.requireMock('@langchain/openai');
|
||||
const { LangchainAdapter } = 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(LangchainAdapter).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 (LangchainAdapter) path', () => {
|
||||
it('returns response from LangchainAdapter 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(LangchainAdapter).toHaveBeenCalledTimes(1);
|
||||
expect(LangchainAdapter).toHaveBeenCalledWith(chatModel, mockCtx);
|
||||
expect(ChatOpenAI).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses LangchainAdapter 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(LangchainAdapter).toHaveBeenCalledWith(modelWithOtherType, mockCtx);
|
||||
expect(ChatOpenAI).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses LangchainAdapter 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(LangchainAdapter).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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { n8nDefaultFailedAttemptHandler } from './n8nDefaultFailedAttemptHandler';
|
||||
import { n8nDefaultFailedAttemptHandler } from 'src/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler';
|
||||
|
||||
class MockHttpError extends Error {
|
||||
response: { status: number };
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { mock } from 'jest-mock-extended';
|
||||
import type { ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { ApplicationError, NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { makeN8nLlmFailedAttemptHandler } from './n8nLlmFailedAttemptHandler';
|
||||
import { makeN8nLlmFailedAttemptHandler } from 'src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
|
||||
|
||||
describe('makeN8nLlmFailedAttemptHandler', () => {
|
||||
const ctx = mock<ISupplyDataFunctions>({
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { hasLongSequentialRepeat } from './helpers';
|
||||
import { hasLongSequentialRepeat } from 'src/utils/helpers';
|
||||
|
||||
describe('hasLongSequentialRepeat', () => {
|
||||
it('should return false for text shorter than threshold', () => {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { Agent, ProxyAgent } from 'undici';
|
||||
|
||||
import { getProxyAgent, proxyFetch } from './http-proxy-agent';
|
||||
import { getProxyAgent, proxyFetch } from 'src/utils/http-proxy-agent';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('undici', () => ({
|
||||
@@ -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,281 @@
|
||||
import type { ServerSentEventMessage } from 'src/utils/sse';
|
||||
import { parseSSEStream } from 'src/utils/sse';
|
||||
|
||||
describe('parseSSEStream', () => {
|
||||
// Helper to create a ReadableStream from string chunks
|
||||
function createStreamFromChunks(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
let index = 0;
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (index < chunks.length) {
|
||||
controller.enqueue(encoder.encode(chunks[index]));
|
||||
index++;
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to collect all events from stream
|
||||
async function collectEvents(
|
||||
stream: ReadableStream<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 = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(chunk1);
|
||||
controller.enqueue(chunk2);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
import type { TiktokenEncoding } from 'js-tiktoken/lite';
|
||||
import { Tiktoken } from 'js-tiktoken/lite';
|
||||
|
||||
import { getEncoding, encodingForModel } from '../tiktoken';
|
||||
import { getEncoding, encodingForModel } from 'src/utils/tokenizer/tiktoken';
|
||||
|
||||
jest.mock('js-tiktoken/lite', () => ({
|
||||
Tiktoken: jest.fn(),
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
estimateTokensByCharCount,
|
||||
estimateTextSplitsByTokens,
|
||||
estimateTokensFromStringList,
|
||||
} from '../token-estimator';
|
||||
} from 'src/utils/tokenizer/token-estimator';
|
||||
|
||||
describe('token-estimator', () => {
|
||||
describe('estimateTokensByCharCount', () => {
|
||||
@@ -57,7 +57,6 @@ export class LangchainAdapter<
|
||||
messages: BaseMessage[],
|
||||
options: this['ParsedCallOptions'],
|
||||
): Promise<ChatResult> {
|
||||
// Convert LangChain messages to generic messages
|
||||
const transformedMessages = messages.map(fromLcMessage);
|
||||
const result = await this.chatModel.generate(transformedMessages, options);
|
||||
// Build content blocks for the message
|
||||
|
||||
@@ -84,7 +84,7 @@ function isInvalidToolCallBlock(
|
||||
function isToolResultBlock(
|
||||
block: LangchainMessages.ContentBlock,
|
||||
): block is LangchainMessages.ContentBlock.Tools.ServerToolCallResult {
|
||||
return block.type === 'tool-result';
|
||||
return block.type === 'server_tool_call_result';
|
||||
}
|
||||
function isCitationBlock(block: unknown): block is LangchainMessages.ContentBlock.Citation {
|
||||
return (
|
||||
@@ -329,11 +329,14 @@ export function toLcMessage(message: Message): LangchainMessages.BaseMessage {
|
||||
name: message.name,
|
||||
});
|
||||
case 'assistant': {
|
||||
const toolCalls = message.content.filter(isN8nToolCallBlock).map((c) => ({
|
||||
id: c.toolCallId,
|
||||
name: c.toolName,
|
||||
args: jsonParse<Record<string, unknown>>(c.input, { fallbackValue: {} }),
|
||||
}));
|
||||
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,
|
||||
|
||||
@@ -7,5 +7,10 @@
|
||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "src/examples/**"]
|
||||
"exclude": [
|
||||
"src/**/__tests__/**",
|
||||
"src/**/*.test.ts",
|
||||
"src/examples/**",
|
||||
"integration-tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"outDir": "./dist_examples",
|
||||
"tsBuildInfoFile": "dist_examples/build.tsbuildinfo"
|
||||
},
|
||||
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
|
||||
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "integration-tests/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts", "examples/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "test/**/*.ts", "examples/**/*.ts", "integration-tests/**/*.ts"],
|
||||
"references": [{ "path": "../../workflow/tsconfig.build.esm.json" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user