first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError, NodeConnectionTypes, NodeOperationError, sleep } from 'n8n-workflow';
|
||||
|
||||
import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
// Import from centralized module
|
||||
import { formatResponse, getInputs, nodeProperties } from './methods';
|
||||
import { processItem } from './methods/processItem';
|
||||
import {
|
||||
getCustomErrorMessage as getCustomOpenAiErrorMessage,
|
||||
isOpenAiError,
|
||||
} from '../../vendors/OpenAi/helpers/error-handling';
|
||||
|
||||
/**
|
||||
* Basic LLM Chain Node Implementation
|
||||
* Allows connecting to language models with optional structured output parsing
|
||||
*/
|
||||
export class ChainLlm implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Basic LLM Chain',
|
||||
name: 'chainLlm',
|
||||
icon: 'fa:link',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9],
|
||||
description: 'A simple chain to prompt a large language model',
|
||||
defaults: {
|
||||
name: 'Basic LLM Chain',
|
||||
color: '#909298',
|
||||
},
|
||||
codex: {
|
||||
alias: ['LangChain'],
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Chains', 'Root Nodes'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.chainllm/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
inputs: `={{ ((parameter) => { ${getInputs.toString()}; return getInputs(parameter) })($parameter) }}`,
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
ai_outputParser: {
|
||||
required: false,
|
||||
displayOptions: { show: { hasOutputParser: [true] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
credentials: [],
|
||||
properties: nodeProperties,
|
||||
};
|
||||
|
||||
/**
|
||||
* Main execution method for the node
|
||||
*/
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
this.logger.debug('Executing Basic LLM Chain');
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const outputParser = await getOptionalOutputParser(this);
|
||||
// If the node version is 1.6(and LLM is using `response_format: json_object`) or higher or an output parser is configured,
|
||||
// we unwrap the response and return the object directly as JSON
|
||||
const shouldUnwrapObjects = this.getNode().typeVersion >= 1.6 || !!outputParser;
|
||||
|
||||
const batchSize = this.getNodeParameter('batching.batchSize', 0, 5) as number;
|
||||
const delayBetweenBatches = this.getNodeParameter(
|
||||
'batching.delayBetweenBatches',
|
||||
0,
|
||||
0,
|
||||
) as number;
|
||||
|
||||
if (this.getNode().typeVersion >= 1.7 && batchSize > 1) {
|
||||
// Process items in batches
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
const batchPromises = batch.map(async (_item, batchItemIndex) => {
|
||||
return await processItem(this, i + batchItemIndex);
|
||||
});
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
|
||||
batchResults.forEach((promiseResult, batchItemIndex) => {
|
||||
const itemIndex = i + batchItemIndex;
|
||||
if (promiseResult.status === 'rejected') {
|
||||
const error = promiseResult.reason as Error;
|
||||
// Handle OpenAI specific rate limit errors
|
||||
if (error instanceof NodeApiError && isOpenAiError(error.cause)) {
|
||||
const openAiErrorCode: string | undefined = (error.cause as any).error?.code;
|
||||
if (openAiErrorCode) {
|
||||
const customMessage = getCustomOpenAiErrorMessage(openAiErrorCode);
|
||||
if (customMessage) {
|
||||
error.message = customMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({
|
||||
json: { error: error.message },
|
||||
pairedItem: { item: itemIndex },
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new NodeOperationError(this.getNode(), error);
|
||||
}
|
||||
|
||||
const responses = promiseResult.value;
|
||||
responses.forEach((response: unknown) => {
|
||||
returnData.push({
|
||||
json: formatResponse(response, shouldUnwrapObjects),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (i + batchSize < items.length && delayBetweenBatches > 0) {
|
||||
await sleep(delayBetweenBatches);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Process each input item
|
||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||
try {
|
||||
const responses = await processItem(this, itemIndex);
|
||||
|
||||
// Process each response and add to return data
|
||||
responses.forEach((response) => {
|
||||
returnData.push({
|
||||
json: formatResponse(response, shouldUnwrapObjects),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle OpenAI specific rate limit errors
|
||||
if (error instanceof NodeApiError && isOpenAiError(error.cause)) {
|
||||
const openAiErrorCode: string | undefined = (error.cause as any).error?.code;
|
||||
if (openAiErrorCode) {
|
||||
const customMessage = getCustomOpenAiErrorMessage(openAiErrorCode);
|
||||
if (customMessage) {
|
||||
error.message = customMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continue on failure if configured
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { Tool } from '@langchain/classic/tools';
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseLLMOutputParser } from '@langchain/core/output_parsers';
|
||||
import { JsonOutputParser, StringOutputParser } from '@langchain/core/output_parsers';
|
||||
import type { ChatPromptTemplate, PromptTemplate } from '@langchain/core/prompts';
|
||||
import type { Runnable } from '@langchain/core/runnables';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { isChatInstance } from '@n8n/ai-utilities';
|
||||
import { getTracingConfig } from '@utils/tracing';
|
||||
|
||||
import { createPromptTemplate, getAgentStepsParser } from './promptUtils';
|
||||
import type { ChainExecutionParams } from './types';
|
||||
|
||||
export class NaiveJsonOutputParser<
|
||||
T extends Record<string, any> = Record<string, any>,
|
||||
> extends JsonOutputParser<T> {
|
||||
async parse(text: string): Promise<T> {
|
||||
// First try direct JSON parsing
|
||||
try {
|
||||
const directParsed = JSON.parse(text);
|
||||
return directParsed as T;
|
||||
} catch (e) {
|
||||
// If fails, fall back to JsonOutputParser parser
|
||||
return await super.parse(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if the LLM has a modelKwargs property(OpenAI)
|
||||
*/
|
||||
export function isModelWithResponseFormat(
|
||||
llm: BaseLanguageModel,
|
||||
): llm is BaseLanguageModel & { modelKwargs: { response_format: { type: string } } } {
|
||||
return (
|
||||
'modelKwargs' in llm &&
|
||||
!!llm.modelKwargs &&
|
||||
typeof llm.modelKwargs === 'object' &&
|
||||
'response_format' in llm.modelKwargs
|
||||
);
|
||||
}
|
||||
|
||||
export function isModelInThinkingMode(
|
||||
llm: BaseLanguageModel,
|
||||
): llm is BaseLanguageModel & { lc_kwargs: { invocationKwargs: { thinking: { type: string } } } } {
|
||||
return (
|
||||
'lc_kwargs' in llm &&
|
||||
'invocationKwargs' in llm.lc_kwargs &&
|
||||
typeof llm.lc_kwargs.invocationKwargs === 'object' &&
|
||||
'thinking' in llm.lc_kwargs.invocationKwargs &&
|
||||
llm.lc_kwargs.invocationKwargs.thinking.type === 'enabled'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if the LLM has a format property(Ollama)
|
||||
*/
|
||||
export function isModelWithFormat(
|
||||
llm: BaseLanguageModel,
|
||||
): llm is BaseLanguageModel & { format: string } {
|
||||
return 'format' in llm && typeof llm.format !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an LLM is configured to output JSON and returns the appropriate output parser
|
||||
*/
|
||||
export function getOutputParserForLLM(
|
||||
llm: BaseChatModel | BaseLanguageModel,
|
||||
): BaseLLMOutputParser<string | Record<string, unknown>> {
|
||||
if (isModelWithResponseFormat(llm) && llm.modelKwargs?.response_format?.type === 'json_object') {
|
||||
return new NaiveJsonOutputParser();
|
||||
}
|
||||
|
||||
if (isModelWithFormat(llm) && llm.format === 'json') {
|
||||
return new NaiveJsonOutputParser();
|
||||
}
|
||||
|
||||
if (isModelInThinkingMode(llm)) {
|
||||
return new NaiveJsonOutputParser();
|
||||
}
|
||||
|
||||
// For example Mistral's Magistral models (LmChatMistralCloud node)
|
||||
if (llm.metadata?.output_format === 'json') {
|
||||
return new NaiveJsonOutputParser();
|
||||
}
|
||||
|
||||
return new StringOutputParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simple chain for LLMs without output parsers
|
||||
*/
|
||||
async function executeSimpleChain({
|
||||
context,
|
||||
llm,
|
||||
query,
|
||||
prompt,
|
||||
}: {
|
||||
context: IExecuteFunctions;
|
||||
llm: BaseChatModel | BaseLanguageModel;
|
||||
query: string;
|
||||
prompt: ChatPromptTemplate | PromptTemplate;
|
||||
}) {
|
||||
const outputParser = getOutputParserForLLM(llm);
|
||||
|
||||
const chain = prompt.pipe(llm).pipe(outputParser).withConfig(getTracingConfig(context));
|
||||
|
||||
// Execute the chain
|
||||
const response = await chain.invoke({
|
||||
query,
|
||||
signal: context.getExecutionCancelSignal(),
|
||||
});
|
||||
|
||||
// Ensure response is always returned as an array
|
||||
return [response];
|
||||
}
|
||||
|
||||
// Some models nodes, like OpenAI, can define built-in tools in their metadata
|
||||
function withBuiltInTools(llm: BaseChatModel | BaseLanguageModel) {
|
||||
const modelTools = (llm.metadata?.tools as Tool[]) ?? [];
|
||||
if (modelTools.length && isChatInstance(llm) && llm.bindTools) {
|
||||
return llm.bindTools(modelTools);
|
||||
}
|
||||
return llm;
|
||||
}
|
||||
|
||||
function prepareLlm(
|
||||
llm: BaseLanguageModel | BaseChatModel,
|
||||
fallbackLlm?: BaseLanguageModel | BaseChatModel | null,
|
||||
) {
|
||||
const mainLlm = withBuiltInTools(llm);
|
||||
if (fallbackLlm) {
|
||||
return mainLlm.withFallbacks([withBuiltInTools(fallbackLlm)]);
|
||||
}
|
||||
return mainLlm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and executes an LLM chain with the given prompt and optional output parsers
|
||||
*/
|
||||
export async function executeChain({
|
||||
context,
|
||||
itemIndex,
|
||||
query,
|
||||
llm,
|
||||
outputParser,
|
||||
messages,
|
||||
fallbackLlm,
|
||||
}: ChainExecutionParams): Promise<unknown[]> {
|
||||
const version = context.getNode().typeVersion;
|
||||
const model = prepareLlm(llm, fallbackLlm) as BaseChatModel | BaseLanguageModel;
|
||||
// If no output parsers provided, use a simple chain with basic prompt template
|
||||
if (!outputParser) {
|
||||
const promptTemplate = await createPromptTemplate({
|
||||
context,
|
||||
itemIndex,
|
||||
llm,
|
||||
messages,
|
||||
query,
|
||||
});
|
||||
|
||||
return await executeSimpleChain({
|
||||
context,
|
||||
llm: model,
|
||||
query,
|
||||
prompt: promptTemplate,
|
||||
});
|
||||
}
|
||||
|
||||
const formatInstructions = outputParser.getFormatInstructions();
|
||||
|
||||
// Create a prompt template with format instructions
|
||||
const promptWithInstructions = await createPromptTemplate({
|
||||
context,
|
||||
itemIndex,
|
||||
llm,
|
||||
messages,
|
||||
formatInstructions,
|
||||
query,
|
||||
});
|
||||
|
||||
let chain: Runnable<{ query: string }>;
|
||||
if (version >= 1.9) {
|
||||
// use getAgentStepsParser to have more robust output parsing
|
||||
chain = promptWithInstructions
|
||||
.pipe(model)
|
||||
.pipe(getAgentStepsParser(outputParser))
|
||||
.withConfig(getTracingConfig(context));
|
||||
} else {
|
||||
chain = promptWithInstructions
|
||||
.pipe(model)
|
||||
.pipe(outputParser)
|
||||
.withConfig(getTracingConfig(context));
|
||||
}
|
||||
|
||||
const response = await chain.invoke({ query }, { signal: context.getExecutionCancelSignal() });
|
||||
|
||||
// Ensure response is always returned as an array
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Array.isArray(response) ? response : [response];
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
AIMessagePromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
SystemMessagePromptTemplate,
|
||||
} from '@langchain/core/prompts';
|
||||
import type { IDataObject, INodeInputConfiguration, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
promptTypeOptions,
|
||||
promptTypeOptionsDeprecated,
|
||||
textFromGuardrailsNode,
|
||||
textFromPreviousNode,
|
||||
} from '@utils/descriptions';
|
||||
import { getBatchingOptionFields, getTemplateNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
/**
|
||||
* Dynamic input configuration generation based on node parameters
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
export function getInputs(parameters: IDataObject) {
|
||||
const inputs: INodeInputConfiguration[] = [
|
||||
{ displayName: '', type: 'main' },
|
||||
{
|
||||
displayName: 'Model',
|
||||
maxConnections: 1,
|
||||
type: 'ai_languageModel',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const needsFallback = parameters?.needsFallback;
|
||||
|
||||
if (needsFallback === true) {
|
||||
inputs.push({
|
||||
displayName: 'Fallback Model',
|
||||
maxConnections: 1,
|
||||
type: 'ai_languageModel',
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
|
||||
// If `hasOutputParser` is undefined it must be version 1.3 or earlier so we
|
||||
// always add the output parser input
|
||||
const hasOutputParser = parameters?.hasOutputParser;
|
||||
if (hasOutputParser === undefined || hasOutputParser === true) {
|
||||
inputs.push({
|
||||
displayName: 'Output Parser',
|
||||
type: 'ai_outputParser',
|
||||
maxConnections: 1,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node properties configuration
|
||||
*/
|
||||
export const nodeProperties: INodeProperties[] = [
|
||||
getTemplateNoticeField(1978),
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.input }}',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.chat_input }}',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1.1, 1.2],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.chatInput }}',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1.3],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...promptTypeOptionsDeprecated,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { lte: 1.3 } }, { _cnd: { gte: 1.8 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...promptTypeOptions,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.8 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...textFromGuardrailsNode,
|
||||
displayOptions: { show: { promptType: ['guardrails'], '@version': [{ _cnd: { gte: 1.5 } }] } },
|
||||
},
|
||||
{
|
||||
...textFromPreviousNode,
|
||||
displayOptions: { show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.5 } }] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt (User Message)',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'e.g. Hello, how can you help me?',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
builderHint: {
|
||||
message:
|
||||
'Use expressions to include dynamic data from previous nodes (e.g., "={{ $json.input }}"). Static text prompts ignore incoming data.',
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
promptType: ['define'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Require Specific Output Format',
|
||||
name: 'hasOutputParser',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [1, 1.1, 1.3],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Enable Fallback Model',
|
||||
name: 'needsFallback',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [1, 1.1, 1.3],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Chat Messages (if Using a Chat Model)',
|
||||
name: 'messages',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
placeholder: 'Add prompt',
|
||||
options: [
|
||||
{
|
||||
name: 'messageValues',
|
||||
displayName: 'Prompt',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Type Name or ID',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'AI',
|
||||
value: AIMessagePromptTemplate.lc_name(),
|
||||
},
|
||||
{
|
||||
name: 'System',
|
||||
value: SystemMessagePromptTemplate.lc_name(),
|
||||
},
|
||||
{
|
||||
name: 'User',
|
||||
value: HumanMessagePromptTemplate.lc_name(),
|
||||
},
|
||||
],
|
||||
default: SystemMessagePromptTemplate.lc_name(),
|
||||
},
|
||||
{
|
||||
displayName: 'Message Type',
|
||||
name: 'messageType',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: [HumanMessagePromptTemplate.lc_name()],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
description: 'Simple text message',
|
||||
},
|
||||
{
|
||||
name: 'Image (Binary)',
|
||||
value: 'imageBinary',
|
||||
description: 'Process the binary input from the previous node',
|
||||
},
|
||||
{
|
||||
name: 'Image (URL)',
|
||||
value: 'imageUrl',
|
||||
description: 'Process the image from the specified URL',
|
||||
},
|
||||
],
|
||||
default: 'text',
|
||||
},
|
||||
{
|
||||
displayName: 'Image Data Field Name',
|
||||
name: 'binaryImageDataKey',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
required: true,
|
||||
description:
|
||||
"The name of the field in the chain's input that contains the binary image file to be processed",
|
||||
displayOptions: {
|
||||
show: {
|
||||
messageType: ['imageBinary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Image URL',
|
||||
name: 'imageUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'URL to the image to be processed',
|
||||
displayOptions: {
|
||||
show: {
|
||||
messageType: ['imageUrl'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Image Details',
|
||||
description:
|
||||
'Control how the model processes the image and generates its textual understanding',
|
||||
name: 'imageDetail',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: [HumanMessagePromptTemplate.lc_name()],
|
||||
messageType: ['imageBinary', 'imageUrl'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
value: 'auto',
|
||||
description:
|
||||
'Model will use the auto setting which will look at the image input size and decide if it should use the low or high setting',
|
||||
},
|
||||
{
|
||||
name: 'Low',
|
||||
value: 'low',
|
||||
description:
|
||||
'The model will receive a low-res 512px x 512px version of the image, and represent the image with a budget of 65 tokens. This allows the API to return faster responses and consume fewer input tokens for use cases that do not require high detail.',
|
||||
},
|
||||
{
|
||||
name: 'High',
|
||||
value: 'high',
|
||||
description:
|
||||
'Allows the model to see the low res image and then creates detailed crops of input images as 512px squares based on the input image size. Each of the detailed crops uses twice the token budget (65 tokens) for a total of 129 tokens.',
|
||||
},
|
||||
],
|
||||
default: 'auto',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Message',
|
||||
name: 'message',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
messageType: ['imageBinary', 'imageUrl'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
getBatchingOptionFields({
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.7 } }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
hasOutputParser: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Connect an additional language model on the canvas to use it as a fallback if the main model fails',
|
||||
name: 'fallbackNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
needsFallback: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
|
||||
import { ChatOllama } from '@langchain/ollama';
|
||||
import type { IExecuteFunctions, IBinaryData } from 'n8n-workflow';
|
||||
import { NodeOperationError, NodeConnectionTypes, OperationalError } from 'n8n-workflow';
|
||||
|
||||
import type { MessageTemplate } from './types';
|
||||
|
||||
export class UnsupportedMimeTypeError extends OperationalError {}
|
||||
|
||||
/**
|
||||
* Converts binary image data to a data URI
|
||||
*/
|
||||
export function dataUriFromImageData(binaryData: IBinaryData, bufferData: Buffer): string {
|
||||
if (!binaryData.mimeType?.startsWith('image/')) {
|
||||
throw new UnsupportedMimeTypeError(
|
||||
`${binaryData.mimeType} is not a supported type of binary data. Only images are supported.`,
|
||||
);
|
||||
}
|
||||
return `data:${binaryData.mimeType};base64,${bufferData.toString('base64')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a human message with image content from either binary data or URL
|
||||
*/
|
||||
export async function createImageMessage({
|
||||
context,
|
||||
itemIndex,
|
||||
message,
|
||||
}: {
|
||||
context: IExecuteFunctions;
|
||||
itemIndex: number;
|
||||
message: MessageTemplate;
|
||||
}): Promise<HumanMessage> {
|
||||
// Validate message type
|
||||
if (message.messageType !== 'imageBinary' && message.messageType !== 'imageUrl') {
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
'Invalid message type. Only imageBinary and imageUrl are supported',
|
||||
);
|
||||
}
|
||||
|
||||
const detail = message.imageDetail === 'auto' ? undefined : message.imageDetail;
|
||||
|
||||
// Handle image URL case
|
||||
if (message.messageType === 'imageUrl' && message.imageUrl) {
|
||||
return new HumanMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: message.imageUrl,
|
||||
detail,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Handle binary image case
|
||||
const binaryDataKey = message.binaryImageDataKey ?? 'data';
|
||||
const inputData = context.getInputData()[itemIndex];
|
||||
const binaryData = inputData.binary?.[binaryDataKey] as IBinaryData;
|
||||
|
||||
if (!binaryData) {
|
||||
throw new NodeOperationError(context.getNode(), 'No binary data set.');
|
||||
}
|
||||
|
||||
const bufferData = await context.helpers.getBinaryDataBuffer(itemIndex, binaryDataKey);
|
||||
const model = (await context.getInputConnectionData(
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
0,
|
||||
)) as BaseLanguageModel;
|
||||
|
||||
try {
|
||||
// Create data URI from binary data
|
||||
const dataURI = dataUriFromImageData(binaryData, bufferData);
|
||||
|
||||
// Some models need different image URL formats
|
||||
const directUriModels = [ChatGoogleGenerativeAI, ChatOllama];
|
||||
const imageUrl = directUriModels.some((i) => model instanceof i)
|
||||
? dataURI
|
||||
: { url: dataURI, detail };
|
||||
|
||||
return new HumanMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: imageUrl,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedMimeTypeError)
|
||||
throw new NodeOperationError(context.getNode(), error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { executeChain } from './chainExecutor';
|
||||
export { getInputs, nodeProperties } from './config';
|
||||
export { formatResponse } from './responseFormatter';
|
||||
export type { MessageTemplate } from './types';
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { type IExecuteFunctions, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
import assert from 'node:assert';
|
||||
|
||||
import { getPromptInputByType } from '@utils/helpers';
|
||||
import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import { executeChain } from './chainExecutor';
|
||||
import { type MessageTemplate } from './types';
|
||||
|
||||
async function getChatModel(
|
||||
ctx: IExecuteFunctions,
|
||||
index: number = 0,
|
||||
): Promise<BaseLanguageModel | undefined> {
|
||||
const connectedModels = await ctx.getInputConnectionData(NodeConnectionTypes.AiLanguageModel, 0);
|
||||
|
||||
let model;
|
||||
|
||||
if (Array.isArray(connectedModels) && index !== undefined) {
|
||||
if (connectedModels.length <= index) {
|
||||
return undefined;
|
||||
}
|
||||
// We get the models in reversed order from the workflow so we need to reverse them again to match the right index
|
||||
const reversedModels = [...connectedModels].reverse();
|
||||
model = reversedModels[index] as BaseLanguageModel;
|
||||
} else {
|
||||
model = connectedModels as BaseLanguageModel;
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
export const processItem = async (ctx: IExecuteFunctions, itemIndex: number) => {
|
||||
const needsFallback = ctx.getNodeParameter('needsFallback', 0, false) as boolean;
|
||||
const llm = await getChatModel(ctx, 0);
|
||||
assert(llm, 'Please connect a model to the Chat Model input');
|
||||
|
||||
const fallbackLlm = needsFallback ? await getChatModel(ctx, 1) : null;
|
||||
if (needsFallback && !fallbackLlm) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'Please connect a model to the Fallback Model input or disable the fallback option',
|
||||
);
|
||||
}
|
||||
|
||||
// Get output parser if configured
|
||||
const outputParser = await getOptionalOutputParser(ctx, itemIndex);
|
||||
|
||||
// Get user prompt based on node version
|
||||
let prompt: string;
|
||||
|
||||
if (ctx.getNode().typeVersion <= 1.3) {
|
||||
prompt = ctx.getNodeParameter('prompt', itemIndex) as string;
|
||||
} else {
|
||||
prompt = getPromptInputByType({
|
||||
ctx,
|
||||
i: itemIndex,
|
||||
inputKey: 'text',
|
||||
promptTypeKey: 'promptType',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate prompt
|
||||
if (prompt === undefined) {
|
||||
throw new NodeOperationError(ctx.getNode(), "The 'prompt' parameter is empty.");
|
||||
}
|
||||
|
||||
// Get chat messages if configured
|
||||
const messages = ctx.getNodeParameter(
|
||||
'messages.messageValues',
|
||||
itemIndex,
|
||||
[],
|
||||
) as MessageTemplate[];
|
||||
|
||||
// Execute the chain
|
||||
return await executeChain({
|
||||
context: ctx,
|
||||
itemIndex,
|
||||
query: prompt,
|
||||
llm,
|
||||
outputParser,
|
||||
messages,
|
||||
fallbackLlm,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { AgentAction, AgentFinish } from '@langchain/core/agents';
|
||||
import { BaseMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import type { BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
|
||||
import {
|
||||
AIMessagePromptTemplate,
|
||||
ChatPromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
PromptTemplate,
|
||||
SystemMessagePromptTemplate,
|
||||
} from '@langchain/core/prompts';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
import { isChatInstance } from '@n8n/ai-utilities';
|
||||
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import { createImageMessage } from './imageUtils';
|
||||
import type { MessageTemplate, PromptParams } from './types';
|
||||
|
||||
/**
|
||||
* Creates a basic query template that may include format instructions
|
||||
*/
|
||||
function buildQueryTemplate(formatInstructions?: string): PromptTemplate {
|
||||
return new PromptTemplate({
|
||||
template: `{query}${formatInstructions ? '\n{formatInstructions}' : ''}`,
|
||||
inputVariables: ['query'],
|
||||
partialVariables: formatInstructions ? { formatInstructions } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an array of message templates into LangChain message objects
|
||||
*/
|
||||
async function processMessageTemplates({
|
||||
context,
|
||||
itemIndex,
|
||||
messages,
|
||||
}: {
|
||||
context: IExecuteFunctions;
|
||||
itemIndex: number;
|
||||
messages: MessageTemplate[];
|
||||
}): Promise<BaseMessagePromptTemplateLike[]> {
|
||||
return await Promise.all(
|
||||
messages.map(async (message) => {
|
||||
// Find the appropriate message class based on type
|
||||
const messageClass = [
|
||||
SystemMessagePromptTemplate,
|
||||
AIMessagePromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
].find((m) => m.lc_name() === message.type);
|
||||
|
||||
if (!messageClass) {
|
||||
throw new OperationalError('Invalid message type', {
|
||||
extra: { messageType: message.type },
|
||||
});
|
||||
}
|
||||
|
||||
// Handle image messages specially for human messages
|
||||
if (messageClass === HumanMessagePromptTemplate && message.messageType !== 'text') {
|
||||
return await createImageMessage({ context, itemIndex, message });
|
||||
}
|
||||
|
||||
// Process text messages
|
||||
// Escape curly braces in the message to prevent LangChain from treating them as variables
|
||||
return messageClass.fromTemplate(
|
||||
(message.message || '').replace(/[{}]/g, (match) => match + match),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes the prompt template by adding or updating the query in the message chain
|
||||
*/
|
||||
async function finalizePromptTemplate({
|
||||
parsedMessages,
|
||||
queryTemplate,
|
||||
query,
|
||||
}: {
|
||||
parsedMessages: BaseMessagePromptTemplateLike[];
|
||||
queryTemplate: PromptTemplate;
|
||||
query?: string;
|
||||
}): Promise<ChatPromptTemplate> {
|
||||
// Check if the last message is a human message with multi-content array
|
||||
const lastMessage = parsedMessages[parsedMessages.length - 1];
|
||||
|
||||
if (lastMessage instanceof HumanMessage && Array.isArray(lastMessage.content)) {
|
||||
// Add the query to the existing human message content
|
||||
const humanMessage = new HumanMessagePromptTemplate(queryTemplate);
|
||||
|
||||
// Format the message with the query and add the content synchronously
|
||||
const formattedMessage = await humanMessage.format({ query });
|
||||
|
||||
// Create a new array with the existing content plus the new item
|
||||
if (Array.isArray(lastMessage.content)) {
|
||||
// Clone the current content array and add the new item
|
||||
const updatedContent = [
|
||||
...lastMessage.content,
|
||||
{
|
||||
text: formattedMessage.content.toString(),
|
||||
type: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
// Replace the content with the updated array
|
||||
lastMessage.content = updatedContent;
|
||||
}
|
||||
} else {
|
||||
// Otherwise, add a new human message with the query
|
||||
parsedMessages.push(new HumanMessagePromptTemplate(queryTemplate));
|
||||
}
|
||||
|
||||
return ChatPromptTemplate.fromMessages(parsedMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the appropriate prompt template based on model type (chat vs completion)
|
||||
* and provided messages
|
||||
*/
|
||||
export async function createPromptTemplate({
|
||||
context,
|
||||
itemIndex,
|
||||
llm,
|
||||
messages,
|
||||
formatInstructions,
|
||||
query,
|
||||
}: PromptParams) {
|
||||
// Create base query template
|
||||
const queryTemplate = buildQueryTemplate(formatInstructions);
|
||||
|
||||
// For non-chat models, just return the query template
|
||||
if (!isChatInstance(llm)) {
|
||||
return queryTemplate;
|
||||
}
|
||||
|
||||
// For chat models, process the messages if provided
|
||||
const parsedMessages = messages?.length
|
||||
? await processMessageTemplates({ context, itemIndex, messages })
|
||||
: [];
|
||||
|
||||
// Add or update the query in the message chain
|
||||
return await finalizePromptTemplate({
|
||||
parsedMessages,
|
||||
queryTemplate,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
const isMessage = (message: unknown): message is BaseMessage => {
|
||||
return message instanceof BaseMessage;
|
||||
};
|
||||
|
||||
const isAgentFinish = (value: unknown): value is AgentFinish => {
|
||||
return typeof value === 'object' && value !== null && 'returnValues' in value;
|
||||
};
|
||||
|
||||
export const getAgentStepsParser =
|
||||
(outputParser: N8nOutputParser) =>
|
||||
async (
|
||||
steps: AgentFinish | BaseMessage | AgentAction[] | string,
|
||||
): Promise<string | Record<string, unknown>> => {
|
||||
if (typeof steps === 'string') {
|
||||
return (await outputParser.parse(steps)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Check if the steps contain the 'format_final_json_response' tool invocation.
|
||||
if (Array.isArray(steps)) {
|
||||
const responseParserTool = steps.find((step) => step.tool === 'format_final_json_response');
|
||||
if (responseParserTool) {
|
||||
const toolInput = responseParserTool.toolInput;
|
||||
// Ensure the tool input is a string
|
||||
const parserInput = toolInput instanceof Object ? JSON.stringify(toolInput) : toolInput;
|
||||
const parsedOutput = (await outputParser.parse(parserInput)) as Record<string, unknown>;
|
||||
return parsedOutput;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof steps === 'object' && isMessage(steps)) {
|
||||
const output = steps.text;
|
||||
|
||||
const parsedOutput = (await outputParser.parse(output)) as Record<string, unknown>;
|
||||
return parsedOutput;
|
||||
}
|
||||
|
||||
if (isAgentFinish(steps)) {
|
||||
const returnValues = steps.returnValues;
|
||||
const parsedOutput = (await outputParser.parse(JSON.stringify(returnValues))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return parsedOutput;
|
||||
}
|
||||
|
||||
throw new Error('Failed to parse agent steps');
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Formats the response from the LLM chain into a consistent structure
|
||||
*/
|
||||
export function formatResponse(response: unknown, returnUnwrappedObject: boolean): IDataObject {
|
||||
if (typeof response === 'string') {
|
||||
return {
|
||||
text: response.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
return {
|
||||
data: response,
|
||||
};
|
||||
}
|
||||
|
||||
if (response instanceof Object) {
|
||||
if (returnUnwrappedObject) {
|
||||
return response as IDataObject;
|
||||
}
|
||||
|
||||
// If the response is an object and we are not unwrapping it, we need to stringify it
|
||||
// to be backwards compatible with older versions of the chain(< 1.6)
|
||||
return {
|
||||
text: JSON.stringify(response),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: {
|
||||
text: response,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
/**
|
||||
* Interface for describing a message template in the UI
|
||||
*/
|
||||
export interface MessageTemplate {
|
||||
type: string;
|
||||
message: string;
|
||||
messageType: 'text' | 'imageBinary' | 'imageUrl';
|
||||
binaryImageDataKey?: string;
|
||||
imageUrl?: string;
|
||||
imageDetail?: 'auto' | 'low' | 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for prompt creation
|
||||
*/
|
||||
export interface PromptParams {
|
||||
context: IExecuteFunctions;
|
||||
itemIndex: number;
|
||||
llm: BaseLanguageModel | BaseChatModel;
|
||||
messages?: MessageTemplate[];
|
||||
formatInstructions?: string;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for chain execution
|
||||
*/
|
||||
export interface ChainExecutionParams {
|
||||
context: IExecuteFunctions;
|
||||
itemIndex: number;
|
||||
query: string;
|
||||
llm: BaseLanguageModel;
|
||||
outputParser?: N8nOutputParser;
|
||||
messages?: MessageTemplate[];
|
||||
fallbackLlm?: BaseLanguageModel | null;
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { FakeChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeApiError, NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import * as helperModule from '@utils/helpers';
|
||||
import * as outputParserModule from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import { ChainLlm } from '../ChainLlm.node';
|
||||
import * as executeChainModule from '../methods/chainExecutor';
|
||||
import * as responseFormatterModule from '../methods/responseFormatter';
|
||||
|
||||
jest.mock('@utils/helpers', () => ({
|
||||
getPromptInputByType: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@utils/output_parsers/N8nOutputParser', () => ({
|
||||
getOptionalOutputParser: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../methods/chainExecutor', () => ({
|
||||
executeChain: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../methods/responseFormatter', () => ({
|
||||
formatResponse: jest.fn().mockImplementation((response) => {
|
||||
if (typeof response === 'string') {
|
||||
return { text: response.trim() };
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ChainLlm Node', () => {
|
||||
let node: ChainLlm;
|
||||
let mockExecuteFunction: jest.Mocked<IExecuteFunctions>;
|
||||
let needsFallback: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
node = new ChainLlm();
|
||||
mockExecuteFunction = mock<IExecuteFunctions>();
|
||||
|
||||
mockExecuteFunction.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
needsFallback = false;
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.5,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'messages.messageValues') return [];
|
||||
if (param === 'needsFallback') return needsFallback;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue(fakeLLM);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('description', () => {
|
||||
it('should have the expected properties', () => {
|
||||
expect(node.description).toBeDefined();
|
||||
expect(node.description.name).toBe('chainLlm');
|
||||
expect(node.description.displayName).toBe('Basic LLM Chain');
|
||||
expect(node.description.version).toContain(1.5);
|
||||
expect(node.description.properties).toBeDefined();
|
||||
expect(node.description.inputs).toBeDefined();
|
||||
expect(node.description.outputs).toEqual([NodeConnectionTypes.Main]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute the chain with the correct parameters', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(['Test response']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledWith({
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Test prompt',
|
||||
fallbackLlm: null,
|
||||
llm: expect.any(FakeChatModel),
|
||||
outputParser: undefined,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(mockExecuteFunction.logger.debug).toHaveBeenCalledWith('Executing Basic LLM Chain');
|
||||
|
||||
expect(result).toEqual([[{ json: expect.any(Object) }]]);
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledTimes(2);
|
||||
expect(result[0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should use the prompt parameter directly for older versions', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.3,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'prompt') return 'Old version prompt';
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(['Test response']);
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledWith({
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Old version prompt',
|
||||
fallbackLlm: null,
|
||||
llm: expect.any(Object),
|
||||
outputParser: undefined,
|
||||
messages: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if prompt is empty', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue(undefined);
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
mockExecuteFunction.getNode.mockReturnValue({ name: 'Test Node' } as INode);
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunction)).rejects.toThrow(/prompt.*empty/);
|
||||
});
|
||||
|
||||
it('should continue on failure when configured', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
const error = new Error('Test error');
|
||||
(executeChainModule.executeChain as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(true);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result).toEqual([[{ json: { error: 'Test error' }, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
|
||||
it('should handle multiple response items from executeChain', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([
|
||||
'Response 1',
|
||||
'Response 2',
|
||||
]);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('batching (version 1.7+)', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.7,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
});
|
||||
|
||||
it('should process items in batches with default settings', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3');
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledTimes(3);
|
||||
expect(result[0]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should process items in smaller batches', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
{ json: { item: 4 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3')
|
||||
.mockReturnValueOnce('Test prompt 4');
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3'])
|
||||
.mockResolvedValueOnce(['Response 4']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledTimes(4);
|
||||
expect(result[0]).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should handle errors in batches with continueOnFail', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(true);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockRejectedValueOnce(new Error('Test error'));
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][1].json).toEqual({ error: 'Test error' });
|
||||
});
|
||||
|
||||
it('should handle OpenAI rate limit errors in batches', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(true);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
const openAiError = new NodeApiError(mockExecuteFunction.getNode(), {
|
||||
message: 'Rate limit exceeded',
|
||||
cause: { error: { code: 'rate_limit_exceeded' } },
|
||||
});
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockRejectedValueOnce(openAiError);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][1].json).toEqual({ error: expect.stringContaining('Rate limit') });
|
||||
});
|
||||
});
|
||||
|
||||
it('should unwrap object responses when node version is 1.6 or higher', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const structuredResponse = {
|
||||
person: { name: 'John', age: 30 },
|
||||
items: ['item1', 'item2'],
|
||||
active: true,
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([structuredResponse]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(formatResponseSpy).toHaveBeenCalledWith(structuredResponse, true);
|
||||
});
|
||||
|
||||
it('should unwrap object responses when output parser is provided regardless of version', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.5,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(
|
||||
mock<outputParserModule.N8nOutputParser>(),
|
||||
);
|
||||
|
||||
const structuredResponse = {
|
||||
result: 'success',
|
||||
data: { key: 'value' },
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([structuredResponse]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(formatResponseSpy).toHaveBeenCalledWith(structuredResponse, true);
|
||||
});
|
||||
|
||||
it('should wrap object responses as text when node version is lower than 1.6 and no output parser', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.5,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const structuredResponse = {
|
||||
person: { name: 'John', age: 30 },
|
||||
items: ['item1', 'item2'],
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([structuredResponse]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(formatResponseSpy).toHaveBeenCalledWith(structuredResponse, false);
|
||||
});
|
||||
|
||||
it('should handle a mix of different response types with the correct wrapping', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const mixedResponses = ['Text response', { structured: 'object' }, ['array', 'response']];
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(mixedResponses);
|
||||
|
||||
(responseFormatterModule.formatResponse as jest.Mock).mockClear();
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenCalledTimes(3);
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Text response',
|
||||
true,
|
||||
);
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ structured: 'object' },
|
||||
true,
|
||||
);
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['array', 'response'],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle LLM responses containing JSON with markdown content', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue(
|
||||
'Generate markdown documentation',
|
||||
);
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const markdownResponse = {
|
||||
title: 'API Documentation',
|
||||
sections: [
|
||||
{
|
||||
name: 'Authentication',
|
||||
content:
|
||||
"# Authentication\n\nUse API keys for all requests:\n\n```javascript\nconst headers = {\n 'Authorization': 'Bearer YOUR_API_KEY'\n};\n```",
|
||||
},
|
||||
{
|
||||
name: 'Endpoints',
|
||||
content:
|
||||
'## Available Endpoints\n\n* GET /users - List all users\n* POST /users - Create a user\n* GET /users/{id} - Get user details',
|
||||
},
|
||||
],
|
||||
examples: {
|
||||
curl: "```bash\ncurl -X GET https://api.example.com/users \\\n -H 'Authorization: Bearer YOUR_API_KEY'\n```",
|
||||
response: '```json\n{\n "users": [],\n "count": 0\n}\n```',
|
||||
},
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([markdownResponse]);
|
||||
|
||||
(responseFormatterModule.formatResponse as jest.Mock).mockImplementation(
|
||||
(response, shouldUnwrap) => {
|
||||
if (shouldUnwrap && typeof response === 'object') {
|
||||
return response;
|
||||
}
|
||||
return { text: JSON.stringify(response) };
|
||||
},
|
||||
);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: markdownResponse,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenCalledWith(markdownResponse, true);
|
||||
});
|
||||
|
||||
it('should use fallback llm if enabled', async () => {
|
||||
needsFallback = true;
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(['Test response']);
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const fakeFallbackLLM = new FakeChatModel({});
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([fakeLLM, fakeFallbackLLM]);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledWith({
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Test prompt',
|
||||
fallbackLlm: expect.any(FakeChatModel),
|
||||
llm: expect.any(FakeChatModel),
|
||||
outputParser: undefined,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(mockExecuteFunction.logger.debug).toHaveBeenCalledWith('Executing Basic LLM Chain');
|
||||
|
||||
expect(result).toEqual([[{ json: expect.any(Object) }]]);
|
||||
});
|
||||
|
||||
it('should pass correct itemIndex to getOptionalOutputParser', async () => {
|
||||
// Clear any previous calls to the mock
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockClear();
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
]);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3');
|
||||
|
||||
const mockParser1 = mock<outputParserModule.N8nOutputParser>();
|
||||
const mockParser2 = mock<outputParserModule.N8nOutputParser>();
|
||||
const mockParser3 = mock<outputParserModule.N8nOutputParser>();
|
||||
|
||||
// Use the already mocked function instead of creating a spy
|
||||
// First call is for the initial check in execute(), then one per item
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock)
|
||||
.mockResolvedValueOnce(undefined) // Initial call in execute()
|
||||
.mockResolvedValueOnce(mockParser1)
|
||||
.mockResolvedValueOnce(mockParser2)
|
||||
.mockResolvedValueOnce(mockParser3);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3']);
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
// Verify getOptionalOutputParser was called with correct indices
|
||||
// First call without index, then 3 calls with indices
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenCalledTimes(4);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockExecuteFunction,
|
||||
); // Initial call
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockExecuteFunction,
|
||||
0,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
mockExecuteFunction,
|
||||
1,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
mockExecuteFunction,
|
||||
2,
|
||||
);
|
||||
|
||||
// Verify executeChain was called with the corresponding parsers
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(1, {
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Test prompt 1',
|
||||
llm: expect.any(Object),
|
||||
fallbackLlm: null,
|
||||
outputParser: mockParser1,
|
||||
messages: [],
|
||||
});
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(2, {
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 1,
|
||||
query: 'Test prompt 2',
|
||||
llm: expect.any(Object),
|
||||
fallbackLlm: null,
|
||||
outputParser: mockParser2,
|
||||
messages: [],
|
||||
});
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(3, {
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 2,
|
||||
query: 'Test prompt 3',
|
||||
llm: expect.any(Object),
|
||||
fallbackLlm: null,
|
||||
outputParser: mockParser3,
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different output parsers for each item', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
// First item has no parser, second has a parser
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(mock<outputParserModule.N8nOutputParser>());
|
||||
|
||||
const response1 = { text: 'plain response' };
|
||||
const response2 = { structured: 'response' };
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce([response1])
|
||||
.mockResolvedValueOnce([response2]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
// First item without parser should not unwrap objects (even in v1.6)
|
||||
// Actually, let me check the logic again... v1.6 unwraps by default
|
||||
// but having an output parser always triggers unwrapping
|
||||
expect(formatResponseSpy).toHaveBeenNthCalledWith(1, response1, true); // v1.6 unwraps
|
||||
expect(formatResponseSpy).toHaveBeenNthCalledWith(2, response2, true); // has parser, unwraps
|
||||
});
|
||||
|
||||
it('should maintain parser consistency across batch processing', async () => {
|
||||
// Clear any previous calls to the mock
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockClear();
|
||||
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.7,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
{ json: { item: 4 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3')
|
||||
.mockReturnValueOnce('Test prompt 4');
|
||||
|
||||
const mockParsers = [
|
||||
mock<outputParserModule.N8nOutputParser>(),
|
||||
undefined,
|
||||
mock<outputParserModule.N8nOutputParser>(),
|
||||
undefined,
|
||||
];
|
||||
|
||||
// Use the already mocked function instead of creating a spy
|
||||
// Account for initial call without index
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockImplementation(
|
||||
async (_ctx, index) => {
|
||||
if (index === undefined) return undefined; // Initial call
|
||||
return mockParsers[index];
|
||||
},
|
||||
);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3'])
|
||||
.mockResolvedValueOnce(['Response 4']);
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
// Verify each item was processed with correct index
|
||||
// First call without index, then 4 calls with indices
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenCalledTimes(5);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockExecuteFunction,
|
||||
); // Initial call
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockExecuteFunction,
|
||||
0,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
mockExecuteFunction,
|
||||
1,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
mockExecuteFunction,
|
||||
2,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
mockExecuteFunction,
|
||||
3,
|
||||
);
|
||||
|
||||
// Verify executeChain received correct parsers
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[0],
|
||||
}),
|
||||
);
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[1],
|
||||
}),
|
||||
);
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[2],
|
||||
}),
|
||||
);
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[3],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,616 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { JsonOutputParser, StringOutputParser } from '@langchain/core/output_parsers';
|
||||
import { ChatPromptTemplate, PromptTemplate } from '@langchain/core/prompts';
|
||||
import { FakeLLM, FakeChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
import * as tracing from '@utils/tracing';
|
||||
|
||||
import { executeChain, NaiveJsonOutputParser } from '../methods/chainExecutor';
|
||||
import * as chainExecutor from '../methods/chainExecutor';
|
||||
import * as promptUtils from '../methods/promptUtils';
|
||||
|
||||
jest.mock('@utils/tracing', () => ({
|
||||
getTracingConfig: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('../methods/promptUtils', () => ({
|
||||
createPromptTemplate: jest.fn(),
|
||||
getAgentStepsParser: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('chainExecutor', () => {
|
||||
let mockContext: jest.Mocked<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getExecutionCancelSignal = jest.fn().mockReturnValue(undefined);
|
||||
mockContext.getNode = jest.fn().mockReturnValue({
|
||||
typeVersion: 1.5,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getOutputParserForLLM', () => {
|
||||
it('should return NaiveJsonOutputParser for OpenAI-like models with json_object response format', () => {
|
||||
const openAILikeModel = {
|
||||
modelKwargs: {
|
||||
response_format: {
|
||||
type: 'json_object',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const parser = chainExecutor.getOutputParserForLLM(
|
||||
openAILikeModel as unknown as BaseChatModel,
|
||||
);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return NaiveJsonOutputParser for Ollama models with json format', () => {
|
||||
const ollamaLikeModel = {
|
||||
format: 'json',
|
||||
};
|
||||
|
||||
const parser = chainExecutor.getOutputParserForLLM(
|
||||
ollamaLikeModel as unknown as BaseChatModel,
|
||||
);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return StringOutputParser for models without JSON format settings', () => {
|
||||
const regularModel = new FakeLLM({});
|
||||
|
||||
const parser = chainExecutor.getOutputParserForLLM(regularModel);
|
||||
expect(parser).toBeInstanceOf(StringOutputParser);
|
||||
});
|
||||
|
||||
it('should return NaiveJsonOutputParser for Anthropic models in thinking mode', () => {
|
||||
const model = {
|
||||
lc_kwargs: {
|
||||
invocationKwargs: {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const parser = chainExecutor.getOutputParserForLLM(model as unknown as BaseChatModel);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return NaiveJsonOutputParser for models with metadata output_format set to json', () => {
|
||||
const model = mock<BaseChatModel>({
|
||||
metadata: {
|
||||
output_format: 'json',
|
||||
},
|
||||
});
|
||||
const parser = chainExecutor.getOutputParserForLLM(model);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return StringOutputParser for models with metadata output_format not set to json', () => {
|
||||
const model = mock<BaseChatModel>({
|
||||
metadata: {
|
||||
output_format: 'text',
|
||||
},
|
||||
});
|
||||
const parser = chainExecutor.getOutputParserForLLM(model);
|
||||
expect(parser).toBeInstanceOf(StringOutputParser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NaiveJsonOutputParser', () => {
|
||||
it('should parse valid JSON directly', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonStr = '{"name": "John", "age": 30}';
|
||||
|
||||
const result = await parser.parse(jsonStr);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested JSON objects', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonStr = '{"person": {"name": "John", "age": 30}, "active": true}';
|
||||
|
||||
const result = await parser.parse(jsonStr);
|
||||
|
||||
expect(result).toEqual({
|
||||
person: {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
},
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use parent class parser for malformed JSON', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const superParseSpy = jest.spyOn(JsonOutputParser.prototype, 'parse').mockResolvedValue({
|
||||
parsed: 'content',
|
||||
});
|
||||
|
||||
const malformedJson = 'Sure, here is your JSON: {"name": "John", "age": 30}';
|
||||
|
||||
await parser.parse(malformedJson);
|
||||
|
||||
expect(superParseSpy).toHaveBeenCalledWith(malformedJson);
|
||||
superParseSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle JSON with surrounding text by using parent parser', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithText = 'Here is the result: {"result": "success", "code": 200}';
|
||||
|
||||
// Mock the parent class parse method
|
||||
const mockParsedResult = { result: 'success', code: 200 };
|
||||
const superParseSpy = jest
|
||||
.spyOn(JsonOutputParser.prototype, 'parse')
|
||||
.mockResolvedValue(mockParsedResult);
|
||||
|
||||
const result = await parser.parse(jsonWithText);
|
||||
|
||||
expect(superParseSpy).toHaveBeenCalledWith(jsonWithText);
|
||||
expect(result).toEqual(mockParsedResult);
|
||||
|
||||
superParseSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should correctly parse JSON with markdown text inside properties', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithMarkdown = `{
|
||||
"title": "Markdown Guide",
|
||||
"content": "# Heading 1\\n## Heading 2\\n* Bullet point\\n* Another bullet\\n\\n\`\`\`code block\`\`\`\\n> Blockquote",
|
||||
"description": "A guide with **bold** and *italic* text"
|
||||
}`;
|
||||
|
||||
const result = await parser.parse(jsonWithMarkdown);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'Markdown Guide',
|
||||
content:
|
||||
'# Heading 1\n## Heading 2\n* Bullet point\n* Another bullet\n\n```code block```\n> Blockquote',
|
||||
description: 'A guide with **bold** and *italic* text',
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly parse JSON with markdown code blocks containing JSON', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithMarkdownAndNestedJson = `{
|
||||
"title": "JSON Examples",
|
||||
"examples": "Here's an example of JSON: \`\`\`json\\n{\\"nested\\": \\"json\\", \\"in\\": \\"code block\\"}\\n\`\`\`",
|
||||
"valid": true
|
||||
}`;
|
||||
|
||||
const result = await parser.parse(jsonWithMarkdownAndNestedJson);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'JSON Examples',
|
||||
examples:
|
||||
'Here\'s an example of JSON: ```json\n{"nested": "json", "in": "code block"}\n```',
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON with special characters in markdown content', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithSpecialChars = `{
|
||||
"title": "Special Characters",
|
||||
"content": "# Testing \\n\\n * List with **bold** & *italic*\\n * Item with [link](https://example.com)\\n * Math: 2 < 3 > 1 && true || false",
|
||||
"technical": "function test() { return x < y && z > w; }"
|
||||
}`;
|
||||
|
||||
const result = await parser.parse(jsonWithSpecialChars);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'Special Characters',
|
||||
content:
|
||||
'# Testing \n\n * List with **bold** & *italic*\n * Item with [link](https://example.com)\n * Math: 2 < 3 > 1 && true || false',
|
||||
technical: 'function test() { return x < y && z > w; }',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeChain', () => {
|
||||
it('should execute a simple chain without output parsers', async () => {
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
});
|
||||
|
||||
expect(promptUtils.createPromptTemplate).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
messages: undefined,
|
||||
query: 'Hello',
|
||||
});
|
||||
|
||||
expect(pipeMock).toHaveBeenCalledWith(fakeLLM);
|
||||
expect(pipeStringOutputParserMock).toHaveBeenCalledWith(expect.any(StringOutputParser));
|
||||
expect(withConfigMock).toHaveBeenCalledWith(expect.any(Object));
|
||||
|
||||
expect(result).toEqual(['Test response']);
|
||||
|
||||
expect(tracing.getTracingConfig).toHaveBeenCalledWith(mockContext);
|
||||
});
|
||||
|
||||
it('should execute a chain with a single output parser', async () => {
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}\n{formatInstructions}',
|
||||
inputVariables: ['query'],
|
||||
partialVariables: { formatInstructions: 'Format as JSON' },
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'Test response' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mock<N8nOutputParser>(),
|
||||
});
|
||||
|
||||
expect(promptUtils.createPromptTemplate).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
messages: undefined,
|
||||
query: 'Hello',
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ result: 'Test response' }]);
|
||||
});
|
||||
|
||||
it('should wrap non-array responses in an array', async () => {
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockOutputParser = mock<N8nOutputParser>();
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'Test response' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mockOutputParser,
|
||||
});
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toEqual([{ result: 'Test response' }]);
|
||||
});
|
||||
|
||||
it('should pass the execution cancel signal to the chain', async () => {
|
||||
// For this test, we'll just verify that getExecutionCancelSignal is called
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
});
|
||||
|
||||
expect(mockContext.getExecutionCancelSignal).toHaveBeenCalled();
|
||||
expect(mockChain.invoke).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should support chat models', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const mockChatPromptTemplate = ChatPromptTemplate.fromMessages([]);
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test chat response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockChatPromptTemplate.pipe = pipeMock;
|
||||
fakeChatModel.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockChatPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeChatModel,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['Test chat response']);
|
||||
});
|
||||
|
||||
it('should use JsonOutputParser for OpenAI models with json_object response format', async () => {
|
||||
const fakeOpenAIModel = new FakeChatModel({});
|
||||
(
|
||||
fakeOpenAIModel as unknown as { modelKwargs: { response_format: { type: string } } }
|
||||
).modelKwargs = {
|
||||
response_format: { type: 'json_object' },
|
||||
};
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('{"result": "json data"}'),
|
||||
};
|
||||
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeOpenAIModel,
|
||||
});
|
||||
|
||||
expect(pipeOutputParserMock).toHaveBeenCalledWith(expect.any(JsonOutputParser));
|
||||
});
|
||||
|
||||
it('should use JsonOutputParser for Ollama models with json format', async () => {
|
||||
const fakeOllamaModel = new FakeChatModel({});
|
||||
(fakeOllamaModel as unknown as { format: string }).format = 'json';
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('{"result": "json data"}'),
|
||||
};
|
||||
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeOllamaModel,
|
||||
});
|
||||
|
||||
expect(pipeOutputParserMock).toHaveBeenCalledWith(expect.any(JsonOutputParser));
|
||||
});
|
||||
|
||||
it('should use getAgentStepsParser for version 1.9+ when output parser is provided', async () => {
|
||||
mockContext.getNode = jest.fn().mockReturnValue({
|
||||
typeVersion: 1.9,
|
||||
});
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const mockOutputParser = mock<N8nOutputParser>({
|
||||
getFormatInstructions: jest.fn().mockReturnValue('Format as JSON'),
|
||||
});
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}\n{formatInstructions}',
|
||||
inputVariables: ['query'],
|
||||
partialVariables: { formatInstructions: 'Format as JSON' },
|
||||
});
|
||||
|
||||
const mockAgentStepsParser = jest.fn().mockResolvedValue({ result: 'parsed' });
|
||||
(promptUtils.getAgentStepsParser as jest.Mock).mockReturnValue(mockAgentStepsParser);
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'parsed' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeAgentStepsParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeAgentStepsParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mockOutputParser,
|
||||
});
|
||||
|
||||
expect(promptUtils.getAgentStepsParser).toHaveBeenCalledWith(mockOutputParser);
|
||||
expect(pipeMock).toHaveBeenCalledWith(fakeLLM);
|
||||
expect(pipeAgentStepsParserMock).toHaveBeenCalledWith(mockAgentStepsParser);
|
||||
});
|
||||
|
||||
it('should use direct output parser for versions < 1.9 when output parser is provided', async () => {
|
||||
mockContext.getNode = jest.fn().mockReturnValue({
|
||||
typeVersion: 1.8,
|
||||
});
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const mockOutputParser = mock<N8nOutputParser>({
|
||||
getFormatInstructions: jest.fn().mockReturnValue('Format as JSON'),
|
||||
});
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}\n{formatInstructions}',
|
||||
inputVariables: ['query'],
|
||||
partialVariables: { formatInstructions: 'Format as JSON' },
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'parsed' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mockOutputParser,
|
||||
});
|
||||
|
||||
expect(promptUtils.getAgentStepsParser).not.toHaveBeenCalled();
|
||||
expect(pipeMock).toHaveBeenCalledWith(fakeLLM);
|
||||
expect(pipeOutputParserMock).toHaveBeenCalledWith(mockOutputParser);
|
||||
});
|
||||
|
||||
it('should handle fallback LLM with built-in tools', async () => {
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const fakeFallbackLLM = new FakeChatModel({});
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
fakeLLM.withFallbacks = jest.fn().mockReturnValue(fakeLLM);
|
||||
fakeFallbackLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
fallbackLlm: fakeFallbackLLM,
|
||||
});
|
||||
|
||||
expect(fakeLLM.withFallbacks).toHaveBeenCalledWith([fakeFallbackLLM]);
|
||||
expect(pipeMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { getInputs, nodeProperties } from '../methods/config';
|
||||
|
||||
describe('config', () => {
|
||||
describe('getInputs', () => {
|
||||
it('should return basic inputs for all parameters', () => {
|
||||
const inputs = getInputs({});
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[0].type).toBe(NodeConnectionTypes.Main);
|
||||
expect(inputs[1].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiOutputParser);
|
||||
});
|
||||
|
||||
it('should exclude the OutputParser when hasOutputParser is false', () => {
|
||||
const inputs = getInputs({ hasOutputParser: false });
|
||||
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0].type).toBe(NodeConnectionTypes.Main);
|
||||
expect(inputs[1].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
});
|
||||
|
||||
it('should include the OutputParser when hasOutputParser is true', () => {
|
||||
const inputs = getInputs({ hasOutputParser: true });
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiOutputParser);
|
||||
});
|
||||
|
||||
it('should exclude the FallbackInput when needsFallback is false', () => {
|
||||
const inputs = getInputs({ hasOutputParser: true, needsFallback: false });
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[0].type).toBe(NodeConnectionTypes.Main);
|
||||
expect(inputs[1].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiOutputParser);
|
||||
});
|
||||
|
||||
it('should include the FallbackInput when needsFallback is true', () => {
|
||||
const inputs = getInputs({ hasOutputParser: false, needsFallback: true });
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeProperties', () => {
|
||||
it('should have the expected properties', () => {
|
||||
expect(Array.isArray(nodeProperties)).toBe(true);
|
||||
expect(nodeProperties.length).toBeGreaterThan(0);
|
||||
|
||||
const promptParams = nodeProperties.filter((prop) => prop.name === 'prompt');
|
||||
expect(promptParams.length).toBeGreaterThan(0);
|
||||
|
||||
const messagesParam = nodeProperties.find((prop) => prop.name === 'messages');
|
||||
expect(messagesParam).toBeDefined();
|
||||
expect(messagesParam?.type).toBe('fixedCollection');
|
||||
|
||||
const hasOutputParserParam = nodeProperties.find((prop) => prop.name === 'hasOutputParser');
|
||||
expect(hasOutputParserParam).toBeDefined();
|
||||
expect(hasOutputParserParam?.type).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
|
||||
import { ChatOllama } from '@langchain/ollama';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, IBinaryData, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
createImageMessage,
|
||||
dataUriFromImageData,
|
||||
UnsupportedMimeTypeError,
|
||||
} from '../methods/imageUtils';
|
||||
import type { MessageTemplate } from '../methods/types';
|
||||
|
||||
// Mock ChatGoogleGenerativeAI and ChatOllama
|
||||
jest.mock('@langchain/google-genai', () => ({
|
||||
ChatGoogleGenerativeAI: class MockChatGoogleGenerativeAI {},
|
||||
}));
|
||||
|
||||
jest.mock('@langchain/ollama', () => ({
|
||||
ChatOllama: class MockChatOllama {},
|
||||
}));
|
||||
|
||||
// Create a better mock for IExecuteFunctions that includes helpers
|
||||
const createMockExecuteFunctions = () => {
|
||||
const mockExec = mock<IExecuteFunctions>();
|
||||
// Add missing helpers property with mocked getBinaryDataBuffer
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
mockExec.helpers = {
|
||||
getBinaryDataBuffer: jest.fn().mockResolvedValue(Buffer.from('Test image data')),
|
||||
} as any;
|
||||
return mockExec;
|
||||
};
|
||||
|
||||
describe('imageUtils', () => {
|
||||
describe('dataUriFromImageData', () => {
|
||||
it('should convert image data to data URI', () => {
|
||||
const mockBuffer = Buffer.from('Test data');
|
||||
const mockBinaryData = mock<IBinaryData>({ mimeType: 'image/jpeg' });
|
||||
|
||||
const dataUri = dataUriFromImageData(mockBinaryData, mockBuffer);
|
||||
expect(dataUri).toBe('data:image/jpeg;base64,VGVzdCBkYXRh');
|
||||
});
|
||||
|
||||
it('should throw UnsupportedMimeTypeError for non-images', () => {
|
||||
const mockBuffer = Buffer.from('Test data');
|
||||
const mockBinaryData = mock<IBinaryData>({ mimeType: 'text/plain' });
|
||||
|
||||
expect(() => {
|
||||
dataUriFromImageData(mockBinaryData, mockBuffer);
|
||||
}).toThrow(UnsupportedMimeTypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createImageMessage', () => {
|
||||
let mockContext: jest.Mocked<IExecuteFunctions>;
|
||||
let mockBuffer: Buffer;
|
||||
let mockBinaryData: jest.Mocked<IBinaryData>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockExecuteFunctions();
|
||||
mockBuffer = Buffer.from('Test image data');
|
||||
mockBinaryData = mock<IBinaryData>({ mimeType: 'image/png' });
|
||||
|
||||
// Mock required methods
|
||||
mockContext.getInputData.mockReturnValue([{ binary: { data: mockBinaryData }, json: {} }]);
|
||||
(mockContext.helpers.getBinaryDataBuffer as jest.Mock).mockResolvedValue(mockBuffer);
|
||||
mockContext.getInputConnectionData.mockResolvedValue({});
|
||||
mockContext.getNode.mockReturnValue({ name: 'TestNode' } as INode);
|
||||
});
|
||||
|
||||
it('should throw an error for invalid message type', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'text', // Invalid for this test case
|
||||
};
|
||||
|
||||
await expect(
|
||||
createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
}),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle image URL messages', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
imageDetail: 'high',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'https://example.com/image.jpg',
|
||||
detail: 'high',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle image URL messages with auto detail', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
imageDetail: 'auto',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'https://example.com/image.jpg',
|
||||
detail: undefined, // Auto becomes undefined
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw an error when binary data is missing', async () => {
|
||||
// Set up missing binary data
|
||||
mockContext.getInputData.mockReturnValue([{ json: {} }]); // No binary data
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
await expect(
|
||||
createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
}),
|
||||
).rejects.toThrow('No binary data set.');
|
||||
});
|
||||
|
||||
it('should handle binary image data for regular models', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
imageDetail: 'low',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'data:image/png;base64,VGVzdCBpbWFnZSBkYXRh',
|
||||
detail: 'low',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle image data differently for GoogleGenerativeAI models', async () => {
|
||||
// Mock a Google model - using our mocked class
|
||||
mockContext.getInputConnectionData.mockResolvedValue(
|
||||
new ChatGoogleGenerativeAI({
|
||||
model: 'gemini-2.5-flash',
|
||||
}),
|
||||
);
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: 'data:image/png;base64,VGVzdCBpbWFnZSBkYXRh',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle image data differently for Ollama models', async () => {
|
||||
// Mock an Ollama model - using our mocked class
|
||||
mockContext.getInputConnectionData.mockResolvedValue(new ChatOllama());
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: 'data:image/png;base64,VGVzdCBpbWFnZSBkYXRh',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should pass through UnsupportedMimeTypeError', async () => {
|
||||
// Mock a non-image mime type
|
||||
mockBinaryData.mimeType = 'application/pdf';
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
await expect(
|
||||
createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
}),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import type { AgentAction, AgentFinish } from '@langchain/core/agents';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatPromptTemplate, PromptTemplate } from '@langchain/core/prompts';
|
||||
import { FakeLLM, FakeChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
import type { N8nStructuredOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import * as imageUtils from '../methods/imageUtils';
|
||||
import { createPromptTemplate, getAgentStepsParser } from '../methods/promptUtils';
|
||||
import type { MessageTemplate } from '../methods/types';
|
||||
|
||||
jest.mock('../methods/imageUtils', () => ({
|
||||
createImageMessage: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('promptUtils', () => {
|
||||
describe('createPromptTemplate', () => {
|
||||
let mockContext: jest.Mocked<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = mock<IExecuteFunctions>();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a simple prompt template for non-chat models', async () => {
|
||||
const fakeLLM = new FakeLLM({});
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
query: 'Test query',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(PromptTemplate);
|
||||
expect(result.inputVariables).toContain('query');
|
||||
});
|
||||
|
||||
it('should create a prompt template with format instructions', async () => {
|
||||
const fakeLLM = new FakeLLM({});
|
||||
const formatInstructions = 'Format your response as JSON';
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
formatInstructions,
|
||||
query: 'Test query',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(PromptTemplate);
|
||||
expect(result.inputVariables).toContain('query');
|
||||
|
||||
// Check that format instructions are included in the template
|
||||
const formattedResult = await result.format({ query: 'Test' });
|
||||
expect(formattedResult).toContain(formatInstructions);
|
||||
});
|
||||
|
||||
it('should create a chat prompt template for chat models', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
query: 'Test query',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(ChatPromptTemplate);
|
||||
});
|
||||
|
||||
it('should process text messages correctly', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const messages: MessageTemplate[] = [
|
||||
{
|
||||
type: 'SystemMessagePromptTemplate',
|
||||
message: 'You are a helpful assistant',
|
||||
messageType: 'text',
|
||||
},
|
||||
{
|
||||
type: 'AIMessagePromptTemplate',
|
||||
message: 'How can I help you?',
|
||||
messageType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages,
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(ChatPromptTemplate);
|
||||
|
||||
const formattedMessages = await (result as ChatPromptTemplate).formatMessages({
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
expect(formattedMessages).toHaveLength(3); // 2 messages + 1 query
|
||||
expect(formattedMessages[0].content).toBe('You are a helpful assistant');
|
||||
expect(formattedMessages[1].content).toBe('How can I help you?');
|
||||
});
|
||||
|
||||
it('should escape curly braces in messages', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const messages: MessageTemplate[] = [
|
||||
{
|
||||
type: 'SystemMessagePromptTemplate',
|
||||
message: 'You are a {helpful} assistant',
|
||||
messageType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages,
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
|
||||
// Validate the messages have escaped curly braces
|
||||
const formattedMessages = await (result as ChatPromptTemplate).formatMessages({
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
expect(formattedMessages[0].content).toBe('You are a {helpful} assistant');
|
||||
});
|
||||
|
||||
it('should handle image messages by calling createImageMessage', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const imageMessage: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
};
|
||||
|
||||
// Mock the image message creation
|
||||
const mockHumanMessage = new HumanMessage({
|
||||
content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }],
|
||||
});
|
||||
(imageUtils.createImageMessage as jest.Mock).mockResolvedValue(mockHumanMessage);
|
||||
|
||||
await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages: [imageMessage],
|
||||
query: 'Describe this image',
|
||||
});
|
||||
|
||||
expect(imageUtils.createImageMessage).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message: imageMessage,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error for invalid message types', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const messages: MessageTemplate[] = [
|
||||
{
|
||||
type: 'InvalidMessageType',
|
||||
message: 'This is an invalid message',
|
||||
messageType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages,
|
||||
query: 'Test query',
|
||||
}),
|
||||
).rejects.toThrow(OperationalError);
|
||||
});
|
||||
|
||||
it('should add the query to an existing human message with content if it exists', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
|
||||
// Create a mock image message with content array
|
||||
const mockHumanMessage = new HumanMessage({
|
||||
content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }],
|
||||
});
|
||||
(imageUtils.createImageMessage as jest.Mock).mockResolvedValue(mockHumanMessage);
|
||||
|
||||
const imageMessage: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
};
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages: [imageMessage],
|
||||
query: 'Describe this image',
|
||||
});
|
||||
|
||||
// Format the message and check that the query was added to the existing content
|
||||
const formattedMessages = await (result as ChatPromptTemplate).formatMessages({
|
||||
query: 'Describe this image',
|
||||
});
|
||||
expect(formattedMessages).toHaveLength(1);
|
||||
|
||||
// The content should now have the original image and the text query
|
||||
const content = formattedMessages[0].content as any[];
|
||||
expect(content).toHaveLength(2);
|
||||
expect(content[0].type).toBe('image_url');
|
||||
expect(content[1].type).toBe('text');
|
||||
expect(content[1].text).toContain('Describe this image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentStepsParser', () => {
|
||||
let mockOutputParser: jest.Mocked<N8nStructuredOutputParser>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOutputParser = mock<N8nStructuredOutputParser>({
|
||||
parse: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse string input directly', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const parsedResult = { result: 'success' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser('{"result": "success"}');
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('{"result": "success"}');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse format_final_json_response tool input', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const toolInput = { city: 'Berlin', temperature: 15 };
|
||||
const steps: AgentAction[] = [
|
||||
{
|
||||
tool: 'format_final_json_response',
|
||||
toolInput,
|
||||
log: '',
|
||||
},
|
||||
];
|
||||
|
||||
const parsedResult = { city: 'Berlin', temperature: 15 };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(steps);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith(JSON.stringify(toolInput));
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should handle format_final_json_response with string tool input', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const toolInput = 'simple string';
|
||||
const steps: AgentAction[] = [
|
||||
{
|
||||
tool: 'format_final_json_response',
|
||||
toolInput,
|
||||
log: '',
|
||||
},
|
||||
];
|
||||
|
||||
const parsedResult = { text: 'simple string' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(steps);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('simple string');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse BaseMessage with text property', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const message = new HumanMessage({ content: [{ type: 'text', text: 'Hello world' }] });
|
||||
|
||||
const parsedResult = { message: 'Hello world' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(message);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('Hello world');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse BaseMessage with content array', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const message = new HumanMessage({
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } },
|
||||
],
|
||||
});
|
||||
|
||||
const parsedResult = { message: 'Hello' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(message);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('Hello');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse BaseMessage with string content', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const message = new HumanMessage({ content: 'Simple text content' });
|
||||
|
||||
const parsedResult = { content: 'Simple text content' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(message);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('Simple text content');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse AgentFinish returnValues', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const agentFinish: AgentFinish = {
|
||||
returnValues: { output: 'Final answer', status: 'success' },
|
||||
log: 'Finished',
|
||||
};
|
||||
|
||||
const parsedResult = { output: 'Final answer', status: 'success' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(agentFinish);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith(JSON.stringify(agentFinish.returnValues));
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should handle array of agent actions without format_final_json_response', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const steps: AgentAction[] = [
|
||||
{ tool: 'search', toolInput: { query: 'test' }, log: '' },
|
||||
{ tool: 'calculator', toolInput: { expression: '1+1' }, log: '' },
|
||||
];
|
||||
|
||||
await expect(parser(steps)).rejects.toThrow('Failed to parse agent steps');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { formatResponse } from '../methods/responseFormatter';
|
||||
|
||||
describe('responseFormatter', () => {
|
||||
describe('formatResponse', () => {
|
||||
it('should format string responses', () => {
|
||||
const result = formatResponse('Test response', true);
|
||||
expect(result).toEqual({
|
||||
text: 'Test response',
|
||||
});
|
||||
});
|
||||
|
||||
it('should trim string responses', () => {
|
||||
const result = formatResponse(' Test response with whitespace ', true);
|
||||
expect(result).toEqual({
|
||||
text: 'Test response with whitespace',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array responses', () => {
|
||||
const testArray = [{ item: 1 }, { item: 2 }];
|
||||
const result = formatResponse(testArray, true);
|
||||
expect(result).toEqual({ data: testArray });
|
||||
});
|
||||
|
||||
it('should handle object responses when unwrapping is enabled', () => {
|
||||
const testObject = { key: 'value', nested: { key: 'value' } };
|
||||
const result = formatResponse(testObject, true);
|
||||
expect(result).toEqual(testObject);
|
||||
});
|
||||
|
||||
it('should stringify object responses when unwrapping is disabled', () => {
|
||||
const testObject = { key: 'value', nested: { key: 'value' } };
|
||||
const result = formatResponse(testObject, false);
|
||||
expect(result).toEqual({
|
||||
text: JSON.stringify(testObject),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle primitive non-string responses', () => {
|
||||
const testNumber = 42;
|
||||
const result = formatResponse(testNumber, true);
|
||||
expect(result).toEqual({
|
||||
response: {
|
||||
text: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex object structures when unwrapping is enabled', () => {
|
||||
const complexObject = {
|
||||
person: {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
address: {
|
||||
street: '123 Main St',
|
||||
city: 'Anytown',
|
||||
},
|
||||
},
|
||||
items: [1, 2, 3],
|
||||
active: true,
|
||||
};
|
||||
|
||||
const result = formatResponse(complexObject, true);
|
||||
expect(result).toEqual(complexObject);
|
||||
});
|
||||
|
||||
it('should stringify complex object structures when unwrapping is disabled', () => {
|
||||
const complexObject = {
|
||||
person: {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
address: {
|
||||
street: '123 Main St',
|
||||
city: 'Anytown',
|
||||
},
|
||||
},
|
||||
items: [1, 2, 3],
|
||||
active: true,
|
||||
};
|
||||
|
||||
const result = formatResponse(complexObject, false);
|
||||
expect(result).toEqual({
|
||||
text: JSON.stringify(complexObject),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user