fix(core): Pair proxied AI fetches with their own undici (no-changelog) (#36318)

This commit is contained in:
Iván Ovejero 2026-08-14 13:51:51 +00:00 committed by GitHub
parent b671cf4276
commit a0acb37ed2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 38 additions and 27 deletions

View File

@ -1,4 +1,4 @@
import { Agent, ProxyAgent } from 'undici';
import { Agent, ProxyAgent, fetch as undiciFetch } from 'undici';
import type { MockedFunction } from 'vitest';
import { getNodeProxyAgent, getProxyAgent, proxyFetch } from 'src/utils/http-proxy-agent';
@ -11,11 +11,9 @@ vi.mock('undici', () => ({
ProxyAgent: vi.fn(function (options) {
return { type: 'ProxyAgent', options };
}),
fetch: vi.fn(),
}));
// Mock global fetch
global.fetch = vi.fn();
describe('getProxyAgent', () => {
// Store original environment variables
const originalEnv = { ...process.env };
@ -308,7 +306,7 @@ describe('getProxyAgent', () => {
describe('proxyFetch', () => {
// Store original environment variables
const originalEnv = { ...process.env };
const mockFetch = global.fetch as MockedFunction<typeof fetch>;
const mockFetch = undiciFetch as unknown as MockedFunction<typeof fetch>;
// Reset environment variables and mocks before each test
beforeEach(() => {

View File

@ -21,7 +21,7 @@ import { createHttpsProxyAgent, resolveProxyUrl } from '@n8n/backend-network/pro
import type { AgentOptions } from 'node:https';
import type { LookupFunction } from 'node:net';
/* eslint-disable n8n-local-rules/no-uncentralized-http -- langchain consumers pin undici v6, incompatible with backend-network's v7 dispatchers; see block comment below */
import { Agent, ProxyAgent } from 'undici';
import { Agent, ProxyAgent, fetch as undiciFetch } from 'undici';
/**
* Options for configuring HTTP agent timeouts.
@ -113,11 +113,12 @@ export async function proxyFetch(
const targetUrl = input instanceof Request ? input.url : input.toString();
const dispatcher = getProxyAgent(targetUrl, timeoutOptions, lookup);
return await fetch(input, {
...init,
// @ts-expect-error - dispatcher is an undici-specific option not in standard fetch
// The dispatcher comes from this package's undici, so the request must use
// the same undici's fetch: the global fetch on Node >= 26 rejects it.
return (await undiciFetch(input as Parameters<typeof undiciFetch>[0], {
...(init as Parameters<typeof undiciFetch>[1]),
dispatcher,
});
})) as unknown as Response;
}
/**

View File

@ -1,8 +1,13 @@
import { proxyFetch } from '@n8n/ai-utilities';
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { searchModels } from '../searchModels';
vi.mock('@n8n/ai-utilities', () => ({
proxyFetch: vi.fn(),
}));
const mockModels = [
{
id: 'claude-3-opus-20240229',
@ -51,11 +56,12 @@ describe('searchModels', () => {
json: async () => ({ data: mockModels }),
text: async () => '',
});
vi.stubGlobal('fetch', fetchSpy);
vi.mocked(proxyFetch).mockImplementation(
fetchSpy as unknown as typeof import('@n8n/ai-utilities')['proxyFetch'],
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

View File

@ -1,4 +1,4 @@
import { getProxyAgent } from '@n8n/ai-utilities';
import { proxyFetch } from '@n8n/ai-utilities';
import { listAnthropicModels } from '@n8n/ai-utilities/model-discovery';
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
@ -19,11 +19,7 @@ export async function searchModels(
apiKey: (credentials.apiKey as string) ?? '',
baseURL,
headers: mergeCustomHeaders(credentials, {}),
fetch: async (url, init) =>
await fetch(url, {
...init,
dispatcher: getProxyAgent(baseURL),
} as RequestInit),
fetch: proxyFetch,
});
return {

View File

@ -1,8 +1,13 @@
import { proxyFetch } from '@n8n/ai-utilities';
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { searchModels } from '../loadModels';
vi.mock('@n8n/ai-utilities', () => ({
proxyFetch: vi.fn(),
}));
const MODEL_IDS = [
'gpt-4',
'gpt-3.5-turbo',
@ -43,7 +48,9 @@ describe('searchModels', () => {
json: async () => ({ data: MODEL_IDS.map((id) => ({ id })) }),
text: async () => '',
});
vi.stubGlobal('fetch', fetchSpy);
vi.mocked(proxyFetch).mockImplementation(
fetchSpy as unknown as typeof import('@n8n/ai-utilities')['proxyFetch'],
);
});
afterEach(() => {

View File

@ -1,4 +1,4 @@
import { getProxyAgent } from '@n8n/ai-utilities';
import { proxyFetch } from '@n8n/ai-utilities';
import { listOpenAiModels } from '@n8n/ai-utilities/model-discovery';
import { AiConfig } from '@n8n/config';
import { Container } from '@n8n/di';
@ -24,11 +24,7 @@ export async function searchModels(
apiKey: credentials.apiKey as string,
baseURL,
headers,
fetch: async (url, init) =>
await fetch(url, {
...init,
dispatcher: getProxyAgent(baseURL),
} as RequestInit),
fetch: proxyFetch,
});
return {

View File

@ -1,7 +1,12 @@
/* 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 {
N8nLlmTracing,
makeN8nLlmFailedAttemptHandler,
getProxyAgent,
proxyFetch,
} from '@n8n/ai-utilities';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import type { ILoadOptionsFunctions, INode, ISupplyDataFunctions } from 'n8n-workflow';
import type { Mock, Mocked } from 'vitest';
@ -425,7 +430,9 @@ describe('LmChatAnthropic', () => {
beforeEach(() => {
mockGetCredentials = vi.fn();
fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
vi.mocked(proxyFetch).mockImplementation(
fetchSpy as unknown as typeof import('@n8n/ai-utilities')['proxyFetch'],
);
mockLoadContext = {
getCredentials: mockGetCredentials,