diff --git a/packages/@n8n/ai-utilities/.gitignore b/packages/@n8n/ai-utilities/.gitignore index b5237a2cb75..82ec42ec7a9 100644 --- a/packages/@n8n/ai-utilities/.gitignore +++ b/packages/@n8n/ai-utilities/.gitignore @@ -3,6 +3,7 @@ node_modules .tmp tmp dist +dist_examples npm-debug.log* yarn.lock .vscode/launch.json diff --git a/packages/@n8n/ai-utilities/README.md b/packages/@n8n/ai-utilities/README.md index b4c648a935f..affc36b280e 100644 --- a/packages/@n8n/ai-utilities/README.md +++ b/packages/@n8n/ai-utilities/README.md @@ -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="/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 { // 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 { 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'], diff --git a/packages/@n8n/ai-utilities/examples/models/openai.ts b/packages/@n8n/ai-utilities/examples/models/openai.ts index 9c420c8ea3d..3b05d4faab2 100644 --- a/packages/@n8n/ai-utilities/examples/models/openai.ts +++ b/packages/@n8n/ai-utilities/examples/models/openai.ts @@ -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 { 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> { 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, + ) => Promise<{ body: unknown }>; + openStream: ( + method: IHttpRequestMethods, + url: string, + body?: object, + headers?: Record, + ) => Promise<{ body: ReadableStream }>; } export class OpenAIChatModel extends BaseChatModel { - 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 { 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 { 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 { } } + 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 { 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 { }; const streamBody = await openAIFetchStream( + this.requests.openStream, `${this.baseURL}/responses`, - this.apiKey, requestBody, ); @@ -499,7 +500,7 @@ export class OpenAIChatModel extends BaseChatModel { 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 { .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 { 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 { 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, }; } } diff --git a/packages/@n8n/ai-utilities/examples/nodes/LmChatOpenAiCustom/LmChatOpenAiCustom.node.ts b/packages/@n8n/ai-utilities/examples/nodes/LmChatOpenAiCustom/LmChatOpenAiCustom.node.ts new file mode 100644 index 00000000000..bd1639a2729 --- /dev/null +++ b/packages/@n8n/ai-utilities/examples/nodes/LmChatOpenAiCustom/LmChatOpenAiCustom.node.ts @@ -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 { + 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>, + }; + }, + }, + { + baseURL: credentials.url as string, + apiKey: credentials.apiKey as string, + providerTools, + temperature: options.temperature, + }, + ); + + return supplyModel(this, model); + } +} diff --git a/packages/@n8n/ai-utilities/examples/nodes/LmChatOpenAiSimple/LmChatOpenAiSimple.node.ts b/packages/@n8n/ai-utilities/examples/nodes/LmChatOpenAiSimple/LmChatOpenAiSimple.node.ts new file mode 100644 index 00000000000..ebf6e3f0645 --- /dev/null +++ b/packages/@n8n/ai-utilities/examples/nodes/LmChatOpenAiSimple/LmChatOpenAiSimple.node.ts @@ -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 { + 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, + }); + } +} diff --git a/packages/@n8n/ai-utilities/examples/nodes/common.ts b/packages/@n8n/ai-utilities/examples/nodes/common.ts new file mode 100644 index 00000000000..738c7b1d817 --- /dev/null +++ b/packages/@n8n/ai-utilities/examples/nodes/common.ts @@ -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; +}; diff --git a/packages/@n8n/ai-utilities/examples/nodes/properties.ts b/packages/@n8n/ai-utilities/examples/nodes/properties.ts new file mode 100644 index 00000000000..b3b30bb64ec --- /dev/null +++ b/packages/@n8n/ai-utilities/examples/nodes/properties.ts @@ -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. Learn more.', + 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', + }, + ], + }, +]; diff --git a/packages/@n8n/ai-utilities/examples/run.ts b/packages/@n8n/ai-utilities/examples/run.ts index 9abf25219de..79862c8cc5d 100644 --- a/packages/@n8n/ai-utilities/examples/run.ts +++ b/packages/@n8n/ai-utilities/examples/run.ts @@ -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>, + }; + }, }); 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}`); diff --git a/packages/@n8n/ai-utilities/package.json b/packages/@n8n/ai-utilities/package.json index 1789199b982..28d7fc9734e 100644 --- a/packages/@n8n/ai-utilities/package.json +++ b/packages/@n8n/ai-utilities/package.json @@ -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 .", diff --git a/packages/@n8n/ai-utilities/scripts/copy-tokenizer-json.js b/packages/@n8n/ai-utilities/scripts/copy-tokenizer-json.js index 26002ad9b18..563b8c076a5 100644 --- a/packages/@n8n/ai-utilities/scripts/copy-tokenizer-json.js +++ b/packages/@n8n/ai-utilities/scripts/copy-tokenizer-json.js @@ -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, '')}`); } diff --git a/packages/@n8n/ai-utilities/src/__tests__/converters/message-roundtrip.test.ts b/packages/@n8n/ai-utilities/src/__tests__/converters/message-roundtrip.test.ts index c65b84020f1..87ad9deb834 100644 --- a/packages/@n8n/ai-utilities/src/__tests__/converters/message-roundtrip.test.ts +++ b/packages/@n8n/ai-utilities/src/__tests__/converters/message-roundtrip.test.ts @@ -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' }, diff --git a/packages/@n8n/ai-utilities/src/__tests__/memory/langchain-history-adapter.test.ts b/packages/@n8n/ai-utilities/src/__tests__/memory/langchain-history-adapter.test.ts index e5b8e2322cb..80b642a024d 100644 --- a/packages/@n8n/ai-utilities/src/__tests__/memory/langchain-history-adapter.test.ts +++ b/packages/@n8n/ai-utilities/src/__tests__/memory/langchain-history-adapter.test.ts @@ -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' }), ]); }); diff --git a/packages/@n8n/ai-utilities/src/__tests__/memory/langchain-memory-adapter.test.ts b/packages/@n8n/ai-utilities/src/__tests__/memory/langchain-memory-adapter.test.ts index e1db31ffb86..93142ecb360 100644 --- a/packages/@n8n/ai-utilities/src/__tests__/memory/langchain-memory-adapter.test.ts +++ b/packages/@n8n/ai-utilities/src/__tests__/memory/langchain-memory-adapter.test.ts @@ -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); diff --git a/packages/@n8n/ai-utilities/src/__tests__/memory/windowed-chat-memory.test.ts b/packages/@n8n/ai-utilities/src/__tests__/memory/windowed-chat-memory.test.ts index 0bf2084272b..5ecfd0c3e59 100644 --- a/packages/@n8n/ai-utilities/src/__tests__/memory/windowed-chat-memory.test.ts +++ b/packages/@n8n/ai-utilities/src/__tests__/memory/windowed-chat-memory.test.ts @@ -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!' }] }, ]); }); }); diff --git a/packages/@n8n/ai-utilities/src/adapters/langchain-chat-model.ts b/packages/@n8n/ai-utilities/src/adapters/langchain-chat-model.ts index 02ea67b674a..519468425ee 100644 --- a/packages/@n8n/ai-utilities/src/adapters/langchain-chat-model.ts +++ b/packages/@n8n/ai-utilities/src/adapters/langchain-chat-model.ts @@ -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 }, + ); } } } diff --git a/packages/@n8n/ai-utilities/src/converters/message.ts b/packages/@n8n/ai-utilities/src/converters/message.ts index 39807e67b20..7a8e7191fb2 100644 --- a/packages/@n8n/ai-utilities/src/converters/message.ts +++ b/packages/@n8n/ai-utilities/src/converters/message.ts @@ -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, diff --git a/packages/@n8n/ai-utilities/src/memory/windowed-chat-memory.ts b/packages/@n8n/ai-utilities/src/memory/windowed-chat-memory.ts index 08c2db03ce7..b8de0efc41c 100644 --- a/packages/@n8n/ai-utilities/src/memory/windowed-chat-memory.ts +++ b/packages/@n8n/ai-utilities/src/memory/windowed-chat-memory.ts @@ -35,12 +35,12 @@ export class WindowedChatMemory extends BaseChatMemory { async saveTurn(input: string, output: string): Promise { const humanMessage: Message = { - role: 'human', + role: 'user', content: [{ type: 'text', text: input }], }; const aiMessage: Message = { - role: 'ai', + role: 'assistant', content: [{ type: 'text', text: output }], }; diff --git a/packages/@n8n/ai-utilities/src/suppliers/supplyModel.ts b/packages/@n8n/ai-utilities/src/suppliers/supplyModel.ts index 1712a215f94..4bd5fe31f40 100644 --- a/packages/@n8n/ai-utilities/src/suppliers/supplyModel.ts +++ b/packages/@n8n/ai-utilities/src/suppliers/supplyModel.ts @@ -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), }); diff --git a/packages/@n8n/ai-utilities/src/types/message.ts b/packages/@n8n/ai-utilities/src/types/message.ts index 76bef1e3282..520c688ac2d 100644 --- a/packages/@n8n/ai-utilities/src/types/message.ts +++ b/packages/@n8n/ai-utilities/src/types/message.ts @@ -1,4 +1,4 @@ -export type MessageRole = 'system' | 'human' | 'ai' | 'tool'; +export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; export type MessageContent = | ContentText diff --git a/packages/@n8n/ai-utilities/src/types/output.ts b/packages/@n8n/ai-utilities/src/types/output.ts index afa814e21bf..f6d56a7277c 100644 --- a/packages/@n8n/ai-utilities/src/types/output.ts +++ b/packages/@n8n/ai-utilities/src/types/output.ts @@ -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 = Record