feat: Add example node in ai-utilities, improve response handling (#25388)

This commit is contained in:
yehorkardash
2026-02-10 13:52:14 +00:00
committed by GitHub
parent 9729c2a5da
commit 366d21d799
23 changed files with 705 additions and 223 deletions
+1
View File
@@ -3,6 +3,7 @@ node_modules
.tmp
tmp
dist
dist_examples
npm-debug.log*
yarn.lock
.vscode/launch.json
+21 -7
View File
@@ -19,7 +19,21 @@ pnpm test
pnpm dev
```
# Model SDK
## Running examples
```bash
pnpm build:examples
```
Update your env file with:
```bash
N8N_CUSTOM_EXTENSIONS="<PATH_TO_N8N>/packages/@n8n/ai-utilities/dist_examples/examples/nodes"
```
Start n8n and add "OpenAI Simple" or "OpenAI Custom" to workflows
# Chat model SDK
## Core Pattern
@@ -145,7 +159,7 @@ class ImaginaryLlmChatModel extends BaseChatModel {
async generate(messages: Message[], config?: ChatModelConfig): Promise<GenerateResult> {
// Convert n8n messages to provider format
const providerMessages = messages.map(m => ({
speaker: m.role === 'human' ? 'user' : m.role === 'ai' ? 'bot' : m.role,
speaker: m.role === 'user' ? 'user' : m.role === 'assistant' ? 'bot' : m.role,
text: m.content.find(c => c.type === 'text')?.text ?? '',
}));
@@ -310,8 +324,8 @@ class MyCustomChatMemory extends BaseChatMemory {
async saveTurn(input: string, output: string): Promise<void> {
await this.chatHistory.addMessages([
{ role: 'human', content: [{ type: 'text', text: input }] },
{ role: 'ai', content: [{ type: 'text', text: output }] },
{ role: 'user', content: [{ type: 'text', text: input }] },
{ role: 'assistant', content: [{ type: 'text', text: output }] },
]);
}
@@ -367,7 +381,7 @@ class ImaginaryDbChatHistory extends BaseChatHistory {
// Convert from provider format to n8n Message format
return data.messages.map((m: any) => ({
role: m.speaker === 'user' ? 'human' : m.speaker === 'bot' ? 'ai' : m.speaker,
role: m.speaker === 'user' ? 'user' : m.speaker === 'bot' ? 'assistant' : m.speaker,
content: [{ type: 'text', text: m.text }],
}));
}
@@ -379,7 +393,7 @@ class ImaginaryDbChatHistory extends BaseChatHistory {
url: `${this.baseUrl}/sessions/${this.sessionId}/messages`,
headers: { Authorization: `Bearer ${this.apiKey}` },
body: {
speaker: message.role === 'human' ? 'user' : message.role === 'ai' ? 'bot' : message.role,
speaker: message.role === 'user' ? 'user' : message.role === 'assistant' ? 'bot' : message.role,
text,
},
json: true,
@@ -405,7 +419,7 @@ export class MemoryImaginaryDb implements INodeType {
version: 1,
description: 'Use ImaginaryDB for chat memory storage',
defaults: { name: 'ImaginaryDB Memory' },
codex: { categories: ['AI'], subcategories: { AI: ['Memory'] } },
codex: { categories: ['assistant'], subcategories: { AI: ['Memory'] } },
inputs: [],
outputs: [NodeConnectionTypes.AiMemory],
outputNames: ['Memory'],
@@ -1,4 +1,5 @@
import type { JSONSchema7 } from 'json-schema';
import type { IHttpRequestMethods } from 'n8n-workflow';
import {
BaseChatModel,
@@ -10,8 +11,10 @@ import {
type Tool,
type ToolCall,
type TokenUsage,
} from '../../src';
import { parseSSEStream } from '../../src/utils/sse';
type ProviderTool,
type MessageContent,
} from 'src';
import { parseSSEStream } from 'src';
export type OpenAITool =
| {
@@ -110,68 +113,30 @@ export interface OpenAIErrorResponse {
}
async function openAIFetch(
fn: RequestConfig['httpRequest'],
url: string,
apiKey: string,
body: OpenAIResponsesRequest,
): Promise<OpenAIResponsesResponse> {
const cleanedBody = Object.fromEntries(
Object.entries(body).filter(([_, value]) => value !== undefined),
);
const response = await fetch(url, {
method: 'POST',
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'application/json',
// eslint-disable-next-line @typescript-eslint/naming-convention
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(cleanedBody),
const response = await fn('POST', url, cleanedBody, {
'Content-Type': 'application/json',
});
if (!response.ok) {
const errorData = (await response.json().catch(() => null)) as OpenAIErrorResponse | null;
const errorMessage = errorData?.error?.message ?? (await response.text());
throw new Error(
`OpenAI API request failed: ${response.status} ${response.statusText}\n${errorMessage}`,
);
}
return (await response.json()) as OpenAIResponsesResponse;
return response.body as OpenAIResponsesResponse;
}
async function openAIFetchStream(
fn: RequestConfig['openStream'],
url: string,
apiKey: string,
body: OpenAIResponsesRequest,
): Promise<ReadableStream<Uint8Array>> {
const cleanedBody = Object.fromEntries(
Object.entries(body).filter(([_, value]) => value !== undefined),
);
const response = await fetch(url, {
method: 'POST',
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'application/json',
// eslint-disable-next-line @typescript-eslint/naming-convention
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(cleanedBody),
const response = await fn('POST', url, cleanedBody, {
'Content-Type': 'application/json',
});
if (!response.ok) {
const errorData = (await response.json().catch(() => null)) as OpenAIErrorResponse | null;
const errorMessage = errorData?.error?.message ?? (await response.text());
throw new Error(
`OpenAI API request failed: ${response.status} ${response.statusText}\n${errorMessage}`,
);
}
if (!response.body) {
throw new Error('Response body is null');
}
return response.body;
}
@@ -225,7 +190,7 @@ function genericMessagesToResponsesInput(messages: Message[]): {
}
}
if (msg.role === 'human') {
if (msg.role === 'user') {
for (const contentPart of msg.content) {
if (contentPart.type === 'text') {
inputItems.push({
@@ -237,7 +202,7 @@ function genericMessagesToResponsesInput(messages: Message[]): {
continue;
}
if (msg.role === 'ai') {
if (msg.role === 'assistant') {
for (const contentPart of msg.content) {
if (contentPart.type === 'text') {
inputItems.push({
@@ -387,26 +352,47 @@ function parseTokenUsage(
export interface OpenAIChatModelConfig extends ChatModelConfig {
apiKey?: string;
baseURL?: string;
providerTools?: ProviderTool[];
}
export interface RequestConfig {
httpRequest: (
method: IHttpRequestMethods,
url: string,
body?: object,
headers?: Record<string, string>,
) => Promise<{ body: unknown }>;
openStream: (
method: IHttpRequestMethods,
url: string,
body?: object,
headers?: Record<string, string>,
) => Promise<{ body: ReadableStream<Uint8Array> }>;
}
export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
private apiKey: string;
private baseURL: string;
constructor(modelId: string = 'gpt-4o', config?: OpenAIChatModelConfig) {
constructor(
modelId: string = 'gpt-4o',
private requests: RequestConfig,
config?: OpenAIChatModelConfig,
) {
super('openai', modelId, config);
const apiKey = config?.apiKey ?? process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OpenAI API key is required. Set OPENAI_API_KEY or pass apiKey in config.');
}
this.apiKey = apiKey;
this.baseURL = config?.baseURL ?? 'https://api.openai.com/v1';
}
private getTools(config?: OpenAIChatModelConfig) {
const ownTools = this.tools;
const providerTools = config?.providerTools ?? [];
return [...ownTools, ...providerTools].map(genericToolToResponsesTool);
}
async generate(messages: Message[], config?: OpenAIChatModelConfig): Promise<GenerateResult> {
const merged = this.mergeConfig(config);
const { instructions, input } = genericMessagesToResponsesInput(messages);
const tools = this.tools.length ? this.tools.map(genericToolToResponsesTool) : undefined;
const tools = this.getTools(config);
const requestBody: OpenAIResponsesRequest = {
model: this.modelId,
input,
@@ -420,7 +406,11 @@ export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
stream: false,
};
const response = await openAIFetch(`${this.baseURL}/responses`, this.apiKey, requestBody);
const response = await openAIFetch(
this.requests.httpRequest,
`${this.baseURL}/responses`,
requestBody,
);
const { text, toolCalls } = parseResponsesOutput(response.output as unknown[]);
@@ -448,18 +438,29 @@ export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
}
}
const content: MessageContent[] = [];
if (toolCalls.length) {
for (const toolCall of toolCalls) {
content.push({
type: 'tool-call',
toolCallId: toolCall.id,
toolName: toolCall.name,
input: JSON.stringify(toolCall.arguments),
});
}
}
content.push({ type: 'text', text });
const message: Message = {
role: 'ai',
content: [{ type: 'text', text }],
role: 'assistant',
content,
id: response.id,
};
return {
id: response.id,
text,
finishReason: response.status === 'completed' ? 'stop' : 'other',
usage,
toolCalls,
message,
rawResponse: response,
providerMetadata: responseMetadata,
@@ -470,7 +471,7 @@ export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
const merged = this.mergeConfig(config) as OpenAIChatModelConfig;
const { instructions, input } = genericMessagesToResponsesInput(messages);
const tools = this.tools.length ? this.tools.map(genericToolToResponsesTool) : undefined;
const tools = this.getTools(config);
const requestBody: OpenAIResponsesRequest = {
model: this.modelId,
@@ -486,8 +487,8 @@ export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
};
const streamBody = await openAIFetchStream(
this.requests.openStream,
`${this.baseURL}/responses`,
this.apiKey,
requestBody,
);
@@ -499,7 +500,7 @@ export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
if (type === 'response.output_text.delta') {
const delta = event.delta;
if (delta) {
yield { type: 'text-delta', textDelta: delta };
yield { type: 'text-delta', delta };
}
}
@@ -519,7 +520,7 @@ export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
.filter(Boolean)
.join('');
if (reasoningText) {
yield { type: 'text-delta', textDelta: reasoningText };
yield { type: 'reasoning-delta', delta: reasoningText };
}
}
}
@@ -527,7 +528,7 @@ export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
if (type === 'response.reasoning_summary_text.delta') {
const delta = event.delta;
if (delta) {
yield { type: 'text-delta', textDelta: delta };
yield { type: 'reasoning-delta', delta };
}
}
@@ -547,11 +548,9 @@ export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
if (buf) {
yield {
type: 'tool-call-delta',
toolCallDelta: {
id: (item.call_id as string) ?? (item.id as string),
name: buf.name,
argumentsDelta: buf.arguments,
},
id: (item.call_id as string) ?? (item.id as string),
name: buf.name,
argumentsDelta: buf.arguments,
};
}
}
@@ -0,0 +1,126 @@
import {
NodeConnectionTypes,
type IDataObject,
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type SupplyData,
} from 'n8n-workflow';
import type Stream from 'node:stream';
import { Readable } from 'node:stream';
import { supplyModel } from 'src/suppliers/supplyModel';
import type { ProviderTool } from 'src/types/tool';
import { OpenAIChatModel } from '../../models/openai';
import { formatBuiltInTools } from '../common';
import { openAiProperties } from '../properties';
export type ModelOptions = {
temperature?: number;
};
export class LmChatOpenAiCustom implements INodeType {
description: INodeTypeDescription = {
displayName: 'OpenAI Custom',
name: 'lmChatOpenAiCustom',
icon: 'fa:robot',
group: ['transform'],
version: [1],
description: 'For advanced usage with an AI chain',
defaults: {
name: 'Custom OpenAI',
},
codex: {
categories: ['assistant'],
subcategories: {
AI: ['Language Models', 'Root Nodes'],
// eslint-disable-next-line @typescript-eslint/naming-convention
'Language Models': ['Chat Models (Recommended)'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatopenai/',
},
],
},
},
inputs: [],
outputs: [NodeConnectionTypes.AiLanguageModel],
outputNames: ['Model'],
credentials: [
{
name: 'openAiApi',
required: true,
},
],
requestDefaults: {
ignoreHttpStatusErrors: true,
baseURL:
'={{ $credentials?.url?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}',
},
properties: openAiProperties,
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('openAiApi');
const modelName = this.getNodeParameter('model', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as ModelOptions;
const providerTools: ProviderTool[] = [];
const builtInToolsParams = formatBuiltInTools(
this.getNodeParameter('builtInTools', itemIndex, {}) as IDataObject,
);
if (builtInToolsParams.length) {
providerTools.push(...builtInToolsParams);
}
const model = new OpenAIChatModel(
modelName,
{
httpRequest: async (method, url, body, headers) => {
const response = await this.helpers.httpRequestWithAuthentication.call(
this,
'openAiApi',
{
url,
method,
body,
headers,
},
);
return {
body: response,
};
},
openStream: async (method, url, body, headers) => {
const response = (await this.helpers.httpRequestWithAuthentication.call(
this,
'openAiApi',
{
method,
url,
body,
headers,
encoding: 'stream',
},
)) as Stream.Readable;
return {
body: Readable.toWeb(response) as ReadableStream<Uint8Array<ArrayBufferLike>>,
};
},
},
{
baseURL: credentials.url as string,
apiKey: credentials.apiKey as string,
providerTools,
temperature: options.temperature,
},
);
return supplyModel(this, model);
}
}
@@ -0,0 +1,87 @@
import {
NodeConnectionTypes,
type IDataObject,
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type SupplyData,
} from 'n8n-workflow';
import { supplyModel } from 'src/suppliers/supplyModel';
import type { ProviderTool } from 'src/types/tool';
import { formatBuiltInTools } from '../common';
import { openAiProperties } from '../properties';
export type ModelOptions = {
temperature?: number;
};
export class LmChatOpenAiSimple implements INodeType {
description: INodeTypeDescription = {
displayName: 'OpenAI Simple',
name: 'lmChatOpenAiSimple',
icon: 'fa:robot',
group: ['transform'],
version: [1],
description: 'For advanced usage with an AI chain',
defaults: {
name: 'OpenAI Simple',
},
codex: {
categories: ['assistant'],
subcategories: {
AI: ['Language Models', 'Root Nodes'],
// eslint-disable-next-line @typescript-eslint/naming-convention
'Language Models': ['Chat Models (Recommended)'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatopenai/',
},
],
},
},
inputs: [],
outputs: [NodeConnectionTypes.AiLanguageModel],
outputNames: ['Model'],
credentials: [
{
name: 'openAiApi',
required: true,
},
],
requestDefaults: {
ignoreHttpStatusErrors: true,
baseURL:
'={{ $credentials?.url?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}',
},
properties: openAiProperties,
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('openAiApi');
const modelName = this.getNodeParameter('model', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as ModelOptions;
const providerTools: ProviderTool[] = [];
const builtInToolsParams = formatBuiltInTools(
this.getNodeParameter('builtInTools', itemIndex, {}) as IDataObject,
);
if (builtInToolsParams.length) {
providerTools.push(...builtInToolsParams);
}
return supplyModel(this, {
type: 'openai',
baseUrl: credentials.url as string,
apiKey: credentials.apiKey as string,
model: modelName,
temperature: options.temperature,
providerTools,
});
}
}
@@ -0,0 +1,44 @@
import type { IDataObject } from 'n8n-workflow';
import type { ProviderTool } from 'src/types/tool';
const toArray = (str: string) =>
str
.split(',')
.map((e) => e.trim())
.filter(Boolean);
export const formatBuiltInTools = (builtInTools: IDataObject): ProviderTool[] => {
const tools: ProviderTool[] = [];
if (builtInTools) {
const webSearchOptions = builtInTools.webSearch as IDataObject;
if (webSearchOptions) {
let allowedDomains: string[] | undefined;
const allowedDomainsRaw = webSearchOptions.allowedDomains as string;
if (allowedDomainsRaw) {
allowedDomains = toArray(allowedDomainsRaw);
}
let userLocation: IDataObject | undefined;
if (webSearchOptions.country || webSearchOptions.city || webSearchOptions.region) {
userLocation = {
type: 'approximate',
country: webSearchOptions.country as string,
city: webSearchOptions.city as string,
region: webSearchOptions.region as string,
};
}
tools.push({
type: 'provider',
name: 'web_search',
args: {
search_context_size: webSearchOptions.searchContextSize as string,
user_location: userLocation,
...(allowedDomains && { filters: { allowed_domains: allowedDomains } }),
},
});
}
}
return tools;
};
@@ -0,0 +1,130 @@
import type { INodeProperties } from 'n8n-workflow';
export const openAiProperties: INodeProperties[] = [
{
displayName: 'Model',
name: 'model',
type: 'options',
description:
'The model which will generate the completion. <a href="https://beta.openai.com/docs/models/overview">Learn more</a>.',
typeOptions: {
loadOptions: {
routing: {
request: {
method: 'GET',
url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || $credentials?.url?.split("/").slice(-1).pop() || "v1" }}/models',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'data',
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.id}}',
value: '={{$responseItem.id}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
},
routing: {
send: {
type: 'body',
property: 'model',
},
},
default: 'gpt-5-mini',
},
{
displayName: 'Built-in Tools',
name: 'builtInTools',
placeholder: 'Add Built-in Tool',
type: 'collection',
default: {},
options: [
{
displayName: 'Web Search',
name: 'webSearch',
type: 'collection',
default: { searchContextSize: 'medium' },
options: [
{
displayName: 'Search Context Size',
name: 'searchContextSize',
type: 'options',
default: 'medium',
description:
'High level guidance for the amount of context window space to use for the search',
options: [
{ name: 'Low', value: 'low' },
{ name: 'Medium', value: 'medium' },
{ name: 'High', value: 'high' },
],
},
{
displayName: 'Web Search Allowed Domains',
name: 'allowedDomains',
type: 'string',
default: '',
description:
'Comma-separated list of domains to search. Only domains in this list will be searched.',
placeholder: 'e.g. google.com, wikipedia.org',
},
{
displayName: 'Country',
name: 'country',
type: 'string',
default: '',
placeholder: 'e.g. US, GB',
},
{
displayName: 'City',
name: 'city',
type: 'string',
default: '',
placeholder: 'e.g. New York, London',
},
{
displayName: 'Region',
name: 'region',
type: 'string',
default: '',
placeholder: 'e.g. New York, London',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
description: 'Additional options to add',
type: 'collection',
default: {},
options: [
{
displayName: 'Sampling Temperature',
name: 'temperature',
default: 0.7,
typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 },
description:
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
type: 'number',
},
],
},
];
+46 -3
View File
@@ -1,9 +1,15 @@
/**
* Can be used to test chat models outside of n8n nodes.
* Example: npx tsx run.ts --generate "What is the weather in Tokyo?"
*/
import dotenv from 'dotenv';
import { createAgent, HumanMessage, tool } from 'langchain';
import z from 'zod';
import { LangchainAdapter } from 'src/adapters/langchain-chat-model';
import { OpenAIChatModel } from './models/openai';
import { LangchainAdapter } from '../src/adapters/langchain-chat-model';
dotenv.config();
@@ -113,8 +119,43 @@ async function main() {
// Create model based on selection
let chatModel;
if (model === 'openai') {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OPENAI_API_KEY is not set');
}
const openaiChatModel = new OpenAIChatModel('gpt-4o', {
apiKey: process.env.OPENAI_API_KEY,
httpRequest: async (method, url, body, headers) => {
const response = await fetch(url, {
method,
body: JSON.stringify(body),
headers: {
...headers,
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText}`);
}
return {
body: await response.json(),
};
},
openStream: async (method, url, body, headers) => {
const response = await fetch(url, {
method,
body: JSON.stringify(body),
headers: {
...headers,
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText}`);
}
return {
body: response.body as ReadableStream<Uint8Array<ArrayBufferLike>>,
};
},
});
chatModel = new LangchainAdapter(openaiChatModel);
} else {
@@ -131,6 +172,7 @@ async function main() {
process.exit(1);
}
if (generate) {
console.log('=====Generate=====');
console.log(
await agent.invoke({
messages: [new HumanMessage(prompt)],
@@ -139,9 +181,10 @@ async function main() {
}
if (stream) {
console.log('=====Stream=====');
for await (const chunk of await agent.stream(
{ messages: [{ role: 'user', content: prompt }] },
{ streamMode: 'updates' },
{ streamMode: 'messages' },
)) {
const [step, content] = Object.entries(chunk)[0];
console.log(`step: ${step}`);
+1
View File
@@ -11,6 +11,7 @@
"dev": "pnpm run watch",
"typecheck": "tsc --noEmit",
"copy-tokenizer-json": "node scripts/copy-tokenizer-json.js .",
"build:examples": "tsc --build tsconfig.examples.json && tsc-alias -p tsconfig.examples.json && pnpm copy-tokenizer-json ./dist_examples/src/",
"build": "tsc --build tsconfig.build.json && tsc-alias -p tsconfig.build.json && pnpm copy-tokenizer-json",
"format": "biome format --write .",
"format:check": "biome ci .",
@@ -3,8 +3,9 @@ const fs = require('fs');
const path = require('path');
function copyTokenizerJsonFiles(baseDir) {
const targetBaseDir = process.argv[3] || 'dist';
// Make sure the target directory exists
const targetDir = path.resolve(baseDir, 'dist', 'utils', 'tokenizer');
const targetDir = path.resolve(baseDir, targetBaseDir, 'utils', 'tokenizer');
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
@@ -12,7 +13,7 @@ function copyTokenizerJsonFiles(baseDir) {
const files = glob.sync('src/utils/tokenizer/*.json', { cwd: baseDir });
for (const file of files) {
const sourcePath = path.resolve(baseDir, file);
const targetPath = path.resolve(baseDir, 'dist', file.replace('src/', ''));
const targetPath = path.resolve(baseDir, targetBaseDir, file.replace('src/', ''));
fs.copyFileSync(sourcePath, targetPath);
console.log(`Copied: ${file} -> ${targetPath.replace(baseDir, '')}`);
}
@@ -46,7 +46,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
describe('human messages', () => {
it('should round-trip a simple text message', () => {
const original: Message = {
role: 'human',
role: 'user',
content: [{ type: 'text', text: 'Hello!' }],
};
@@ -55,7 +55,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
it('should round-trip multiple text blocks', () => {
const original: Message = {
role: 'human',
role: 'user',
content: [
{ type: 'text', text: 'First part' },
{ type: 'text', text: 'Second part' },
@@ -67,7 +67,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
it('should preserve id and name', () => {
const original: Message = {
role: 'human',
role: 'user',
content: [{ type: 'text', text: 'Hello' }],
id: 'msg-456',
name: 'user-1',
@@ -78,7 +78,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
it('should round-trip a file content block', () => {
const original: Message = {
role: 'human',
role: 'user',
content: [
{
type: 'file',
@@ -93,7 +93,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
it('should round-trip a file with url in providerMetadata', () => {
const original: Message = {
role: 'human',
role: 'user',
content: [
{
type: 'file',
@@ -114,7 +114,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
describe('ai messages', () => {
it('should round-trip a simple text response', () => {
const original: Message = {
role: 'ai',
role: 'assistant',
content: [{ type: 'text', text: 'Hello! How can I help you?' }],
};
@@ -123,7 +123,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
it('should round-trip a reasoning block', () => {
const original: Message = {
role: 'ai',
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Let me think about this...' },
{ type: 'text', text: 'The answer is 42.' },
@@ -135,7 +135,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
it('should round-trip an AI message with tool calls', () => {
const original: Message = {
role: 'ai',
role: 'assistant',
content: [
{ type: 'text', text: 'Let me look that up.' },
{
@@ -150,7 +150,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
const result = roundTrip(original);
// Content order may differ: fromLcMessage appends tool calls after content
expect(result.role).toBe('ai');
expect(result.role).toBe('assistant');
expect(result.content).toHaveLength(2);
const textBlock = result.content.find((c) => c.type === 'text');
@@ -167,7 +167,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
it('should round-trip multiple tool calls', () => {
const original: Message = {
role: 'ai',
role: 'assistant',
content: [
{
type: 'tool-call',
@@ -186,7 +186,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
const result = roundTrip(original);
expect(result.role).toBe('ai');
expect(result.role).toBe('assistant');
const toolCalls = result.content.filter((c) => c.type === 'tool-call');
expect(toolCalls).toHaveLength(2);
expect(toolCalls[0]).toMatchObject({ toolCallId: 'call-1', toolName: 'search' });
@@ -195,7 +195,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
it('should preserve id and name', () => {
const original: Message = {
role: 'ai',
role: 'assistant',
content: [{ type: 'text', text: 'Response' }],
id: 'msg-789',
name: 'assistant',
@@ -206,7 +206,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
it('should round-trip a citation block', () => {
const original: Message = {
role: 'ai',
role: 'assistant',
content: [
{
type: 'citation',
@@ -226,7 +226,7 @@ describe('message round-trip: n8n -> LC -> n8n', () => {
it('should round-trip a provider (non-standard) block', () => {
const original: Message = {
role: 'ai',
role: 'assistant',
content: [
{ type: 'provider', value: { customField: 'customValue', nested: { a: 1 } } },
{ type: 'text', text: 'Normal text' },
@@ -5,7 +5,7 @@ import type { ChatHistory } from '../../types/memory';
import type { Message } from '../../types/message';
describe('LangchainHistoryAdapter', () => {
const createMessage = (role: 'human' | 'ai' | 'system', text: string): Message => ({
const createMessage = (role: 'user' | 'assistant' | 'system', text: string): Message => ({
role,
content: [{ type: 'text', text }],
});
@@ -28,7 +28,7 @@ describe('LangchainHistoryAdapter', () => {
});
it('should convert human messages to HumanMessage', async () => {
const history = createMockHistory([createMessage('human', 'Hello')]);
const history = createMockHistory([createMessage('user', 'Hello')]);
const adapter = new LangchainHistoryAdapter(history);
const result = await adapter.getMessages();
@@ -39,7 +39,7 @@ describe('LangchainHistoryAdapter', () => {
});
it('should convert AI messages to AIMessage', async () => {
const history = createMockHistory([createMessage('ai', 'Hi there!')]);
const history = createMockHistory([createMessage('assistant', 'Hi there!')]);
const adapter = new LangchainHistoryAdapter(history);
const result = await adapter.getMessages();
@@ -63,8 +63,8 @@ describe('LangchainHistoryAdapter', () => {
it('should convert multiple messages preserving order', async () => {
const messages = [
createMessage('system', 'System prompt'),
createMessage('human', 'Hello'),
createMessage('ai', 'Hi!'),
createMessage('user', 'Hello'),
createMessage('assistant', 'Hi!'),
];
const history = createMockHistory(messages);
const adapter = new LangchainHistoryAdapter(history);
@@ -87,7 +87,7 @@ describe('LangchainHistoryAdapter', () => {
expect(history.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'human',
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'text', text: 'Hello' }),
]),
@@ -103,7 +103,7 @@ describe('LangchainHistoryAdapter', () => {
expect(history.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'ai',
role: 'assistant',
content: expect.arrayContaining([
expect.objectContaining({ type: 'text', text: 'Hi there!' }),
]),
@@ -136,8 +136,8 @@ describe('LangchainHistoryAdapter', () => {
await adapter.addMessages([new HumanMessage('Hello'), new AIMessage('Hi!')]);
expect(history.addMessages).toHaveBeenCalledWith([
expect.objectContaining({ role: 'human' }),
expect.objectContaining({ role: 'ai' }),
expect.objectContaining({ role: 'user' }),
expect.objectContaining({ role: 'assistant' }),
]);
});
@@ -5,7 +5,7 @@ import type { ChatHistory, ChatMemory } from '../../types/memory';
import type { Message } from '../../types/message';
describe('LangchainMemoryAdapter', () => {
const createMessage = (role: 'human' | 'ai' | 'system', text: string): Message => ({
const createMessage = (role: 'user' | 'assistant' | 'system', text: string): Message => ({
role,
content: [{ type: 'text', text }],
});
@@ -37,7 +37,7 @@ describe('LangchainMemoryAdapter', () => {
});
it('should convert human messages to HumanMessage', async () => {
const memory = createMockMemory([createMessage('human', 'Hello')]);
const memory = createMockMemory([createMessage('user', 'Hello')]);
const adapter = new LangchainMemoryAdapter(memory);
const result = await adapter.loadMemoryVariables({});
@@ -48,7 +48,7 @@ describe('LangchainMemoryAdapter', () => {
});
it('should convert AI messages to AIMessage', async () => {
const memory = createMockMemory([createMessage('ai', 'Hi there!')]);
const memory = createMockMemory([createMessage('assistant', 'Hi there!')]);
const adapter = new LangchainMemoryAdapter(memory);
const result = await adapter.loadMemoryVariables({});
@@ -74,8 +74,8 @@ describe('LangchainMemoryAdapter', () => {
it('should convert multiple messages in order', async () => {
const messages = [
createMessage('system', 'System prompt'),
createMessage('human', 'Hello'),
createMessage('ai', 'Hi!'),
createMessage('user', 'Hello'),
createMessage('assistant', 'Hi!'),
];
const memory = createMockMemory(messages);
const adapter = new LangchainMemoryAdapter(memory);
@@ -3,7 +3,7 @@ import type { ChatHistory } from '../../types/memory';
import type { Message } from '../../types/message';
describe('WindowedChatMemory', () => {
const createMessage = (role: 'human' | 'ai', text: string): Message => ({
const createMessage = (role: 'user' | 'assistant', text: string): Message => ({
role,
content: [{ type: 'text', text }],
});
@@ -38,7 +38,7 @@ describe('WindowedChatMemory', () => {
});
it('should return all messages when under window size', async () => {
const msgs = [createMessage('human', 'Hello'), createMessage('ai', 'Hi!')];
const msgs = [createMessage('user', 'Hello'), createMessage('assistant', 'Hi!')];
const history = createMockHistory(msgs);
const memory = new WindowedChatMemory(history, { windowSize: 5 });
@@ -49,12 +49,12 @@ describe('WindowedChatMemory', () => {
it('should return only last N pairs when over window size', async () => {
const msgs = [
createMessage('human', 'Message 1'),
createMessage('ai', 'Response 1'),
createMessage('human', 'Message 2'),
createMessage('ai', 'Response 2'),
createMessage('human', 'Message 3'),
createMessage('ai', 'Response 3'),
createMessage('user', 'Message 1'),
createMessage('assistant', 'Response 1'),
createMessage('user', 'Message 2'),
createMessage('assistant', 'Response 2'),
createMessage('user', 'Message 3'),
createMessage('assistant', 'Response 3'),
];
const history = createMockHistory(msgs);
const memory = new WindowedChatMemory(history, { windowSize: 2 });
@@ -76,7 +76,7 @@ describe('WindowedChatMemory', () => {
// Add 25 messages (more than default 10 pairs = 20 messages)
const msgs: Message[] = [];
for (let i = 1; i <= 25; i++) {
msgs.push(createMessage(i % 2 === 1 ? 'human' : 'ai', `Message ${i}`));
msgs.push(createMessage(i % 2 === 1 ? 'user' : 'assistant', `Message ${i}`));
}
await history.addMessages(msgs);
@@ -95,8 +95,8 @@ describe('WindowedChatMemory', () => {
await memory.saveTurn('Hello!', 'Hi there!');
expect(history.addMessages).toHaveBeenCalledWith([
{ role: 'human', content: [{ type: 'text', text: 'Hello!' }] },
{ role: 'ai', content: [{ type: 'text', text: 'Hi there!' }] },
{ role: 'user', content: [{ type: 'text', text: 'Hello!' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'Hi there!' }] },
]);
});
});
@@ -9,7 +9,7 @@ import { ChatGenerationChunk } from '@langchain/core/outputs';
import type { Runnable } from '@langchain/core/runnables';
import type { ISupplyDataFunctions } from 'n8n-workflow';
import { fromLcMessage } from '../converters/message';
import { fromLcMessage, toLcMessage } from '../converters/message';
import { fromLcTool } from '../converters/tool';
import type { ChatModel, ChatModelConfig } from '../types/chat-model';
import { makeN8nLlmFailedAttemptHandler } from '../utils/failed-attempt-handler/n8nLlmFailedAttemptHandler';
@@ -61,13 +61,7 @@ export class LangchainAdapter<
const transformedMessages = messages.map(fromLcMessage);
const result = await this.chatModel.generate(transformedMessages, options);
// Build content blocks for the message
const content: ContentBlock[] = [];
if (result.text) {
content.push({
type: 'text',
text: result.text,
});
}
const lcMessage = toLcMessage(result.message);
// Build usage metadata
const usage_metadata = result.usage
@@ -88,33 +82,21 @@ export class LangchainAdapter<
}
: undefined;
// Convert tool calls to LangChain format
const tool_calls =
result.toolCalls?.map((tc) => ({
id: tc.id,
name: tc.name,
args: tc.arguments,
type: 'tool_call' as const,
})) ?? [];
if (AIMessage.isInstance(lcMessage)) {
lcMessage.usage_metadata = usage_metadata;
}
// Convert back to LangChain format
const aiMessage = new AIMessage({
content,
usage_metadata,
response_metadata: {
...result.providerMetadata,
model: this.chatModel.modelId,
provider: this.chatModel.provider,
},
id: result.id,
tool_calls,
});
lcMessage.response_metadata = {
...result.providerMetadata,
model: this.chatModel.modelId,
provider: this.chatModel.provider,
};
return {
generations: [
{
text: result.text,
message: aiMessage,
text: lcMessage.text,
message: lcMessage,
},
],
llmOutput: {
@@ -133,58 +115,39 @@ export class LangchainAdapter<
const stream = this.chatModel.stream(genericMessages, options);
for await (const chunk of stream) {
if (chunk.type === 'text-delta' && chunk.textDelta) {
let lcChunk: ChatGenerationChunk | undefined = undefined;
if (chunk.type === 'text-delta') {
const content: ContentBlock[] = [
{
type: 'text',
text: chunk.textDelta,
text: chunk.delta,
},
];
const chunkResult = new ChatGenerationChunk({
lcChunk = new ChatGenerationChunk({
message: new AIMessageChunk({
content,
usage_metadata: chunk.usage
? {
input_tokens: chunk.usage.promptTokens ?? 0,
output_tokens: chunk.usage.completionTokens ?? 0,
total_tokens: chunk.usage.totalTokens ?? 0,
}
: undefined,
}),
text: chunk.textDelta,
text: chunk.delta,
});
yield chunkResult;
await runManager?.handleLLMNewToken(
chunkResult.text ?? '',
{
prompt: 0,
completion: 0,
},
undefined,
undefined,
undefined,
{ chunk: chunkResult },
);
} else if (chunk.type === 'tool-call-delta' && chunk.toolCallDelta) {
} else if (chunk.type === 'tool-call-delta') {
const tool_call_chunks = [
{
type: 'tool_call_chunk' as const,
id: chunk.toolCallDelta.id,
name: chunk.toolCallDelta.name,
args: chunk.toolCallDelta.argumentsDelta,
id: chunk.id,
name: chunk.name,
args: chunk.argumentsDelta,
index: 0,
},
];
const chunkResult = new ChatGenerationChunk({
lcChunk = new ChatGenerationChunk({
message: new AIMessageChunk({
content: '',
tool_call_chunks,
}),
text: '',
});
yield chunkResult;
} else if (chunk.type === 'finish') {
const usage_metadata = chunk.usage
? {
@@ -194,7 +157,7 @@ export class LangchainAdapter<
}
: undefined;
const chunkResult = new ChatGenerationChunk({
lcChunk = new ChatGenerationChunk({
message: new AIMessageChunk({
content: '',
usage_metadata,
@@ -207,7 +170,51 @@ export class LangchainAdapter<
finish_reason: chunk.finishReason,
},
});
yield chunkResult;
} else if (chunk.type === 'error') {
lcChunk = new ChatGenerationChunk({
message: new AIMessageChunk({
content: '',
response_metadata: {
finish_reason: 'error',
error: chunk.error,
},
}),
text: '',
generationInfo: {
finish_reason: 'error',
error: chunk.error,
},
});
} else if (chunk.type === 'content') {
const lcMessage = toLcMessage({
role: 'assistant',
content: [chunk.content],
id: chunk.id,
});
const lcMessageChunk = new AIMessageChunk({
content: lcMessage.content,
id: lcMessage.id,
name: lcMessage.name,
});
lcChunk = new ChatGenerationChunk({
message: lcMessageChunk,
text: lcMessage.text,
});
}
if (lcChunk) {
yield lcChunk;
await runManager?.handleLLMNewToken(
lcChunk.text ?? '',
{
prompt: 0,
completion: 0,
},
undefined,
undefined,
undefined,
{ chunk: lcChunk },
);
}
}
}
@@ -40,14 +40,14 @@ function fromLcRole(role: LangchainMessages.MessageType): N8nMessages.MessageRol
switch (role) {
case 'system':
return 'system';
case 'human':
return 'human';
case 'ai':
return 'ai';
case 'user':
return 'user';
case 'assistant':
return 'assistant';
case 'tool':
return 'tool';
default:
return 'human';
return 'user';
}
}
function isTextBlock(
@@ -224,7 +224,7 @@ export function fromLcMessage(msg: LangchainMessages.BaseMessage): N8nMessages.M
content.push(...mappedToolsCalls);
}
return {
role: 'ai',
role: 'assistant',
content,
id: msg.id,
name: msg.name,
@@ -240,7 +240,7 @@ export function fromLcMessage(msg: LangchainMessages.BaseMessage): N8nMessages.M
}
if (LangchainMessages.HumanMessage.isInstance(msg)) {
return {
role: 'human',
role: 'user',
content: fromLcContent(msg.content),
id: msg.id,
name: msg.name,
@@ -257,7 +257,7 @@ export function fromLcMessage(msg: LangchainMessages.BaseMessage): N8nMessages.M
throw new Error(`Provided message is not a valid Langchain message: ${JSON.stringify(msg)}`);
}
function toLcContent(block: N8nMessages.MessageContent): LangchainMessages.ContentBlock | null {
export function toLcContent(block: N8nMessages.MessageContent): LangchainMessages.ContentBlock {
if (isN8nTextBlock(block)) {
return { type: 'text', text: block.text };
}
@@ -309,13 +309,11 @@ function toLcContent(block: N8nMessages.MessageContent): LangchainMessages.Conte
value: block.value,
} as LangchainMessages.ContentBlock.NonStandard;
}
return null;
throw new Error(`Failed to convert to Langchain content block: ${JSON.stringify(block)}`);
}
export function toLcMessage(message: Message): LangchainMessages.BaseMessage {
const lcContent = message.content
.map(toLcContent)
.filter((c): c is LangchainMessages.ContentBlock => c !== null);
const lcContent = message.content.map(toLcContent);
switch (message.role) {
case 'system':
@@ -324,13 +322,13 @@ export function toLcMessage(message: Message): LangchainMessages.BaseMessage {
id: message.id,
name: message.name,
});
case 'human':
case 'user':
return new LangchainMessages.HumanMessage({
content: lcContent,
id: message.id,
name: message.name,
});
case 'ai': {
case 'assistant': {
const toolCalls = message.content.filter(isN8nToolCallBlock).map((c) => ({
id: c.toolCallId,
name: c.toolName,
@@ -35,12 +35,12 @@ export class WindowedChatMemory extends BaseChatMemory {
async saveTurn(input: string, output: string): Promise<void> {
const humanMessage: Message = {
role: 'human',
role: 'user',
content: [{ type: 'text', text: input }],
};
const aiMessage: Message = {
role: 'ai',
role: 'assistant',
content: [{ type: 'text', text: output }],
};
@@ -61,6 +61,7 @@ function getOpenAiModel(ctx: ISupplyDataFunctions, model: OpenAiModel) {
stop: model.stop,
maxTokens: model.maxTokens,
maxCompletionTokens: model.maxCompletionTokens,
timeout: model.timeout,
callbacks: [new N8nLlmTracing(ctx)],
onFailedAttempt: makeN8nLlmFailedAttemptHandler(ctx, model.onFailedAttempt),
});
@@ -1,4 +1,4 @@
export type MessageRole = 'system' | 'human' | 'ai' | 'tool';
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
export type MessageContent =
| ContentText
+35 -20
View File
@@ -1,5 +1,4 @@
import type { Message } from './message';
import type { ToolCall } from './tool';
import type { ContentMetadata, Message, MessageContent } from './message';
export type FinishReason = 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other';
@@ -18,17 +17,12 @@ export type TokenUsage<T extends Record<string, unknown> = Record<string, unknow
export interface GenerateResult {
id?: string;
text: string;
finishReason?: FinishReason;
usage?: TokenUsage;
/**
* Tool calls made by the model
*/
toolCalls?: ToolCall[];
/**
* The generated message
*/
message?: Message;
message: Message;
/**
* Metadata about the response from the provider
*/
@@ -36,15 +30,36 @@ export interface GenerateResult {
rawResponse?: unknown;
}
export interface StreamChunk {
type: 'text-delta' | 'tool-call-delta' | 'finish' | 'error';
textDelta?: string;
toolCallDelta?: {
id?: string;
name?: string;
argumentsDelta?: string;
};
finishReason?: FinishReason;
usage?: TokenUsage;
error?: unknown;
}
export type StreamChunk = ContentMetadata &
(
| {
type: 'text-delta';
id?: string;
delta: string;
}
| {
type: 'reasoning-delta';
id?: string;
delta: string;
}
| {
type: 'tool-call-delta';
id?: string;
name?: string;
argumentsDelta?: string;
}
| {
type: 'finish';
finishReason: FinishReason;
usage?: TokenUsage;
}
| {
type: 'error';
error: unknown;
}
| {
type: 'content';
content: MessageContent;
id?: string;
}
);
@@ -7,5 +7,5 @@
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "src/examples/**"]
}
@@ -0,0 +1,14 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": ".",
"paths": {
"src/*": ["./src/*"],
"src": ["./src"]
},
"outDir": "./dist_examples",
"tsBuildInfoFile": "dist_examples/build.tsbuildinfo"
},
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
}
+2 -1
View File
@@ -6,7 +6,8 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"src/*": ["./src/*"]
"src/*": ["./src/*"],
"src": ["./src"]
},
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo",
"emitDecoratorMetadata": true,