mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-29 19:10:43 +08:00
feat: Add example node in ai-utilities, improve response handling (#25388)
This commit is contained in:
@@ -3,6 +3,7 @@ node_modules
|
||||
.tmp
|
||||
tmp
|
||||
dist
|
||||
dist_examples
|
||||
npm-debug.log*
|
||||
yarn.lock
|
||||
.vscode/launch.json
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+126
@@ -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);
|
||||
}
|
||||
}
|
||||
+87
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"src/*": ["./src/*"]
|
||||
"src/*": ["./src/*"],
|
||||
"src": ["./src"]
|
||||
},
|
||||
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo",
|
||||
"emitDecoratorMetadata": true,
|
||||
|
||||
Reference in New Issue
Block a user