diff --git a/packages/@n8n/ai-utilities/eslint.config.mjs b/packages/@n8n/ai-utilities/eslint.config.mjs index 1ff34870054..fe33e87f426 100644 --- a/packages/@n8n/ai-utilities/eslint.config.mjs +++ b/packages/@n8n/ai-utilities/eslint.config.mjs @@ -1,7 +1,7 @@ -import { defineConfig } from 'eslint/config'; +import { defineConfig, globalIgnores } from 'eslint/config'; import { nodeConfig } from '@n8n/eslint-config/node'; -export default defineConfig(nodeConfig, { +export default defineConfig(nodeConfig, globalIgnores(['scripts/**']), { rules: { '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unsafe-assignment': 'warn', @@ -11,5 +11,6 @@ export default defineConfig(nodeConfig, { 'no-case-declarations': 'warn', '@typescript-eslint/require-await': 'warn', '@typescript-eslint/prefer-nullish-coalescing': 'warn', + '@typescript-eslint/naming-convention': 'warn', }, }); diff --git a/packages/@n8n/ai-utilities/examples/models/openai.ts b/packages/@n8n/ai-utilities/examples/models/openai.ts index b0c761a005c..9c420c8ea3d 100644 --- a/packages/@n8n/ai-utilities/examples/models/openai.ts +++ b/packages/@n8n/ai-utilities/examples/models/openai.ts @@ -9,16 +9,10 @@ import { type StreamChunk, type Tool, type ToolCall, + type TokenUsage, } from '../../src'; import { parseSSEStream } from '../../src/utils/sse'; -// ============================================================================= -// OpenAI API Types -// ============================================================================= - -/** - * OpenAI API tool definition - */ export type OpenAITool = | { type: 'function'; @@ -31,14 +25,8 @@ export type OpenAITool = type: 'web_search'; }; -/** - * OpenAI API tool choice - */ export type OpenAIToolChoice = 'auto' | 'required' | 'none' | { type: 'function'; name: string }; -/** - * OpenAI Responses API request body - */ export interface OpenAIResponsesRequest { model: string; input: string | ResponsesInputItem[]; @@ -54,9 +42,6 @@ export interface OpenAIResponsesRequest { metadata?: Record; } -/** - * OpenAI Responses API response - */ export interface OpenAIResponsesResponse { id: string; object: string; @@ -81,9 +66,6 @@ export interface OpenAIResponsesResponse { service_tier?: string; } -/** - * OpenAI Responses API output item - */ export type ResponsesOutputItem = | { type: 'message'; @@ -110,9 +92,6 @@ export type ResponsesOutputItem = }>; }; -/** - * OpenAI streaming event types - */ export interface OpenAIStreamEvent { type: string; delta?: string; @@ -121,9 +100,6 @@ export interface OpenAIStreamEvent { response?: Record; } -/** - * OpenAI API error response - */ export interface OpenAIErrorResponse { error: { message: string; @@ -133,19 +109,11 @@ export interface OpenAIErrorResponse { }; } -// ============================================================================= -// HTTP Helper Functions -// ============================================================================= - -/** - * Make a POST request to OpenAI API - */ async function openAIFetch( url: string, apiKey: string, body: OpenAIResponsesRequest, ): Promise { - // Remove undefined values from request body const cleanedBody = Object.fromEntries( Object.entries(body).filter(([_, value]) => value !== undefined), ); @@ -172,15 +140,11 @@ async function openAIFetch( return (await response.json()) as OpenAIResponsesResponse; } -/** - * Make a streaming POST request to OpenAI API - */ async function openAIFetchStream( url: string, apiKey: string, body: OpenAIResponsesRequest, ): Promise> { - // Remove undefined values from request body const cleanedBody = Object.fromEntries( Object.entries(body).filter(([_, value]) => value !== undefined), ); @@ -211,25 +175,17 @@ async function openAIFetchStream( return response.body; } -/** - * Parse OpenAI streaming events from SSE stream - * Uses the robust SSE parser and extracts OpenAI-specific event data - */ async function* parseOpenAIStreamEvents( body: ReadableStream, ): AsyncIterable { for await (const message of parseSSEStream(body)) { - // OpenAI sends events in the data field if (!message.data) continue; - - // Skip [DONE] marker if (message.data === '[DONE]') continue; try { const event = JSON.parse(message.data); yield event as OpenAIStreamEvent; } catch (e) { - // Skip invalid JSON - log warning in development if (process.env.NODE_ENV !== 'production') { console.warn('Failed to parse OpenAI SSE event:', message.data); } @@ -237,10 +193,6 @@ async function* parseOpenAIStreamEvents( } } -// ============================================================================= -// OpenAI Responses API – input/output conversion -// ============================================================================= - type ResponsesInputItem = | { role: 'user'; content: string } | { role: 'user'; content: Array<{ type: 'input_text'; text: string }> } @@ -257,10 +209,6 @@ type ResponsesInputItem = } | { type: 'function_call_output'; call_id: string; output: string }; -/** - * Convert N8nMessage[] to OpenAI Responses API input and instructions. - * @see https://platform.openai.com/docs/api-reference/responses/create - */ function genericMessagesToResponsesInput(messages: Message[]): { instructions?: string; input: string | ResponsesInputItem[]; @@ -291,7 +239,6 @@ function genericMessagesToResponsesInput(messages: Message[]): { if (msg.role === 'ai') { for (const contentPart of msg.content) { - // Otherwise reconstruct from message content if (contentPart.type === 'text') { inputItems.push({ type: 'message', @@ -360,9 +307,6 @@ function genericMessagesToResponsesInput(messages: Message[]): { return { instructions, input: inputItems }; } -/** - * Convert N8nTool to OpenAI Responses API function tool format. - */ function genericToolToResponsesTool(tool: Tool): OpenAITool { if (tool.type === 'provider') { if (tool.name === 'web_search') { @@ -383,9 +327,6 @@ function genericToolToResponsesTool(tool: Tool): OpenAITool { }; } -/** - * Parse Responses API output array into text and tool calls. - */ function parseResponsesOutput(output: unknown[]): { text: string; toolCalls: ToolCall[]; @@ -421,32 +362,33 @@ function parseResponsesOutput(output: unknown[]): { return { text, toolCalls }; } -// ============================================================================= -// OpenAI Chat Model (Responses API) -// ============================================================================= +function parseTokenUsage( + usage: OpenAIResponsesResponse['usage'] | undefined, +): TokenUsage | undefined { + return usage + ? { + promptTokens: usage.input_tokens ?? 0, + completionTokens: usage.output_tokens ?? 0, + totalTokens: usage.total_tokens ?? 0, + inputTokenDetails: { + ...(!!usage.input_tokens_details?.cached_tokens && { + cacheRead: usage.input_tokens_details.cached_tokens, + }), + }, + outputTokenDetails: { + ...(!!usage.output_tokens_details?.reasoning_tokens && { + reasoning: usage.output_tokens_details.reasoning_tokens, + }), + }, + } + : undefined; +} export interface OpenAIChatModelConfig extends ChatModelConfig { - /** - * OpenAI API key (defaults to process.env.OPENAI_API_KEY) - */ apiKey?: string; - - /** - * Base URL for the API (optional, for proxies) - */ baseURL?: string; } -/** - * N8n chat model implementation using the OpenAI Responses API. - * Supports text, tools (function calling), and streaming. - * - * Note: This model does NOT execute tools automatically. When tool calls are - * returned by the model, they are passed to the framework (e.g., LangChain) - * which handles tool execution via its agent loop. - * - * @see https://platform.openai.com/docs/api-reference/responses/create - */ export class OpenAIChatModel extends BaseChatModel { private apiKey: string; private baseURL: string; @@ -482,25 +424,8 @@ export class OpenAIChatModel extends BaseChatModel { const { text, toolCalls } = parseResponsesOutput(response.output as unknown[]); - const usage = response.usage - ? { - promptTokens: response.usage.input_tokens ?? 0, - completionTokens: response.usage.output_tokens ?? 0, - totalTokens: response.usage.total_tokens ?? 0, - input_token_details: { - ...(!!response.usage.input_tokens_details?.cached_tokens && { - cache_read: response.usage.input_tokens_details.cached_tokens, - }), - }, - output_token_details: { - ...(!!response.usage.output_tokens_details?.reasoning_tokens && { - reasoning: response.usage.output_tokens_details.reasoning_tokens, - }), - }, - } - : undefined; + const usage = parseTokenUsage(response.usage); - // Build response metadata const responseMetadata: Record = { model_provider: 'openai', model: response.model, @@ -513,11 +438,9 @@ export class OpenAIChatModel extends BaseChatModel { user: response.user, service_tier: response.service_tier, model_name: response.model, - // Store raw output for reconstructing messages later output: response.output, }; - // Parse output for reasoning and other content for (const item of response.output as unknown[]) { const o = item as Record; if (o.type === 'reasoning') { @@ -525,7 +448,6 @@ export class OpenAIChatModel extends BaseChatModel { } } - // Create the message object const message: Message = { role: 'ai', content: [{ type: 'text', text }], @@ -571,7 +493,6 @@ export class OpenAIChatModel extends BaseChatModel { const toolCallBuffers: Record = {}; - // Parse SSE stream for await (const event of parseOpenAIStreamEvents(streamBody)) { const type = event.type; @@ -591,7 +512,6 @@ export class OpenAIChatModel extends BaseChatModel { arguments: (item.arguments as string) ?? '', }; } - // Handle reasoning items if (item?.type === 'reasoning') { const summary = (item.summary as Array>) ?? []; const reasoningText = summary @@ -641,17 +561,10 @@ export class OpenAIChatModel extends BaseChatModel { const responseData = (event.response as unknown as OpenAIResponsesResponse) ?? (event as unknown as OpenAIResponsesResponse); - const usage = responseData.usage; yield { type: 'finish', finishReason: 'stop', - usage: usage - ? { - promptTokens: usage.input_tokens ?? 0, - completionTokens: usage.output_tokens ?? 0, - totalTokens: usage.total_tokens ?? 0, - } - : undefined, + usage: parseTokenUsage(responseData.usage), }; } } diff --git a/packages/@n8n/ai-utilities/examples/run.ts b/packages/@n8n/ai-utilities/examples/run.ts index e8fe6f77d06..9abf25219de 100644 --- a/packages/@n8n/ai-utilities/examples/run.ts +++ b/packages/@n8n/ai-utilities/examples/run.ts @@ -3,7 +3,7 @@ import { createAgent, HumanMessage, tool } from 'langchain'; import z from 'zod'; import { OpenAIChatModel } from './models/openai'; -import { supplyModel } from '../src/suppliers/supplyModel'; +import { LangchainAdapter } from '../src/adapters/langchain-chat-model'; dotenv.config(); @@ -116,7 +116,7 @@ async function main() { const openaiChatModel = new OpenAIChatModel('gpt-4o', { apiKey: process.env.OPENAI_API_KEY, }); - chatModel = supplyModel(openaiChatModel).response; + chatModel = new LangchainAdapter(openaiChatModel); } else { throw new Error(`Unsupported model: ${model}`); } diff --git a/packages/@n8n/ai-utilities/package.json b/packages/@n8n/ai-utilities/package.json index f2d8d790a0f..1789199b982 100644 --- a/packages/@n8n/ai-utilities/package.json +++ b/packages/@n8n/ai-utilities/package.json @@ -10,7 +10,8 @@ "clean": "rimraf dist .turbo", "dev": "pnpm run watch", "typecheck": "tsc --noEmit", - "build": "tsc --build tsconfig.build.json && tsc-alias -p tsconfig.build.json", + "copy-tokenizer-json": "node scripts/copy-tokenizer-json.js .", + "build": "tsc --build tsconfig.build.json && tsc-alias -p tsconfig.build.json && pnpm copy-tokenizer-json", "format": "biome format --write .", "format:check": "biome ci .", "lint": "eslint . --quiet", @@ -41,6 +42,10 @@ "@n8n/config": "workspace:*", "@n8n/typescript-config": "workspace:*", "n8n-workflow": "workspace:*", - "tmp-promise": "3.0.3" + "tmp-promise": "3.0.3", + "js-tiktoken": "catalog:", + "https-proxy-agent": "catalog:", + "proxy-from-env": "^1.1.0", + "undici": "^6.21.0" } } diff --git a/packages/@n8n/nodes-langchain/scripts/copy-tokenizer-json.js b/packages/@n8n/ai-utilities/scripts/copy-tokenizer-json.js similarity index 70% rename from packages/@n8n/nodes-langchain/scripts/copy-tokenizer-json.js rename to packages/@n8n/ai-utilities/scripts/copy-tokenizer-json.js index 2aa7f1da1e9..26002ad9b18 100644 --- a/packages/@n8n/nodes-langchain/scripts/copy-tokenizer-json.js +++ b/packages/@n8n/ai-utilities/scripts/copy-tokenizer-json.js @@ -9,12 +9,12 @@ function copyTokenizerJsonFiles(baseDir) { fs.mkdirSync(targetDir, { recursive: true }); } // Copy all tokenizer JSON files - const files = glob.sync('utils/tokenizer/*.json', { cwd: 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); + const targetPath = path.resolve(baseDir, 'dist', file.replace('src/', '')); fs.copyFileSync(sourcePath, targetPath); - console.log(`Copied: ${file} -> dist/${file}`); + console.log(`Copied: ${file} -> ${targetPath.replace(baseDir, '')}`); } } diff --git a/packages/@n8n/ai-utilities/src/adapters/langchain.ts b/packages/@n8n/ai-utilities/src/adapters/langchain-chat-model.ts similarity index 76% rename from packages/@n8n/ai-utilities/src/adapters/langchain.ts rename to packages/@n8n/ai-utilities/src/adapters/langchain-chat-model.ts index d63de3730c3..02ea67b674a 100644 --- a/packages/@n8n/ai-utilities/src/adapters/langchain.ts +++ b/packages/@n8n/ai-utilities/src/adapters/langchain-chat-model.ts @@ -4,22 +4,49 @@ import type { BindToolsInput } from '@langchain/core/language_models/chat_models import { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { BaseMessage, ContentBlock } from '@langchain/core/messages'; import { AIMessage, AIMessageChunk } from '@langchain/core/messages'; -import type { ChatResult } from '@langchain/core/outputs'; +import type { ChatResult, LLMResult } from '@langchain/core/outputs'; 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 { fromLcTool } from '../converters/tool'; import type { ChatModel, ChatModelConfig } from '../types/chat-model'; +import { makeN8nLlmFailedAttemptHandler } from '../utils/failed-attempt-handler/n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../utils/n8n-llm-tracing'; export class LangchainAdapter< CallOptions extends ChatModelConfig = ChatModelConfig, > extends BaseChatModel { - constructor(private chatModel: ChatModel) { - super({ - // TODO: Move N8nLlmTracing to ai-utilities - // callbacks: [new N8nLlmTracing(this)], - }); + constructor( + private chatModel: ChatModel, + private ctx?: ISupplyDataFunctions, + ) { + const params = { + ...(ctx + ? { + callbacks: [ + new N8nLlmTracing(ctx, { + tokensUsageParser: (result: LLMResult) => { + const tokenUsage = result?.llmOutput?.tokenUsage as + | AIMessage['usage_metadata'] + | undefined; + const completionTokens = (tokenUsage?.output_tokens as number) ?? 0; + const promptTokens = (tokenUsage?.input_tokens as number) ?? 0; + + return { + completionTokens, + promptTokens, + totalTokens: completionTokens + promptTokens, + }; + }, + }), + ], + onFailedAttempt: makeN8nLlmFailedAttemptHandler(ctx), + } + : {}), + }; + super(params); } _llmType(): string { @@ -48,8 +75,16 @@ export class LangchainAdapter< input_tokens: result.usage.promptTokens ?? 0, output_tokens: result.usage.completionTokens ?? 0, total_tokens: result.usage.totalTokens ?? 0, - input_token_details: result.usage.input_token_details, - output_token_details: result.usage.output_token_details, + input_token_details: result.usage.inputTokenDetails + ? { + cache_read: result.usage.inputTokenDetails.cacheRead, + } + : undefined, + output_token_details: result.usage.outputTokenDetails + ? { + reasoning: result.usage.outputTokenDetails.reasoning, + } + : undefined, } : undefined; @@ -84,7 +119,7 @@ export class LangchainAdapter< ], llmOutput: { id: result.id, - estimatedTokenUsage: usage_metadata, + tokenUsage: usage_metadata, }, }; } @@ -182,7 +217,7 @@ export class LangchainAdapter< ): Runnable { const genericTools = tools.map(fromLcTool); const newModel = this.chatModel.withTools(genericTools); - const newAdapter = new LangchainAdapter(newModel); + const newAdapter = new LangchainAdapter(newModel, this.ctx); return newAdapter as any; } diff --git a/packages/@n8n/ai-utilities/src/chat-model/base.ts b/packages/@n8n/ai-utilities/src/chat-model/base.ts index c7d783c8128..5b6ca546f02 100644 --- a/packages/@n8n/ai-utilities/src/chat-model/base.ts +++ b/packages/@n8n/ai-utilities/src/chat-model/base.ts @@ -29,12 +29,6 @@ export abstract class BaseChatModel((tool) => ({ + // openai format requires type to be the name of the tool + // langchain simply passes the tool object to openai as is + type: tool.name, + ...tool.args, + })), + }; + } + + return openAiModel; +} + +export function supplyModel(ctx: ISupplyDataFunctions, model: ModelOptions) { + if (isOpenAiModel(model)) { + const openAiModel = getOpenAiModel(ctx, model); + return { + response: openAiModel, + }; + } + const adapter = new LangchainAdapter(model, ctx); return { response: adapter, }; diff --git a/packages/@n8n/ai-utilities/src/types/chat-model.ts b/packages/@n8n/ai-utilities/src/types/chat-model.ts index fee480003d0..d4a2e0abcf6 100644 --- a/packages/@n8n/ai-utilities/src/types/chat-model.ts +++ b/packages/@n8n/ai-utilities/src/types/chat-model.ts @@ -62,11 +62,6 @@ export interface ChatModelConfig { * Additional HTTP headers */ headers?: Record; - - /** - * Provider-specific options - */ - providerOptions?: Record; } export interface ChatModel { diff --git a/packages/@n8n/ai-utilities/src/types/openai.ts b/packages/@n8n/ai-utilities/src/types/openai.ts new file mode 100644 index 00000000000..d4c3bb55dab --- /dev/null +++ b/packages/@n8n/ai-utilities/src/types/openai.ts @@ -0,0 +1,142 @@ +import type { ProviderTool } from './tool'; + +export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | null; +export type VerbosityParam = 'low' | 'medium' | 'high' | null; + +export interface OpenAIModelOptions { + baseUrl: string; + /** Model name to use */ + model: string; + /** + * API key to use when making requests to OpenAI. + */ + apiKey: string; + /** + * Provider-specific tools to use. + * @example + * { + * type: 'provider', + * name: 'web_search', + * args: { + * search_context_size: 'medium', + * userLocation: { + * type: "approximate", + * country: "US" + * }, + * }, + * } + */ + providerTools?: ProviderTool[]; + defaultHeaders?: Record; + /** + * Whether to use the responses API for all requests. If `false` the responses API will be used + * only when required in order to fulfill the request. + */ + useResponsesApi?: boolean; + /** + * Whether to return log probabilities of the output tokens or not. + * If true, returns the log probabilities of each output token returned in the content of message. + */ + logprobs?: boolean; + /** + * An integer between 0 and 5 specifying the number of most likely tokens to return at each token position, + * each with an associated log probability. logprobs must be set to true if this parameter is used. + */ + topLogprobs?: number; + /** + * Whether the model supports the `strict` argument when passing in tools. + * If `undefined` the `strict` argument will not be passed to OpenAI. + */ + supportsStrictToolCalling?: boolean; + + reasoning?: { + effort?: ReasoningEffort | null; + summary?: 'auto' | 'concise' | 'detailed' | null; + }; + + /** + * Should be set to `true` in tenancies with Zero Data Retention + * @see https://platform.openai.com/docs/guides/your-data + * + * @default false + */ + zdrEnabled?: boolean; + + /** + * Service tier to use for this request. Can be "auto", "default", or "flex" or "priority". + * Specifies the service tier for prioritization and latency optimization. + */ + service_tier?: 'auto' | 'default' | 'flex' | 'scale' | 'priority' | null; + + /** + * Used by OpenAI to cache responses for similar requests to optimize your cache + * hit rates. Replaces the `user` field. + * [Learn more](https://platform.openai.com/docs/guides/prompt-caching). + */ + promptCacheKey?: string; + + /** Sampling temperature to use */ + temperature?: number; + /** + * Maximum number of tokens to generate in the completion. -1 returns as many + * tokens as possible given the prompt and the model's maximum context size. + */ + maxTokens?: number; + /** + * Maximum number of tokens to generate in the completion. -1 returns as many + * tokens as possible given the prompt and the model's maximum context size. + * Alias for `maxTokens` for reasoning models. + */ + maxCompletionTokens?: number; + /** Total probability mass of tokens to consider at each step */ + topP?: number; + /** Penalizes repeated tokens according to frequency */ + frequencyPenalty?: number; + /** Penalizes repeated tokens */ + presencePenalty?: number; + /** Number of completions to generate for each prompt */ + n?: number; + /** Dictionary used to adjust the probability of specific tokens being generated */ + logitBias?: Record; + /** Unique string identifier representing your end-user, which can help OpenAI to monitor and detect abuse. */ + user?: string; + /** Whether to stream the results or not. Enabling disables tokenUsage reporting */ + streaming?: boolean; + /** + * Whether or not to include token usage data in streamed chunks. + * @default true + */ + streamUsage?: boolean; + + /** Holds any additional parameters that are valid to pass to {@link + * https://platform.openai.com/docs/api-reference/completions/create | + * `openai.createCompletion`} that are not explicitly specified on this interface + */ + additionalParams?: Record; + /** + * List of stop words to use when generating + * Alias for `stopSequences` + */ + stop?: string[]; + /** List of stop words to use when generating */ + stopSequences?: string[]; + /** + * Timeout to use when making requests to OpenAI. + */ + timeout?: number; + /** + * The verbosity of the model's response. + */ + verbosity?: VerbosityParam; + /** + * Maximum number of retries to attempt. + */ + maxRetries?: number; + + /** + * Custom handler to handle failed attempts. Takes the originally thrown + * error object as input, and should itself throw an error if the input + * error is not retryable. + */ + onFailedAttempt?: (error: unknown) => void; +} diff --git a/packages/@n8n/ai-utilities/src/types/output.ts b/packages/@n8n/ai-utilities/src/types/output.ts index ea87731de09..afa814e21bf 100644 --- a/packages/@n8n/ai-utilities/src/types/output.ts +++ b/packages/@n8n/ai-utilities/src/types/output.ts @@ -1,21 +1,26 @@ import type { Message } from './message'; import type { ToolCall } from './tool'; +export type FinishReason = 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other'; + +export type TokenUsage = Record> = { + promptTokens: number; + completionTokens: number; + totalTokens: number; + inputTokenDetails?: { + cacheRead?: number; + }; + outputTokenDetails?: { + reasoning?: number; + }; + additionalMetadata?: T; +}; + export interface GenerateResult { id?: string; text: string; - finishReason?: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other'; - usage?: { - promptTokens: number; - completionTokens: number; - totalTokens: number; - input_token_details?: { - cache_read?: number; - }; - output_token_details?: { - reasoning?: number; - }; - }; + finishReason?: FinishReason; + usage?: TokenUsage; /** * Tool calls made by the model */ @@ -39,11 +44,7 @@ export interface StreamChunk { name?: string; argumentsDelta?: string; }; - finishReason?: string; - usage?: { - promptTokens: number; - completionTokens: number; - totalTokens: number; - }; + finishReason?: FinishReason; + usage?: TokenUsage; error?: unknown; } diff --git a/packages/@n8n/nodes-langchain/nodes/llms/n8nDefaultFailedAttemptHandler.test.ts b/packages/@n8n/ai-utilities/src/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.test.ts similarity index 100% rename from packages/@n8n/nodes-langchain/nodes/llms/n8nDefaultFailedAttemptHandler.test.ts rename to packages/@n8n/ai-utilities/src/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.test.ts diff --git a/packages/@n8n/nodes-langchain/nodes/llms/n8nDefaultFailedAttemptHandler.ts b/packages/@n8n/ai-utilities/src/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.ts similarity index 100% rename from packages/@n8n/nodes-langchain/nodes/llms/n8nDefaultFailedAttemptHandler.ts rename to packages/@n8n/ai-utilities/src/utils/failed-attempt-handler/n8nDefaultFailedAttemptHandler.ts diff --git a/packages/@n8n/nodes-langchain/nodes/llms/n8nLlmFailedAttemptHandler.test.ts b/packages/@n8n/ai-utilities/src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.test.ts similarity index 100% rename from packages/@n8n/nodes-langchain/nodes/llms/n8nLlmFailedAttemptHandler.test.ts rename to packages/@n8n/ai-utilities/src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.test.ts diff --git a/packages/@n8n/nodes-langchain/nodes/llms/n8nLlmFailedAttemptHandler.ts b/packages/@n8n/ai-utilities/src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.ts similarity index 94% rename from packages/@n8n/nodes-langchain/nodes/llms/n8nLlmFailedAttemptHandler.ts rename to packages/@n8n/ai-utilities/src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.ts index 61e8d165818..3bf2e35c086 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/n8nLlmFailedAttemptHandler.ts +++ b/packages/@n8n/ai-utilities/src/utils/failed-attempt-handler/n8nLlmFailedAttemptHandler.ts @@ -23,7 +23,7 @@ export const makeN8nLlmFailedAttemptHandler = ( n8nDefaultFailedAttemptHandler(error); } catch (e) { // Wrap the error in a NodeApiError - const apiError = new NodeApiError(ctx.getNode(), e as unknown as JsonObject, { + const apiError = new NodeApiError(ctx.getNode(), e as JsonObject, { functionality: 'configuration-node', }); diff --git a/packages/@n8n/ai-utilities/src/utils/helpers.test.ts b/packages/@n8n/ai-utilities/src/utils/helpers.test.ts new file mode 100644 index 00000000000..565e8521e57 --- /dev/null +++ b/packages/@n8n/ai-utilities/src/utils/helpers.test.ts @@ -0,0 +1,105 @@ +import { hasLongSequentialRepeat } from './helpers'; + +describe('hasLongSequentialRepeat', () => { + it('should return false for text shorter than threshold', () => { + const text = 'a'.repeat(99); + expect(hasLongSequentialRepeat(text, 100)).toBe(false); + }); + + it('should return false for normal text without repeats', () => { + const text = 'This is a normal text without many sequential repeating characters.'; + expect(hasLongSequentialRepeat(text)).toBe(false); + }); + + it('should return true for text with exactly threshold repeats', () => { + const text = 'a'.repeat(100); + expect(hasLongSequentialRepeat(text, 100)).toBe(true); + }); + + it('should return true for text with more than threshold repeats', () => { + const text = 'b'.repeat(150); + expect(hasLongSequentialRepeat(text, 100)).toBe(true); + }); + + it('should detect repeats in the middle of text', () => { + const text = 'Normal text ' + 'x'.repeat(100) + ' more normal text'; + expect(hasLongSequentialRepeat(text, 100)).toBe(true); + }); + + it('should detect repeats at the end of text', () => { + const text = 'Normal text at the beginning' + 'z'.repeat(100); + expect(hasLongSequentialRepeat(text, 100)).toBe(true); + }); + + it('should work with different thresholds', () => { + const text = 'a'.repeat(50); + expect(hasLongSequentialRepeat(text, 30)).toBe(true); + expect(hasLongSequentialRepeat(text, 60)).toBe(false); + }); + + it('should handle special characters', () => { + const text = '.'.repeat(100); + expect(hasLongSequentialRepeat(text, 100)).toBe(true); + }); + + it('should handle spaces', () => { + const text = ' '.repeat(100); + expect(hasLongSequentialRepeat(text, 100)).toBe(true); + }); + + it('should handle newlines', () => { + const text = '\n'.repeat(100); + expect(hasLongSequentialRepeat(text, 100)).toBe(true); + }); + + it('should not detect non-sequential repeats', () => { + const text = 'ababab'.repeat(50); // 300 chars but no sequential repeats + expect(hasLongSequentialRepeat(text, 100)).toBe(false); + }); + + it('should handle mixed content with repeats below threshold', () => { + const text = 'aaa' + 'b'.repeat(50) + 'ccc' + 'd'.repeat(40) + 'eee'; + expect(hasLongSequentialRepeat(text, 100)).toBe(false); + }); + + it('should handle empty string', () => { + expect(hasLongSequentialRepeat('', 100)).toBe(false); + }); + + it('should work with very large texts', () => { + const normalText = 'Lorem ipsum dolor sit amet '.repeat(1000); + const textWithRepeat = normalText + 'A'.repeat(100) + normalText; + expect(hasLongSequentialRepeat(textWithRepeat, 100)).toBe(true); + }); + + it('should detect unicode character repeats', () => { + const text = '😀'.repeat(100); + expect(hasLongSequentialRepeat(text, 100)).toBe(true); + }); + + describe('error handling', () => { + it('should handle null input', () => { + expect(hasLongSequentialRepeat(null as unknown as string)).toBe(false); + }); + + it('should handle undefined input', () => { + expect(hasLongSequentialRepeat(undefined as unknown as string)).toBe(false); + }); + + it('should handle non-string input', () => { + expect(hasLongSequentialRepeat(123 as unknown as string)).toBe(false); + expect(hasLongSequentialRepeat({} as unknown as string)).toBe(false); + expect(hasLongSequentialRepeat([] as unknown as string)).toBe(false); + }); + + it('should handle zero or negative threshold', () => { + const text = 'a'.repeat(100); + expect(hasLongSequentialRepeat(text, 0)).toBe(false); + expect(hasLongSequentialRepeat(text, -1)).toBe(false); + }); + + it('should handle empty string', () => { + expect(hasLongSequentialRepeat('', 100)).toBe(false); + }); + }); +}); diff --git a/packages/@n8n/ai-utilities/src/utils/helpers.ts b/packages/@n8n/ai-utilities/src/utils/helpers.ts index d1f85f16a0e..08375c1f602 100644 --- a/packages/@n8n/ai-utilities/src/utils/helpers.ts +++ b/packages/@n8n/ai-utilities/src/utils/helpers.ts @@ -26,3 +26,50 @@ export function getMetadataFiltersValues( return undefined; } + +/** + * Detects if a text contains a character that repeats sequentially for a specified threshold. + * This is used to prevent performance issues with tiktoken on highly repetitive content. + * @param text The text to check + * @param threshold The minimum number of sequential repeats to detect (default: 1000) + * @returns true if a character repeats sequentially for at least the threshold amount + */ +export function hasLongSequentialRepeat(text: string, threshold = 1000): boolean { + try { + // Validate inputs + if ( + text === null || + typeof text !== 'string' || + text.length === 0 || + threshold <= 0 || + text.length < threshold + ) { + return false; + } + // Use string iterator to avoid creating array copy (memory efficient) + const iterator = text[Symbol.iterator](); + let prev = iterator.next(); + + if (prev.done) { + return false; + } + + let count = 1; + for (const char of iterator) { + if (char === prev.value) { + count++; + if (count >= threshold) { + return true; + } + } else { + count = 1; + prev = { value: char, done: false }; + } + } + + return false; + } catch (error) { + // On any error, return false to allow normal processing + return false; + } +} diff --git a/packages/@n8n/nodes-langchain/utils/httpProxyAgent.ts b/packages/@n8n/ai-utilities/src/utils/http-proxy-agent.ts similarity index 100% rename from packages/@n8n/nodes-langchain/utils/httpProxyAgent.ts rename to packages/@n8n/ai-utilities/src/utils/http-proxy-agent.ts diff --git a/packages/@n8n/nodes-langchain/utils/tests/httpProxyAgent.test.ts b/packages/@n8n/ai-utilities/src/utils/httpProxyAgent.test.ts similarity index 99% rename from packages/@n8n/nodes-langchain/utils/tests/httpProxyAgent.test.ts rename to packages/@n8n/ai-utilities/src/utils/httpProxyAgent.test.ts index 6699e1aaa7d..066f1f88e8a 100644 --- a/packages/@n8n/nodes-langchain/utils/tests/httpProxyAgent.test.ts +++ b/packages/@n8n/ai-utilities/src/utils/httpProxyAgent.test.ts @@ -1,6 +1,6 @@ import { Agent, ProxyAgent } from 'undici'; -import { getProxyAgent, proxyFetch } from '../httpProxyAgent'; +import { getProxyAgent, proxyFetch } from './http-proxy-agent'; // Mock the dependencies jest.mock('undici', () => ({ diff --git a/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts b/packages/@n8n/ai-utilities/src/utils/n8n-llm-tracing.ts similarity index 96% rename from packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts rename to packages/@n8n/ai-utilities/src/utils/n8n-llm-tracing.ts index f416cee797d..68aeb2f345e 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/N8nLlmTracing.ts +++ b/packages/@n8n/ai-utilities/src/utils/n8n-llm-tracing.ts @@ -12,8 +12,8 @@ import pick from 'lodash/pick'; import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow'; import { NodeConnectionTypes, NodeError, NodeOperationError } from 'n8n-workflow'; -import { logAiEvent } from '@n8n/ai-utilities'; -import { estimateTokensFromStringList } from '@utils/tokenizer/token-estimator'; +import { logAiEvent } from './log-ai-event'; +import { estimateTokensFromStringList } from './tokenizer/token-estimator'; type TokensUsageParser = (result: LLMResult) => { completionTokens: number; @@ -192,6 +192,7 @@ export class N8nLlmTracing extends BaseCallbackHandler { const runDetails = this.runsMap[runId] ?? { index: Object.keys(this.runsMap).length }; // Filter out non-x- headers to avoid leaking sensitive information in logs + // eslint-disable-next-line no-prototype-builtins if (typeof error === 'object' && error?.hasOwnProperty('headers')) { const errorWithHeaders = error as { headers: Record }; @@ -220,6 +221,7 @@ export class N8nLlmTracing extends BaseCallbackHandler { } logAiEvent(this.executionFunctions, 'ai-llm-errored', { + // eslint-disable-next-line @typescript-eslint/no-base-to-string error: Object.keys(error).length === 0 ? error.toString() : error, runId, parentRunId, diff --git a/packages/@n8n/nodes-langchain/utils/tokenizer/cl100k_base.json b/packages/@n8n/ai-utilities/src/utils/tokenizer/cl100k_base.json similarity index 100% rename from packages/@n8n/nodes-langchain/utils/tokenizer/cl100k_base.json rename to packages/@n8n/ai-utilities/src/utils/tokenizer/cl100k_base.json diff --git a/packages/@n8n/nodes-langchain/utils/tokenizer/o200k_base.json b/packages/@n8n/ai-utilities/src/utils/tokenizer/o200k_base.json similarity index 100% rename from packages/@n8n/nodes-langchain/utils/tokenizer/o200k_base.json rename to packages/@n8n/ai-utilities/src/utils/tokenizer/o200k_base.json diff --git a/packages/@n8n/nodes-langchain/utils/tests/tiktoken.test.ts b/packages/@n8n/ai-utilities/src/utils/tokenizer/tests/tiktoken.test.ts similarity index 97% rename from packages/@n8n/nodes-langchain/utils/tests/tiktoken.test.ts rename to packages/@n8n/ai-utilities/src/utils/tokenizer/tests/tiktoken.test.ts index 0eda5e5d33d..22389a82ce9 100644 --- a/packages/@n8n/nodes-langchain/utils/tests/tiktoken.test.ts +++ b/packages/@n8n/ai-utilities/src/utils/tokenizer/tests/tiktoken.test.ts @@ -6,7 +6,7 @@ import type { TiktokenEncoding } from 'js-tiktoken/lite'; import { Tiktoken } from 'js-tiktoken/lite'; -import { getEncoding, encodingForModel } from '../tokenizer/tiktoken'; +import { getEncoding, encodingForModel } from '../tiktoken'; jest.mock('js-tiktoken/lite', () => ({ Tiktoken: jest.fn(), @@ -39,6 +39,7 @@ describe('tiktoken utils', () => { throw new Error(`Unexpected file path: ${path}`); }); + // eslint-disable-next-line n8n-local-rules/no-uncaught-json-parse mockJsonParse.mockImplementation((content: string) => JSON.parse(content)); }); diff --git a/packages/@n8n/nodes-langchain/utils/tokenizer/tests/token-estimator.test.ts b/packages/@n8n/ai-utilities/src/utils/tokenizer/tests/token-estimator.test.ts similarity index 100% rename from packages/@n8n/nodes-langchain/utils/tokenizer/tests/token-estimator.test.ts rename to packages/@n8n/ai-utilities/src/utils/tokenizer/tests/token-estimator.test.ts diff --git a/packages/@n8n/nodes-langchain/utils/tokenizer/tiktoken.ts b/packages/@n8n/ai-utilities/src/utils/tokenizer/tiktoken.ts similarity index 100% rename from packages/@n8n/nodes-langchain/utils/tokenizer/tiktoken.ts rename to packages/@n8n/ai-utilities/src/utils/tokenizer/tiktoken.ts diff --git a/packages/@n8n/nodes-langchain/utils/tokenizer/token-estimator.ts b/packages/@n8n/ai-utilities/src/utils/tokenizer/token-estimator.ts similarity index 100% rename from packages/@n8n/nodes-langchain/utils/tokenizer/token-estimator.ts rename to packages/@n8n/ai-utilities/src/utils/tokenizer/token-estimator.ts diff --git a/packages/@n8n/nodes-langchain/nodes/ModelSelector/ModelSelector.node.ts b/packages/@n8n/nodes-langchain/nodes/ModelSelector/ModelSelector.node.ts index 69337432466..3ffb7ce1d13 100644 --- a/packages/@n8n/nodes-langchain/nodes/ModelSelector/ModelSelector.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/ModelSelector/ModelSelector.node.ts @@ -14,7 +14,7 @@ import { } from 'n8n-workflow'; import { numberInputsProperty, configuredInputs } from './helpers'; -import { N8nLlmTracing } from '../llms/N8nLlmTracing'; +import { N8nLlmTracing } from '@n8n/ai-utilities'; import { N8nNonEstimatingTracing } from '../llms/N8nNonEstimatingTracing'; interface ModeleSelectionRule { diff --git a/packages/@n8n/nodes-langchain/nodes/ModelSelector/test/ModelSelector.node.test.ts b/packages/@n8n/nodes-langchain/nodes/ModelSelector/test/ModelSelector.node.test.ts index 39bd6cb1f5f..23f451ead34 100644 --- a/packages/@n8n/nodes-langchain/nodes/ModelSelector/test/ModelSelector.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/ModelSelector/test/ModelSelector.node.test.ts @@ -6,7 +6,7 @@ import { NodeOperationError, NodeConnectionTypes } from 'n8n-workflow'; import { ModelSelector } from '../ModelSelector.node'; // Mock the N8nLlmTracing module completely to avoid module resolution issues -jest.mock('../../llms/N8nLlmTracing', () => ({ +jest.mock('@n8n/ai-utilities', () => ({ N8nLlmTracing: jest.fn().mockImplementation(() => ({ handleLLMStart: jest.fn(), handleLLMEnd: jest.fn(), diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/buildExecutionContext.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/buildExecutionContext.ts index 3d347c54e0d..0d9d424cfc2 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/buildExecutionContext.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/buildExecutionContext.ts @@ -1,5 +1,5 @@ -import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { BaseChatMemory } from '@langchain/classic/memory'; +import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { NodeOperationError } from 'n8n-workflow'; import type { IExecuteFunctions, ISupplyDataFunctions, INodeExecutionData } from 'n8n-workflow'; import assert from 'node:assert'; diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts index a78fb592bad..1bb4e63846e 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAwsBedrock/EmbeddingsAwsBedrock.node.ts @@ -2,8 +2,7 @@ import type { BedrockRuntimeClientConfig } from '@aws-sdk/client-bedrock-runtime import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'; import { BedrockEmbeddings } from '@langchain/aws'; import { NodeHttpHandler } from '@smithy/node-http-handler'; -import { getNodeProxyAgent } from '@utils/httpProxyAgent'; -import { logWrapper } from '@n8n/ai-utilities'; +import { getNodeProxyAgent, logWrapper } from '@n8n/ai-utilities'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { NodeConnectionTypes, diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts index ed87b5ec15b..d66d375dceb 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts @@ -1,4 +1,5 @@ import { AzureOpenAIEmbeddings } from '@langchain/openai'; +import { getProxyAgent, logWrapper } from '@n8n/ai-utilities'; import { NodeConnectionTypes, type INodeType, @@ -7,8 +8,6 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getProxyAgent } from '@utils/httpProxyAgent'; -import { logWrapper } from '@n8n/ai-utilities'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class EmbeddingsAzureOpenAi implements INodeType { diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts index 35c218c7696..b5cbdedd353 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts @@ -12,8 +12,7 @@ import { import type { ClientOptions } from 'openai'; import { checkDomainRestrictions } from '@utils/checkDomainRestrictions'; -import { getProxyAgent } from '@utils/httpProxyAgent'; -import { logWrapper } from '@n8n/ai-utilities'; +import { getProxyAgent, logWrapper } from '@n8n/ai-utilities'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; const modelParameter: INodeProperties = { diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/test/EmbeddingsAzureOpenAi.test.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/test/EmbeddingsAzureOpenAi.test.ts index 247d975b165..5982c3f5a46 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/test/EmbeddingsAzureOpenAi.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/test/EmbeddingsAzureOpenAi.test.ts @@ -10,7 +10,8 @@ jest.mock('@langchain/openai'); class MockProxyAgent {} -jest.mock('@utils/httpProxyAgent', () => ({ +jest.mock('@n8n/ai-utilities', () => ({ + logWrapper: jest.fn().mockImplementation(() => jest.fn()), getProxyAgent: jest.fn().mockImplementation(() => new MockProxyAgent()), })); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts index f15185c8969..524a6a5f811 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts @@ -1,19 +1,18 @@ import { ChatAnthropic } from '@langchain/anthropic'; import type { LLMResult } from '@langchain/core/outputs'; -import { getProxyAgent } from '@utils/httpProxyAgent'; -import { getConnectionHintNoticeField } from '@utils/sharedFields'; +import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; import { NodeConnectionTypes, - type INodePropertyOptions, type INodeProperties, - type ISupplyDataFunctions, + type INodePropertyOptions, type INodeType, type INodeTypeDescription, + type ISupplyDataFunctions, type SupplyData, } from 'n8n-workflow'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + import { searchModels } from './methods/searchModels'; const modelField: INodeProperties = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/test/LmChatAnthropic.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/test/LmChatAnthropic.test.ts index af5c0484ae2..3cecb09ac79 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/test/LmChatAnthropic.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/test/LmChatAnthropic.test.ts @@ -2,23 +2,19 @@ /* eslint-disable @typescript-eslint/unbound-method */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { ChatAnthropic } from '@langchain/anthropic'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities'; import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; -import { makeN8nLlmFailedAttemptHandler } from '../../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../../N8nLlmTracing'; import { LmChatAnthropic } from '../LmChatAnthropic.node'; jest.mock('@langchain/anthropic'); -jest.mock('../../N8nLlmTracing'); -jest.mock('../../n8nLlmFailedAttemptHandler'); -jest.mock('@utils/httpProxyAgent', () => ({ - getProxyAgent: jest.fn().mockReturnValue({}), -})); +jest.mock('@n8n/ai-utilities'); const MockedChatAnthropic = jest.mocked(ChatAnthropic); const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing); const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler); +const mockedGetProxyAgent = jest.mocked(getProxyAgent); describe('LmChatAnthropic', () => { let lmChatAnthropic: LmChatAnthropic; @@ -50,7 +46,7 @@ describe('LmChatAnthropic', () => { // Mock the constructors/functions properly MockedN8nLlmTracing.mockImplementation(() => ({}) as N8nLlmTracing); mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(jest.fn()); - + mockedGetProxyAgent.mockReturnValue({} as any); return mockContext; }; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatLemonade/LmChatLemonade.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatLemonade/LmChatLemonade.node.ts index 46d8082e986..67f17c26b8a 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatLemonade/LmChatLemonade.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatLemonade/LmChatLemonade.node.ts @@ -1,4 +1,5 @@ import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; +import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; import { NodeConnectionTypes, type INodeType, @@ -10,11 +11,8 @@ import { import type { LemonadeApiCredentialsType } from '../../../credentials/LemonadeApi.credentials'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; -import { getProxyAgent } from '@utils/httpProxyAgent'; import { lemonadeModel, lemonadeOptions, lemonadeDescription } from '../LMLemonade/description'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatLemonade implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts index 3f20c891d01..f823d35dff5 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts @@ -1,5 +1,6 @@ import type { ChatOllamaInput } from '@langchain/ollama'; import { ChatOllama } from '@langchain/ollama'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, proxyFetch } from '@n8n/ai-utilities'; import { NodeConnectionTypes, type INodeType, @@ -9,11 +10,8 @@ import { } from 'n8n-workflow'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; -import { proxyFetch } from '@utils/httpProxyAgent'; import { ollamaModel, ollamaOptions, ollamaDescription } from '../LMOllama/description'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatOllama implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts index 930d58226a5..2670ac67de1 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts @@ -11,12 +11,10 @@ import { } from 'n8n-workflow'; import { checkDomainRestrictions } from '@utils/checkDomainRestrictions'; -import { getProxyAgent } from '@utils/httpProxyAgent'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities'; import { formatBuiltInTools, prepareAdditionalResponsesParams } from './common'; import { searchModels } from './methods/loadModels'; import type { ModelOptions } from './types'; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts index a7ed9480a8b..e62a6ff6d8b 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts @@ -2,7 +2,7 @@ import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow' import OpenAI from 'openai'; import { shouldIncludeModel } from '../../../vendors/OpenAi/helpers/modelFiltering'; -import { getProxyAgent } from '@utils/httpProxyAgent'; +import { getProxyAgent } from '@n8n/ai-utilities'; import { Container } from '@n8n/di'; import { AiConfig } from '@n8n/config'; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts index 6c312a73714..f5ba5629114 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts @@ -9,8 +9,7 @@ import { import { getConnectionHintNoticeField } from '@utils/sharedFields'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; export class LmCohere implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMLemonade/LmLemonade.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMLemonade/LmLemonade.node.ts index c495deafff3..4a8fda8bc02 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMLemonade/LmLemonade.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMLemonade/LmLemonade.node.ts @@ -12,8 +12,7 @@ import type { LemonadeApiCredentialsType } from '../../../credentials/LemonadeAp import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { lemonadeDescription, lemonadeModel, lemonadeOptions } from './description'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; export class LmLemonade implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts index 75114be3009..7645f837d6f 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts @@ -10,8 +10,7 @@ import { import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { ollamaDescription, ollamaModel, ollamaOptions } from './description'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; export class LmOllama implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts index d18edfad5d8..6aec4c82cf1 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts @@ -1,4 +1,5 @@ import { OpenAI, type ClientOptions } from '@langchain/openai'; +import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; import { NodeConnectionTypes } from 'n8n-workflow'; import type { INodeType, @@ -8,13 +9,9 @@ import type { ILoadOptionsFunctions, } from 'n8n-workflow'; -import { getProxyAgent } from '@utils/httpProxyAgent'; import { Container } from '@n8n/di'; import { AiConfig } from '@n8n/config'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; - type LmOpenAiOptions = { baseURL?: string; frequencyPenalty?: number; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts index cc88792747b..462abf270e3 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts @@ -9,8 +9,7 @@ import { import { getConnectionHintNoticeField } from '@utils/sharedFields'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; export class LmOpenHuggingFaceInference implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts index 7859c4dba7b..0a3980e6ef5 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts @@ -1,8 +1,12 @@ import type { BedrockRuntimeClientConfig } from '@aws-sdk/client-bedrock-runtime'; import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'; import { ChatBedrockConverse } from '@langchain/aws'; +import { + getNodeProxyAgent, + makeN8nLlmFailedAttemptHandler, + N8nLlmTracing, +} from '@n8n/ai-utilities'; import { NodeHttpHandler } from '@smithy/node-http-handler'; -import { getNodeProxyAgent } from '@utils/httpProxyAgent'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { NodeConnectionTypes, @@ -12,9 +16,6 @@ import { type SupplyData, } from 'n8n-workflow'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; - export class LmChatAwsBedrock implements INodeType { description: INodeTypeDescription = { displayName: 'AWS Bedrock Chat Model', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts index 8860a86955b..456589320da 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts @@ -1,4 +1,5 @@ import { AzureChatOpenAI } from '@langchain/openai'; +import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; import { NodeOperationError, NodeConnectionTypes, @@ -8,8 +9,6 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getProxyAgent } from '@utils/httpProxyAgent'; - import { setupApiKeyAuthentication } from './credentials/api-key'; import { setupOAuth2Authentication } from './credentials/oauth2'; import { properties } from './properties'; @@ -19,8 +18,6 @@ import type { AzureOpenAIOAuth2ModelConfig, AzureOpenAIOptions, } from './types'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatAzureOpenAi implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatCohere/LmChatCohere.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatCohere/LmChatCohere.node.ts index 3d8c10a1881..9351c306b19 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatCohere/LmChatCohere.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatCohere/LmChatCohere.node.ts @@ -9,8 +9,7 @@ import type { import { getConnectionHintNoticeField } from '@utils/sharedFields'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; export function tokensUsageParser(result: LLMResult): { completionTokens: number; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatDeepSeek/LmChatDeepSeek.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDeepSeek/LmChatDeepSeek.node.ts index 26dba6a5588..f852d7cd29f 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatDeepSeek/LmChatDeepSeek.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDeepSeek/LmChatDeepSeek.node.ts @@ -1,4 +1,5 @@ import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; +import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; import { NodeConnectionTypes, type INodeType, @@ -7,13 +8,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getProxyAgent } from '@utils/httpProxyAgent'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; import type { OpenAICompatibleCredential } from '../../../types/types'; import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatDeepSeek implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts index b6414b64fd4..d95e2bbe975 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts @@ -12,8 +12,7 @@ import type { import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { getAdditionalOptions } from '../gemini-common/additional-options'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; function errorDescriptionMapper(error: NodeError) { if (error.description?.includes('properties: should be non-empty for OBJECT type')) { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts index 67ac73af7e7..fa7532f3849 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts @@ -18,8 +18,7 @@ import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { makeErrorFromStatus } from './error-handling'; import { getAdditionalOptions } from '../gemini-common/additional-options'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; export class LmChatGoogleVertex implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts index c5960f8a053..b24587c0517 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/test/LmChatGoogleVertex.test.ts @@ -1,14 +1,12 @@ import { ChatVertexAI } from '@langchain/google-vertexai'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; -import { makeN8nLlmFailedAttemptHandler } from '../../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../../N8nLlmTracing'; import { LmChatGoogleVertex } from '../LmChatGoogleVertex.node'; jest.mock('@langchain/google-vertexai'); -jest.mock('../../N8nLlmTracing'); -jest.mock('../../n8nLlmFailedAttemptHandler'); +jest.mock('@n8n/ai-utilities'); jest.mock('n8n-nodes-base/dist/utils/utilities', () => ({ formatPrivateKey: jest.fn().mockImplementation((key: string) => key), })); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts index c4c3b53fbee..05aec2c008f 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts @@ -1,4 +1,5 @@ import { ChatGroq } from '@langchain/groq'; +import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; import { NodeConnectionTypes, type INodeType, @@ -7,12 +8,8 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getProxyAgent } from '@utils/httpProxyAgent'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; - export class LmChatGroq implements INodeType { description: INodeTypeDescription = { displayName: 'Groq Chat Model', diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts index e156f75b9eb..5dac030535e 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts @@ -1,6 +1,7 @@ import type { ChatMistralAIInput } from '@langchain/mistralai'; import { ChatMistralAI } from '@langchain/mistralai'; import { HTTPClient } from '@mistralai/mistralai/lib/http.js'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, proxyFetch } from '@n8n/ai-utilities'; import { NodeConnectionTypes, type INodeType, @@ -10,10 +11,6 @@ import { } from 'n8n-workflow'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; -import { proxyFetch } from '@utils/httpProxyAgent'; - -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; const deprecatedMagistralModelsWithTextOutput = ['magistral-small-2506', 'magistral-medium-2506']; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts index e84ba603dc4..e1cd4cabd93 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.ts @@ -1,4 +1,5 @@ import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; +import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; import { NodeConnectionTypes, type INodeType, @@ -7,13 +8,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getProxyAgent } from '@utils/httpProxyAgent'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; import type { OpenAICompatibleCredential } from '../../../types/types'; import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatOpenRouter implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatVercelAiGateway/LmChatVercelAiGateway.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatVercelAiGateway/LmChatVercelAiGateway.node.ts index 8b3a56671ca..1d5ef3aad16 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatVercelAiGateway/LmChatVercelAiGateway.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatVercelAiGateway/LmChatVercelAiGateway.node.ts @@ -1,4 +1,5 @@ import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; +import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; import { NodeConnectionTypes, type INodeType, @@ -7,13 +8,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getProxyAgent } from '@utils/httpProxyAgent'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; import type { OpenAICompatibleCredential } from '../../../types/types'; import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatVercelAiGateway implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/LmChatXAiGrok.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/LmChatXAiGrok.node.ts index 8ca7d5f8900..9c9594ff40b 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/LmChatXAiGrok.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/LmChatXAiGrok.node.ts @@ -1,4 +1,5 @@ import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; +import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; import { NodeConnectionTypes, type INodeType, @@ -7,13 +8,10 @@ import { type SupplyData, } from 'n8n-workflow'; -import { getProxyAgent } from '@utils/httpProxyAgent'; import { getConnectionHintNoticeField } from '@utils/sharedFields'; import type { OpenAICompatibleCredential } from '../../../types/types'; import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; export class LmChatXAiGrok implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts index 707c6521240..299ff631821 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts @@ -1,23 +1,19 @@ /* eslint-disable n8n-nodes-base/node-filename-against-convention */ /* eslint-disable @typescript-eslint/unbound-method */ import { ChatAnthropic } from '@langchain/anthropic'; +import { N8nLlmTracing, makeN8nLlmFailedAttemptHandler, getProxyAgent } from '@n8n/ai-utilities'; import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; import type { ILoadOptionsFunctions, INode, ISupplyDataFunctions } from 'n8n-workflow'; import { LmChatAnthropic } from '../LMChatAnthropic/LmChatAnthropic.node'; -import { N8nLlmTracing } from '../N8nLlmTracing'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; jest.mock('@langchain/anthropic'); -jest.mock('../N8nLlmTracing'); -jest.mock('../n8nLlmFailedAttemptHandler'); -jest.mock('@utils/httpProxyAgent', () => ({ - getProxyAgent: jest.fn().mockReturnValue({}), -})); +jest.mock('@n8n/ai-utilities'); const MockedChatAnthropic = jest.mocked(ChatAnthropic); const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing); const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler); +const mockedGetProxyAgent = jest.mocked(getProxyAgent); describe('LmChatAnthropic', () => { let lmChatAnthropic: LmChatAnthropic; @@ -49,7 +45,7 @@ describe('LmChatAnthropic', () => { // Mock the constructors/functions properly MockedN8nLlmTracing.mockImplementation(() => ({}) as any); mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(jest.fn()); - + mockedGetProxyAgent.mockReturnValue({} as any); return mockContext; }; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts index f41e5aa92f4..4d803f07198 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatOpenAi.test.ts @@ -1,6 +1,7 @@ /* eslint-disable n8n-nodes-base/node-filename-against-convention */ /* eslint-disable @typescript-eslint/unbound-method */ import { ChatOpenAI } from '@langchain/openai'; +import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities'; import { AiConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; @@ -8,21 +9,16 @@ import type { IDataObject, INode, ISupplyDataFunctions } from 'n8n-workflow'; import * as common from '../LMChatOpenAi/common'; import { LmChatOpenAi } from '../LMChatOpenAi/LmChatOpenAi.node'; -import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; -import { N8nLlmTracing } from '../N8nLlmTracing'; jest.mock('@langchain/openai'); -jest.mock('../N8nLlmTracing'); -jest.mock('../n8nLlmFailedAttemptHandler'); +jest.mock('@n8n/ai-utilities'); jest.mock('../LMChatOpenAi/common'); -jest.mock('@utils/httpProxyAgent', () => ({ - getProxyAgent: jest.fn().mockReturnValue({}), -})); const MockedChatOpenAI = jest.mocked(ChatOpenAI); const MockedN8nLlmTracing = jest.mocked(N8nLlmTracing); const mockedMakeN8nLlmFailedAttemptHandler = jest.mocked(makeN8nLlmFailedAttemptHandler); const mockedCommon = jest.mocked(common); +const mockedGetProxyAgent = jest.mocked(getProxyAgent); const { openAiDefaultHeaders: defaultHeaders } = Container.get(AiConfig); describe('LmChatOpenAi', () => { @@ -55,7 +51,7 @@ describe('LmChatOpenAi', () => { // Mock the constructors/functions properly MockedN8nLlmTracing.mockImplementation(() => ({}) as any); mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(jest.fn()); - + mockedGetProxyAgent.mockReturnValue({} as any); return mockContext; }; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/test/N8nLlmTracing.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/test/N8nLlmTracing.test.ts index 889a0fa6492..06697b3f4df 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/test/N8nLlmTracing.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/test/N8nLlmTracing.test.ts @@ -8,7 +8,7 @@ import { mock } from 'jest-mock-extended'; import type { IDataObject, ISupplyDataFunctions } from 'n8n-workflow'; import { NodeOperationError, NodeApiError } from 'n8n-workflow'; -import { N8nLlmTracing } from '../N8nLlmTracing'; +import { N8nLlmTracing } from '@n8n/ai-utilities'; describe('N8nLlmTracing', () => { const executionFunctions = mock({ diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts index 40a1bcb9801..88eb880e700 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts @@ -11,7 +11,7 @@ import type { } from 'n8n-workflow'; import { createResultError, createResultOk, NodeOperationError } from 'n8n-workflow'; -import { proxyFetch } from '@utils/httpProxyAgent'; +import { proxyFetch } from '@n8n/ai-utilities'; import type { McpAuthenticationOption, McpServerTransport, McpTool } from './types'; diff --git a/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/TokenTextSplitter.ts b/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/TokenTextSplitter.ts index 670d4450710..262b30ab110 100644 --- a/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/TokenTextSplitter.ts +++ b/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/TokenTextSplitter.ts @@ -1,8 +1,10 @@ import type { TokenTextSplitterParams } from '@langchain/textsplitters'; import { TextSplitter } from '@langchain/textsplitters'; -import { hasLongSequentialRepeat } from '@utils/helpers'; -import { getEncoding } from '@utils/tokenizer/tiktoken'; -import { estimateTextSplitsByTokens } from '@utils/tokenizer/token-estimator'; +import { + hasLongSequentialRepeat, + getEncoding, + estimateTextSplitsByTokens, +} from '@n8n/ai-utilities'; import type * as tiktoken from 'js-tiktoken'; /** diff --git a/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/tests/TokenTextSplitter.test.ts b/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/tests/TokenTextSplitter.test.ts index f5b427a0799..030e845d0f0 100644 --- a/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/tests/TokenTextSplitter.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/text_splitters/TextSplitterTokenSplitter/tests/TokenTextSplitter.test.ts @@ -1,13 +1,9 @@ +import * as aiUtilities from '@n8n/ai-utilities'; import { OperationalError } from 'n8n-workflow'; -import * as helpers from '../../../../utils/helpers'; -import * as tiktokenUtils from '../../../../utils/tokenizer/tiktoken'; -import * as tokenEstimator from '../../../../utils/tokenizer/token-estimator'; import { TokenTextSplitter } from '../TokenTextSplitter'; -jest.mock('../../../../utils/tokenizer/tiktoken'); -jest.mock('../../../../utils/helpers'); -jest.mock('../../../../utils/tokenizer/token-estimator'); +jest.mock('@n8n/ai-utilities'); describe('TokenTextSplitter', () => { let mockTokenizer: jest.Mocked<{ @@ -20,9 +16,9 @@ describe('TokenTextSplitter', () => { encode: jest.fn(), decode: jest.fn(), }; - (tiktokenUtils.getEncoding as jest.Mock).mockReturnValue(mockTokenizer); + (aiUtilities.getEncoding as jest.Mock).mockReturnValue(mockTokenizer); // Default mock for hasLongSequentialRepeat - no repetition - (helpers.hasLongSequentialRepeat as jest.Mock).mockReturnValue(false); + (aiUtilities.hasLongSequentialRepeat as jest.Mock).mockReturnValue(false); }); afterEach(() => { @@ -85,7 +81,7 @@ describe('TokenTextSplitter', () => { const result = await splitter.splitText(inputText); - expect(tiktokenUtils.getEncoding).toHaveBeenCalledWith('cl100k_base'); + expect(aiUtilities.getEncoding).toHaveBeenCalledWith('cl100k_base'); expect(mockTokenizer.encode).toHaveBeenCalledWith(inputText, [], 'all'); expect(result).toEqual(['Hello world,', ' this is', ' a test']); }); @@ -129,7 +125,7 @@ describe('TokenTextSplitter', () => { await splitter.splitText(inputText); - expect(tiktokenUtils.getEncoding).toHaveBeenCalledWith('o200k_base'); + expect(aiUtilities.getEncoding).toHaveBeenCalledWith('o200k_base'); expect(mockTokenizer.encode).toHaveBeenCalledWith(inputText, ['<|special|>'], ['<|bad|>']); }); @@ -141,7 +137,7 @@ describe('TokenTextSplitter', () => { await splitter.splitText('first call'); await splitter.splitText('second call'); - expect(tiktokenUtils.getEncoding).toHaveBeenCalledTimes(1); + expect(aiUtilities.getEncoding).toHaveBeenCalledTimes(1); }); it('should handle large text with multiple chunks and overlap', async () => { @@ -180,18 +176,18 @@ describe('TokenTextSplitter', () => { const repetitiveText = 'a'.repeat(1000); const estimatedChunks = ['chunk1', 'chunk2', 'chunk3']; - (helpers.hasLongSequentialRepeat as jest.Mock).mockReturnValue(true); - (tokenEstimator.estimateTextSplitsByTokens as jest.Mock).mockReturnValue(estimatedChunks); + (aiUtilities.hasLongSequentialRepeat as jest.Mock).mockReturnValue(true); + (aiUtilities.estimateTextSplitsByTokens as jest.Mock).mockReturnValue(estimatedChunks); const result = await splitter.splitText(repetitiveText); // Should not call tiktoken - expect(tiktokenUtils.getEncoding).not.toHaveBeenCalled(); + expect(aiUtilities.getEncoding).not.toHaveBeenCalled(); expect(mockTokenizer.encode).not.toHaveBeenCalled(); // Should use estimation - expect(helpers.hasLongSequentialRepeat).toHaveBeenCalledWith(repetitiveText); - expect(tokenEstimator.estimateTextSplitsByTokens).toHaveBeenCalledWith( + expect(aiUtilities.hasLongSequentialRepeat).toHaveBeenCalledWith(repetitiveText); + expect(aiUtilities.estimateTextSplitsByTokens).toHaveBeenCalledWith( repetitiveText, 100, 10, @@ -210,21 +206,21 @@ describe('TokenTextSplitter', () => { const normalText = 'This is normal text without repetition'; const mockTokenIds = [1, 2, 3, 4, 5, 6]; - (helpers.hasLongSequentialRepeat as jest.Mock).mockReturnValue(false); + (aiUtilities.hasLongSequentialRepeat as jest.Mock).mockReturnValue(false); mockTokenizer.encode.mockReturnValue(mockTokenIds); mockTokenizer.decode.mockImplementation(() => 'chunk'); await splitter.splitText(normalText); // Should check for repetition - expect(helpers.hasLongSequentialRepeat).toHaveBeenCalledWith(normalText); + expect(aiUtilities.hasLongSequentialRepeat).toHaveBeenCalledWith(normalText); // Should use tiktoken - expect(tiktokenUtils.getEncoding).toHaveBeenCalled(); + expect(aiUtilities.getEncoding).toHaveBeenCalled(); expect(mockTokenizer.encode).toHaveBeenCalled(); // Should not use estimation - expect(tokenEstimator.estimateTextSplitsByTokens).not.toHaveBeenCalled(); + expect(aiUtilities.estimateTextSplitsByTokens).not.toHaveBeenCalled(); }); it('should handle repetitive content with different encodings', async () => { @@ -237,12 +233,12 @@ describe('TokenTextSplitter', () => { const repetitiveText = '.'.repeat(500); const estimatedChunks = ['estimated chunk 1', 'estimated chunk 2']; - (helpers.hasLongSequentialRepeat as jest.Mock).mockReturnValue(true); - (tokenEstimator.estimateTextSplitsByTokens as jest.Mock).mockReturnValue(estimatedChunks); + (aiUtilities.hasLongSequentialRepeat as jest.Mock).mockReturnValue(true); + (aiUtilities.estimateTextSplitsByTokens as jest.Mock).mockReturnValue(estimatedChunks); const result = await splitter.splitText(repetitiveText); - expect(tokenEstimator.estimateTextSplitsByTokens).toHaveBeenCalledWith( + expect(aiUtilities.estimateTextSplitsByTokens).toHaveBeenCalledWith( repetitiveText, 50, 5, @@ -255,12 +251,12 @@ describe('TokenTextSplitter', () => { const splitter = new TokenTextSplitter(); const edgeText = 'x'.repeat(100); - (helpers.hasLongSequentialRepeat as jest.Mock).mockReturnValue(true); - (tokenEstimator.estimateTextSplitsByTokens as jest.Mock).mockReturnValue(['single chunk']); + (aiUtilities.hasLongSequentialRepeat as jest.Mock).mockReturnValue(true); + (aiUtilities.estimateTextSplitsByTokens as jest.Mock).mockReturnValue(['single chunk']); const result = await splitter.splitText(edgeText); - expect(helpers.hasLongSequentialRepeat).toHaveBeenCalledWith(edgeText); + expect(aiUtilities.hasLongSequentialRepeat).toHaveBeenCalledWith(edgeText); expect(result).toEqual(['single chunk']); }); @@ -268,16 +264,13 @@ describe('TokenTextSplitter', () => { const splitter = new TokenTextSplitter(); const mixedText = 'Normal text ' + 'z'.repeat(200) + ' more normal text'; - (helpers.hasLongSequentialRepeat as jest.Mock).mockReturnValue(true); - (tokenEstimator.estimateTextSplitsByTokens as jest.Mock).mockReturnValue([ - 'chunk1', - 'chunk2', - ]); + (aiUtilities.hasLongSequentialRepeat as jest.Mock).mockReturnValue(true); + (aiUtilities.estimateTextSplitsByTokens as jest.Mock).mockReturnValue(['chunk1', 'chunk2']); const result = await splitter.splitText(mixedText); - expect(helpers.hasLongSequentialRepeat).toHaveBeenCalledWith(mixedText); - expect(tokenEstimator.estimateTextSplitsByTokens).toHaveBeenCalled(); + expect(aiUtilities.hasLongSequentialRepeat).toHaveBeenCalledWith(mixedText); + expect(aiUtilities.estimateTextSplitsByTokens).toHaveBeenCalled(); expect(result).toEqual(['chunk1', 'chunk2']); }); }); @@ -305,18 +298,16 @@ describe('TokenTextSplitter', () => { const splitter = new TokenTextSplitter(); const text = 'This will cause tiktoken to fail'; - (helpers.hasLongSequentialRepeat as jest.Mock).mockReturnValue(false); - (tiktokenUtils.getEncoding as jest.Mock).mockImplementation(() => { + (aiUtilities.hasLongSequentialRepeat as jest.Mock).mockReturnValue(false); + (aiUtilities.getEncoding as jest.Mock).mockImplementation(() => { throw new Error('Tiktoken error'); }); - (tokenEstimator.estimateTextSplitsByTokens as jest.Mock).mockReturnValue([ - 'fallback chunk', - ]); + (aiUtilities.estimateTextSplitsByTokens as jest.Mock).mockReturnValue(['fallback chunk']); const result = await splitter.splitText(text); expect(result).toEqual(['fallback chunk']); - expect(tokenEstimator.estimateTextSplitsByTokens).toHaveBeenCalledWith( + expect(aiUtilities.estimateTextSplitsByTokens).toHaveBeenCalledWith( text, splitter.chunkSize, splitter.chunkOverlap, @@ -328,13 +319,11 @@ describe('TokenTextSplitter', () => { const splitter = new TokenTextSplitter(); const text = 'This will cause encode to fail'; - (helpers.hasLongSequentialRepeat as jest.Mock).mockReturnValue(false); + (aiUtilities.hasLongSequentialRepeat as jest.Mock).mockReturnValue(false); mockTokenizer.encode.mockImplementation(() => { throw new OperationalError('Encode error'); }); - (tokenEstimator.estimateTextSplitsByTokens as jest.Mock).mockReturnValue([ - 'fallback chunk', - ]); + (aiUtilities.estimateTextSplitsByTokens as jest.Mock).mockReturnValue(['fallback chunk']); const result = await splitter.splitText(text); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts index 39b1c99c0ac..e3fb5b180b4 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts @@ -24,7 +24,7 @@ import { getTracingConfig } from '@utils/tracing'; import { formatToOpenAIAssistantTool, getChatMessages } from '../../../helpers/utils'; import { assistantRLC } from '../descriptions'; -import { getProxyAgent } from '@utils/httpProxyAgent'; +import { getProxyAgent } from '@n8n/ai-utilities'; import { Container } from '@n8n/di'; import { AiConfig } from '@n8n/config'; import { checkDomainRestrictions } from '@utils/checkDomainRestrictions'; diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 7bb85699903..f0e68a34cf2 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -25,8 +25,7 @@ "dev": "pnpm run watch", "typecheck": "tsc --noEmit", "copy-nodes-json": "node ../../nodes-base/scripts/copy-nodes-json.js .", - "copy-tokenizer-json": "node scripts/copy-tokenizer-json.js .", - "build": "tsc --build tsconfig.build.json && pnpm copy-nodes-json && tsc-alias -p tsconfig.build.json && pnpm copy-tokenizer-json && pnpm n8n-copy-static-files && pnpm n8n-generate-metadata", + "build": "tsc --build tsconfig.build.json && pnpm copy-nodes-json && tsc-alias -p tsconfig.build.json && pnpm n8n-copy-static-files && pnpm n8n-generate-metadata", "format": "biome format --write .", "format:check": "biome ci .", "lint": "eslint nodes credentials utils --quiet", @@ -256,9 +255,8 @@ "form-data": "catalog:", "generate-schema": "2.6.0", "html-to-text": "9.0.5", - "https-proxy-agent": "catalog:", "ignore": "^5.2.0", - "js-tiktoken": "^1.0.12", + "js-tiktoken": "catalog:", "jsdom": "23.0.1", "langchain": "catalog:", "@langchain/classic": "1.0.5", @@ -271,13 +269,11 @@ "openai": "^6.9.0", "pdf-parse": "1.1.1", "pg": "catalog:", - "proxy-from-env": "^1.1.0", "redis": "4.6.14", "sanitize-html": "2.12.1", "sqlite3": "5.1.7", "temp": "0.9.4", "tmp-promise": "3.0.3", - "undici": "^6.21.0", "weaviate-client": "3.9.0", "zod": "catalog:", "zod-to-json-schema": "3.23.3" diff --git a/packages/@n8n/nodes-langchain/scripts/post-build.js b/packages/@n8n/nodes-langchain/scripts/post-build.js index 9450175799b..2d1a11bd99e 100644 --- a/packages/@n8n/nodes-langchain/scripts/post-build.js +++ b/packages/@n8n/nodes-langchain/scripts/post-build.js @@ -17,7 +17,6 @@ function runCommand(command) { // Run all post-build tasks runCommand('npx tsc-alias -p tsconfig.build.json'); -runCommand('node scripts/copy-tokenizer-json.js .'); runCommand('node ../../nodes-base/scripts/copy-nodes-json.js .'); runCommand('pnpm n8n-copy-static-files'); runCommand('pnpm n8n-generate-metadata'); diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index 68a6974e1e7..986cb0cb2c0 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -238,50 +238,3 @@ export function unwrapNestedOutput(output: Record): Record= threshold) { - return true; - } - } else { - count = 1; - prev = { value: char, done: false }; - } - } - - return false; - } catch (error) { - // On any error, return false to allow normal processing - return false; - } -} diff --git a/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts b/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts index 36cd7ae9208..b5497b69cc1 100644 --- a/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts +++ b/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts @@ -8,7 +8,6 @@ import { z } from 'zod'; import { escapeSingleCurlyBrackets, getConnectedTools, - hasLongSequentialRepeat, unwrapNestedOutput, getSessionId, } from '../helpers'; @@ -486,107 +485,3 @@ describe('getSessionId', () => { expect(sessionId).toBe('customSessionId'); }); }); - -describe('hasLongSequentialRepeat', () => { - it('should return false for text shorter than threshold', () => { - const text = 'a'.repeat(99); - expect(hasLongSequentialRepeat(text, 100)).toBe(false); - }); - - it('should return false for normal text without repeats', () => { - const text = 'This is a normal text without many sequential repeating characters.'; - expect(hasLongSequentialRepeat(text)).toBe(false); - }); - - it('should return true for text with exactly threshold repeats', () => { - const text = 'a'.repeat(100); - expect(hasLongSequentialRepeat(text, 100)).toBe(true); - }); - - it('should return true for text with more than threshold repeats', () => { - const text = 'b'.repeat(150); - expect(hasLongSequentialRepeat(text, 100)).toBe(true); - }); - - it('should detect repeats in the middle of text', () => { - const text = 'Normal text ' + 'x'.repeat(100) + ' more normal text'; - expect(hasLongSequentialRepeat(text, 100)).toBe(true); - }); - - it('should detect repeats at the end of text', () => { - const text = 'Normal text at the beginning' + 'z'.repeat(100); - expect(hasLongSequentialRepeat(text, 100)).toBe(true); - }); - - it('should work with different thresholds', () => { - const text = 'a'.repeat(50); - expect(hasLongSequentialRepeat(text, 30)).toBe(true); - expect(hasLongSequentialRepeat(text, 60)).toBe(false); - }); - - it('should handle special characters', () => { - const text = '.'.repeat(100); - expect(hasLongSequentialRepeat(text, 100)).toBe(true); - }); - - it('should handle spaces', () => { - const text = ' '.repeat(100); - expect(hasLongSequentialRepeat(text, 100)).toBe(true); - }); - - it('should handle newlines', () => { - const text = '\n'.repeat(100); - expect(hasLongSequentialRepeat(text, 100)).toBe(true); - }); - - it('should not detect non-sequential repeats', () => { - const text = 'ababab'.repeat(50); // 300 chars but no sequential repeats - expect(hasLongSequentialRepeat(text, 100)).toBe(false); - }); - - it('should handle mixed content with repeats below threshold', () => { - const text = 'aaa' + 'b'.repeat(50) + 'ccc' + 'd'.repeat(40) + 'eee'; - expect(hasLongSequentialRepeat(text, 100)).toBe(false); - }); - - it('should handle empty string', () => { - expect(hasLongSequentialRepeat('', 100)).toBe(false); - }); - - it('should work with very large texts', () => { - const normalText = 'Lorem ipsum dolor sit amet '.repeat(1000); - const textWithRepeat = normalText + 'A'.repeat(100) + normalText; - expect(hasLongSequentialRepeat(textWithRepeat, 100)).toBe(true); - }); - - it('should detect unicode character repeats', () => { - const text = '😀'.repeat(100); - expect(hasLongSequentialRepeat(text, 100)).toBe(true); - }); - - describe('error handling', () => { - it('should handle null input', () => { - expect(hasLongSequentialRepeat(null as any)).toBe(false); - }); - - it('should handle undefined input', () => { - expect(hasLongSequentialRepeat(undefined as any)).toBe(false); - }); - - it('should handle non-string input', () => { - expect(hasLongSequentialRepeat(123 as any)).toBe(false); - expect(hasLongSequentialRepeat({} as any)).toBe(false); - expect(hasLongSequentialRepeat([] as any)).toBe(false); - }); - - it('should handle zero or negative threshold', () => { - const text = 'a'.repeat(100); - expect(hasLongSequentialRepeat(text, 0)).toBe(false); - expect(hasLongSequentialRepeat(text, -1)).toBe(false); - }); - - it('should handle empty string', () => { - expect(hasLongSequentialRepeat('', 100)).toBe(false); - }); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83ba3f9bb60..cef21abd4d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ catalogs: js-base64: specifier: 3.7.2 version: 3.7.2 + js-tiktoken: + specifier: 1.0.12 + version: 1.0.12 jsonrepair: specifier: 3.13.1 version: 3.13.1 @@ -504,15 +507,27 @@ importers: '@n8n/typescript-config': specifier: workspace:* version: link:../typescript-config + https-proxy-agent: + specifier: 'catalog:' + version: 7.0.6 + js-tiktoken: + specifier: 'catalog:' + version: 1.0.12 langchain: specifier: 'catalog:' version: 1.2.3(@langchain/core@1.1.8(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.204.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.9.1(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.204.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(openai@6.9.1(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(zod-to-json-schema@3.23.3(zod@3.25.67)) n8n-workflow: specifier: workspace:* version: link:../../workflow + proxy-from-env: + specifier: ^1.1.0 + version: 1.1.0 tmp-promise: specifier: 3.0.3 version: 3.0.3 + undici: + specifier: ^6.23.0 + version: 6.23.0 zod: specifier: 3.25.67 version: 3.25.67 @@ -1484,14 +1499,11 @@ importers: html-to-text: specifier: 9.0.5 version: 9.0.5 - https-proxy-agent: - specifier: 'catalog:' - version: 7.0.6 ignore: specifier: ^5.2.0 version: 5.2.4 js-tiktoken: - specifier: ^1.0.12 + specifier: 'catalog:' version: 1.0.12 jsdom: specifier: 23.0.1 @@ -1526,9 +1538,6 @@ importers: pg: specifier: 'catalog:' version: 8.17.0 - proxy-from-env: - specifier: ^1.1.0 - version: 1.1.0 redis: specifier: 4.6.14 version: 4.6.14 @@ -1544,9 +1553,6 @@ importers: tmp-promise: specifier: 3.0.3 version: 3.0.3 - undici: - specifier: ^6.23.0 - version: 6.23.0 vm2: specifier: 'catalog:' version: 3.10.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f5b707b097b..a62adb18c7f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -81,6 +81,7 @@ catalog: xss: 1.0.15 zod: 3.25.67 zod-to-json-schema: 3.23.3 + js-tiktoken: 1.0.12 catalogs: frontend: