mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
fix(core): Apply AI request timeout even without a proxy (#34992)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
298a941af6
commit
ab3ed79fa5
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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<ISupplyDataFunctions>(
|
||||
{},
|
||||
mockNodeDef,
|
||||
) as Mocked<ISupplyDataFunctions>;
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user