diff --git a/packages/@n8n/ai-utilities/src/__tests__/utils/http-proxy-agent.test.ts b/packages/@n8n/ai-utilities/src/__tests__/utils/http-proxy-agent.test.ts index b149b91eff7..8c341389187 100644 --- a/packages/@n8n/ai-utilities/src/__tests__/utils/http-proxy-agent.test.ts +++ b/packages/@n8n/ai-utilities/src/__tests__/utils/http-proxy-agent.test.ts @@ -244,6 +244,38 @@ describe('getProxyAgent', () => { // Since we can't easily re-import, we verify the mock was called with defaults expect(Agent).toHaveBeenCalled(); }); + + it('should return an Agent instead of undefined when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => { + process.env.N8N_AI_TIMEOUT_MAX = '120000'; + + const agent = getProxyAgent('https://api.openai.com/v1'); + + // DEFAULT_TIMEOUT was captured from the env at module load time (before this test set it), + // so the value here reflects that capture, not '120000' — the module-reset test below + // covers the env value actually being picked up end to end. + expect(Agent).toHaveBeenCalled(); + expect(agent).toEqual(expect.objectContaining({ type: 'Agent' })); + expect(ProxyAgent).not.toHaveBeenCalled(); + }); + + it('should honor N8N_AI_TIMEOUT_MAX when there is no proxy and the caller passes no timeout options at all', async () => { + vi.resetModules(); + process.env.N8N_AI_TIMEOUT_MAX = '120000'; + + const undici = await import('undici'); + const { getProxyAgent: freshGetProxyAgent } = await import('../../utils/http-proxy-agent.js'); + + const agent = freshGetProxyAgent('https://api.openai.com/v1'); + + expect(undici.Agent).toHaveBeenCalledWith({ + headersTimeout: 120000, + bodyTimeout: 120000, + }); + expect(agent).toEqual({ + type: 'Agent', + options: { headersTimeout: 120000, bodyTimeout: 120000 }, + }); + }); }); describe('secure lookup', () => { diff --git a/packages/@n8n/ai-utilities/src/utils/http-proxy-agent.ts b/packages/@n8n/ai-utilities/src/utils/http-proxy-agent.ts index c3e1c9cba7b..6aceee98dcf 100644 --- a/packages/@n8n/ai-utilities/src/utils/http-proxy-agent.ts +++ b/packages/@n8n/ai-utilities/src/utils/http-proxy-agent.ts @@ -52,13 +52,16 @@ const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/'; * always returns an Agent/ProxyAgent (even without proxy) to ensure timeouts are applied. * @param lookup - Optional DNS lookup to pin the resolved address at connect time (e.g. an egress * filter's secure lookup). When provided (without a proxy) an Agent is always returned. - * @returns An Agent (no proxy with timeout options or a lookup) or ProxyAgent (with proxy) configured with timeouts, - * or undefined if no proxy, timeout options, nor lookup are provided (backward compatible behavior). + * @returns An Agent (no proxy with timeout options, a lookup, or `N8N_AI_TIMEOUT_MAX` set) or ProxyAgent + * (with proxy) configured with timeouts, or undefined if no proxy, timeout options, lookup, nor + * `N8N_AI_TIMEOUT_MAX` are provided/set (backward compatible behavior). * * @remarks * When timeoutOptions are provided, this function always returns an agent to ensure timeouts are properly configured. * The default undici timeouts (5 minutes) are too short for many AI operations. - * When timeoutOptions are NOT provided, returns undefined if no proxy is configured (backward compatible). + * When timeoutOptions are NOT provided, this still returns an agent if `N8N_AI_TIMEOUT_MAX` is set, + * so the env override isn't silently ignored just because no proxy is configured. Otherwise, returns + * undefined if no proxy is configured (backward compatible). */ export function getProxyAgent( targetUrl?: string, @@ -82,6 +85,9 @@ export function getProxyAgent( if (timeoutOptions) { return new Agent(agentOptions); } + if (process.env.N8N_AI_TIMEOUT_MAX) { + return new Agent(agentOptions); + } return undefined; } 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 6c94b229ffd..7fe7c1841ee 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts @@ -1,6 +1,6 @@ import { ChatGroq } from '@langchain/groq'; import { - getProxyAgent, + getNodeProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getConnectionHintNoticeField, @@ -150,7 +150,7 @@ export class LmChatGroq implements INodeType { maxTokens: options.maxTokensToSample, temperature: options.temperature, callbacks: [new N8nLlmTracing(this)], - httpAgent: getProxyAgent('https://api.groq.com/openai/v1'), + httpAgent: getNodeProxyAgent('https://api.groq.com/openai/v1'), onFailedAttempt: makeN8nLlmFailedAttemptHandler(this), }); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/test/LmChatGroq.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/test/LmChatGroq.test.ts new file mode 100644 index 00000000000..c62ce78542d --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/test/LmChatGroq.test.ts @@ -0,0 +1,112 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/unbound-method */ +import { ChatGroq } from '@langchain/groq'; +import { + getNodeProxyAgent, + getProxyAgent, + makeN8nLlmFailedAttemptHandler, +} from '@n8n/ai-utilities'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; +import type { Mocked } from 'vitest'; + +import { LmChatGroq } from '../LmChatGroq.node'; + +vi.mock('@langchain/groq'); +vi.mock('@n8n/ai-utilities'); + +const MockedChatGroq = vi.mocked(ChatGroq); +const mockedMakeN8nLlmFailedAttemptHandler = vi.mocked(makeN8nLlmFailedAttemptHandler); +const mockedGetNodeProxyAgent = vi.mocked(getNodeProxyAgent); +const mockedGetProxyAgent = vi.mocked(getProxyAgent); + +describe('LmChatGroq', () => { + let node: LmChatGroq; + + const mockNodeDef: INode = { + id: '1', + name: 'Groq Chat Model', + typeVersion: 1, + type: 'n8n-nodes-langchain.lmChatGroq', + position: [0, 0], + parameters: {}, + }; + + const setupMockContext = () => { + const ctx = createMockExecuteFunction( + {}, + mockNodeDef, + ) as Mocked; + + ctx.getCredentials = vi.fn().mockResolvedValue({ apiKey: 'test-groq-key' }); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'llama3-8b-8192'; + if (paramName === 'options') return {}; + return undefined; + }); + + mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(vi.fn()); + mockedGetNodeProxyAgent.mockReturnValue(undefined); + return ctx; + }; + + beforeEach(() => { + node = new LmChatGroq(); + vi.clearAllMocks(); + }); + + describe('supplyData', () => { + // groq-sdk hands `httpAgent` straight to node-fetch's `agent` option, which requires a Node + // http(s).Agent — an undici Agent/ProxyAgent (from getProxyAgent) throws a TypeError there. + it('should build the http agent with getNodeProxyAgent, not getProxyAgent', async () => { + const ctx = setupMockContext(); + const nodeAgent = { fake: 'node-agent' }; + mockedGetNodeProxyAgent.mockReturnValue(nodeAgent as never); + + await node.supplyData.call(ctx, 0); + + expect(mockedGetNodeProxyAgent).toHaveBeenCalledWith('https://api.groq.com/openai/v1'); + expect(mockedGetProxyAgent).not.toHaveBeenCalled(); + expect(MockedChatGroq).toHaveBeenCalledWith( + expect.objectContaining({ httpAgent: nodeAgent }), + ); + }); + + it('should create ChatGroq with credentials and node parameters', async () => { + const ctx = setupMockContext(); + + const result = await node.supplyData.call(ctx, 0); + + expect(ctx.getCredentials).toHaveBeenCalledWith('groqApi'); + expect(MockedChatGroq).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-groq-key', + model: 'llama3-8b-8192', + callbacks: expect.arrayContaining([expect.any(Object)]), + onFailedAttempt: expect.any(Function), + }), + ); + expect(result).toEqual({ response: expect.any(Object) }); + }); + + it('should pass options to ChatGroq', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'llama-3.3-70b-versatile'; + if (paramName === 'options') return { maxTokensToSample: 2048, temperature: 0.3 }; + return undefined; + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatGroq).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'llama-3.3-70b-versatile', + maxTokens: 2048, + temperature: 0.3, + }), + ); + }); + }); +});