🌐 fix: Centralize Outbound Proxy Handling (#13726)

* fix: centralize outbound proxy handling

* chore: sort proxy imports

* test: update proxy helper mocks

* fix: honor proxy bypasses consistently

* fix: support http axios proxy targets
This commit is contained in:
Danny Avila 2026-06-14 10:47:49 -04:00 committed by GitHub
parent 16bbc4b97e
commit 98704f28c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 668 additions and 250 deletions

View file

@ -1,12 +1,14 @@
const path = require('path');
const OpenAI = require('openai');
const { v4: uuidv4 } = require('uuid');
const { ProxyAgent, fetch } = require('undici');
const { fetch } = require('undici');
const { logger } = require('@librechat/data-schemas');
const { Tool } = require('@librechat/agents/langchain/tools');
const {
getImageBasename,
extractBaseURL,
getProxyDispatcher,
getEnvProxyDispatcher,
createMinimalRetentionRequest,
} = require('@librechat/api');
const { FileContext, ContentTypes } = require('librechat-data-provider');
@ -82,10 +84,10 @@ class DALLE3 extends Tool {
config.apiKey = process.env.DALLE3_API_KEY;
}
if (process.env.PROXY) {
const proxyAgent = new ProxyAgent(process.env.PROXY);
const proxyDispatcher = getProxyDispatcher();
if (proxyDispatcher) {
config.fetchOptions = {
dispatcher: proxyAgent,
dispatcher: proxyDispatcher,
};
}
@ -186,9 +188,9 @@ Error Message: ${error.message}`);
if (this.isAgent) {
let fetchOptions = {};
if (process.env.PROXY) {
const proxyAgent = new ProxyAgent(process.env.PROXY);
fetchOptions.dispatcher = proxyAgent;
const dispatcher = getEnvProxyDispatcher();
if (dispatcher) {
fetchOptions.dispatcher = dispatcher;
}
const imageResponse = await fetch(theImageUrl, fetchOptions);
const arrayBuffer = await imageResponse.arrayBuffer();

View file

@ -2,9 +2,12 @@ const axios = require('axios');
const fetch = require('node-fetch');
const { v4: uuidv4 } = require('uuid');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { Tool } = require('@librechat/agents/langchain/tools');
const { createMinimalRetentionRequest } = require('@librechat/api');
const {
applyAxiosProxyConfig,
createMinimalRetentionRequest,
getHttpsProxyAgent,
} = require('@librechat/api');
const { FileContext, ContentTypes } = require('librechat-data-provider');
const fluxApiJsonSchema = {
@ -150,10 +153,7 @@ class FluxAPI extends Tool {
getAxiosConfig() {
const config = {};
if (process.env.PROXY) {
config.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
return config;
return applyAxiosProxyConfig(config, this.baseUrl);
}
/** @param {Object|string} value */
@ -307,8 +307,9 @@ class FluxAPI extends Tool {
try {
// Fetch the image and convert to base64
const fetchOptions = {};
if (process.env.PROXY) {
fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY);
const agent = getHttpsProxyAgent(imageUrl);
if (agent) {
fetchOptions.agent = agent;
}
const imageResponse = await fetch(imageUrl, fetchOptions);
const arrayBuffer = await imageResponse.arrayBuffer();
@ -539,8 +540,9 @@ class FluxAPI extends Tool {
if (this.isAgent) {
try {
const fetchOptions = {};
if (process.env.PROXY) {
fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY);
const agent = getHttpsProxyAgent(imageUrl);
if (agent) {
fetchOptions.agent = agent;
}
const imageResponse = await fetch(imageUrl, fetchOptions);
const arrayBuffer = await imageResponse.arrayBuffer();

View file

@ -1,7 +1,6 @@
const path = require('path');
const sharp = require('sharp');
const { v4 } = require('uuid');
const { ProxyAgent } = require('undici');
const { GoogleGenAI } = require('@google/genai');
const { logger } = require('@librechat/data-schemas');
const { tool } = require('@librechat/agents/langchain/tools');
@ -10,6 +9,7 @@ const {
geminiToolkit,
loadServiceKey,
getBalanceConfig,
getEnvProxyDispatcher,
getTransactionsConfig,
} = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@ -20,14 +20,14 @@ const { spendTokens, getFiles } = require('~/models');
* This wraps globalThis.fetch to add a proxy dispatcher only for googleapis.com URLs
* This is necessary because @google/genai SDK doesn't support custom fetch or httpOptions.dispatcher
*/
if (process.env.PROXY) {
const googleApiProxyDispatcher = getEnvProxyDispatcher();
if (googleApiProxyDispatcher) {
const originalFetch = globalThis.fetch;
const proxyAgent = new ProxyAgent(process.env.PROXY);
globalThis.fetch = function (url, options = {}) {
const urlString = url.toString();
if (urlString.includes('googleapis.com')) {
options = { ...options, dispatcher: proxyAgent };
options = { ...options, dispatcher: googleApiProxyDispatcher };
}
return originalFetch.call(this, url, options);
};

View file

@ -2,12 +2,16 @@ const axios = require('axios');
const { v4 } = require('uuid');
const OpenAI = require('openai');
const FormData = require('form-data');
const { ProxyAgent } = require('undici');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { tool } = require('@librechat/agents/langchain/tools');
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
const { logAxiosError, oaiToolkit, extractBaseURL } = require('@librechat/api');
const {
logAxiosError,
oaiToolkit,
extractBaseURL,
getProxyDispatcher,
applyAxiosProxyConfig,
} = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { getFiles } = require('~/models');
@ -123,10 +127,10 @@ function createOpenAIImageTools(fields = {}) {
throw new Error('Missing required field: prompt');
}
const clientConfig = { ...closureConfig };
if (process.env.PROXY) {
const proxyAgent = new ProxyAgent(process.env.PROXY);
const proxyDispatcher = getProxyDispatcher();
if (proxyDispatcher) {
clientConfig.fetchOptions = {
dispatcher: proxyAgent,
dispatcher: proxyDispatcher,
};
}
@ -233,10 +237,10 @@ Error Message: ${error.message}`);
}
const clientConfig = { ...closureConfig };
if (process.env.PROXY) {
const proxyAgent = new ProxyAgent(process.env.PROXY);
const proxyDispatcher = getProxyDispatcher();
if (proxyDispatcher) {
clientConfig.fetchOptions = {
dispatcher: proxyAgent,
dispatcher: proxyDispatcher,
};
}
@ -349,9 +353,7 @@ Error Message: ${error.message}`);
baseURL,
};
if (process.env.PROXY) {
axiosConfig.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
applyAxiosProxyConfig(axiosConfig, baseURL);
if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {
axiosConfig.params = {

View file

@ -1,6 +1,7 @@
const { z } = require('zod');
const { ProxyAgent, fetch } = require('undici');
const { fetch } = require('undici');
const { tool } = require('@librechat/agents/langchain/tools');
const { getEnvProxyDispatcher } = require('@librechat/api');
const { getApiKey } = require('./credentials');
function createTavilySearchTool(fields = {}) {
@ -28,8 +29,9 @@ function createTavilySearchTool(fields = {}) {
body: JSON.stringify(requestBody),
};
if (process.env.PROXY) {
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
const dispatcher = getEnvProxyDispatcher();
if (dispatcher) {
fetchOptions.dispatcher = dispatcher;
}
const response = await fetch('https://api.tavily.com/search', fetchOptions);

View file

@ -1,6 +1,7 @@
const { ProxyAgent, fetch } = require('undici');
const { fetch } = require('undici');
const { Tool } = require('@librechat/agents/langchain/tools');
const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env');
const { getEnvProxyDispatcher } = require('@librechat/api');
const tavilySearchJsonSchema = {
type: 'object',
@ -120,8 +121,9 @@ class TavilySearchResults extends Tool {
body: JSON.stringify(requestBody),
};
if (process.env.PROXY) {
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
const dispatcher = getEnvProxyDispatcher();
if (dispatcher) {
fetchOptions.dispatcher = dispatcher;
}
const response = await fetch('https://api.tavily.com/search', fetchOptions);

View file

@ -1,7 +1,20 @@
const DALLE3 = require('../DALLE3');
const { ProxyAgent } = require('undici');
const processFileURL = jest.fn();
const proxyEnvKeys = [
'PROXY',
'proxy',
'HTTP_PROXY',
'HTTPS_PROXY',
'NO_PROXY',
'http_proxy',
'https_proxy',
'no_proxy',
];
function clearProxyEnv() {
proxyEnvKeys.forEach((key) => delete process.env[key]);
}
describe('DALLE3 Proxy Configuration', () => {
let originalEnv;
@ -13,13 +26,14 @@ describe('DALLE3 Proxy Configuration', () => {
beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
clearProxyEnv();
});
afterEach(() => {
process.env = originalEnv;
});
it('should configure ProxyAgent in fetchOptions.dispatcher when PROXY env is set', () => {
it('should configure fetchOptions.dispatcher when proxy env is set', () => {
// Set proxy environment variable
process.env.PROXY = 'http://proxy.example.com:8080';
process.env.DALLE_API_KEY = 'test-api-key';
@ -34,12 +48,10 @@ describe('DALLE3 Proxy Configuration', () => {
expect(dalleWithProxy.openai._options).toBeDefined();
expect(dalleWithProxy.openai._options.fetchOptions).toBeDefined();
expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeDefined();
expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent);
expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeDefined();
});
it('should not configure ProxyAgent when PROXY env is not set', () => {
// Ensure PROXY is not set
delete process.env.PROXY;
it('should not configure a dispatcher when proxy env is not set', () => {
process.env.DALLE_API_KEY = 'test-api-key';
// Create instance

View file

@ -1,5 +1,3 @@
const { ProxyAgent } = require('undici');
/**
* These tests verify the proxy wrapper behavior for GeminiImageGen.
* Instead of loading the full module (which has many dependencies),
@ -29,14 +27,14 @@ describe('GeminiImageGen Proxy Configuration', () => {
* This is the same logic from GeminiImageGen.js lines 30-42.
*/
function applyProxyWrapper() {
if (process.env.PROXY) {
const proxyDispatcher = process.env.PROXY ? { type: 'proxy-dispatcher' } : undefined;
if (proxyDispatcher) {
const _originalFetch = globalThis.fetch;
const proxyAgent = new ProxyAgent(process.env.PROXY);
globalThis.fetch = function (url, options = {}) {
const urlString = url.toString();
if (urlString.includes('googleapis.com')) {
options = { ...options, dispatcher: proxyAgent };
options = { ...options, dispatcher: proxyDispatcher };
}
return _originalFetch.call(this, url, options);
};
@ -78,7 +76,7 @@ describe('GeminiImageGen Proxy Configuration', () => {
await globalThis.fetch('https://generativelanguage.googleapis.com/v1/models', {});
expect(capturedOptions).toBeDefined();
expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent);
expect(capturedOptions.dispatcher).toEqual({ type: 'proxy-dispatcher' });
});
it('should not add dispatcher to non-googleapis.com URLs', async () => {
@ -118,7 +116,7 @@ describe('GeminiImageGen Proxy Configuration', () => {
});
expect(capturedOptions).toBeDefined();
expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent);
expect(capturedOptions.dispatcher).toEqual({ type: 'proxy-dispatcher' });
expect(capturedOptions.headers).toEqual(customHeaders);
expect(capturedOptions.method).toBe('POST');
});

View file

@ -1,7 +1,11 @@
const { fetch, ProxyAgent } = require('undici');
const { fetch } = require('undici');
const TavilySearchResults = require('../TavilySearchResults');
const { getEnvProxyDispatcher } = require('@librechat/api');
jest.mock('undici');
jest.mock('@librechat/api', () => ({
getEnvProxyDispatcher: jest.fn(),
}));
describe('TavilySearchResults', () => {
let originalEnv;
@ -46,32 +50,29 @@ describe('TavilySearchResults', () => {
fetch.mockResolvedValue(mockResponse);
});
it('should use ProxyAgent when PROXY env var is set', async () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.PROXY = proxyUrl;
const mockProxyAgent = { type: 'proxy-agent' };
ProxyAgent.mockImplementation(() => mockProxyAgent);
it('should use a shared proxy dispatcher when configured', async () => {
const mockProxyDispatcher = { type: 'proxy-dispatcher' };
getEnvProxyDispatcher.mockReturnValue(mockProxyDispatcher);
const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
await instance._call({ query: 'test query' });
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
expect(getEnvProxyDispatcher).toHaveBeenCalled();
expect(fetch).toHaveBeenCalledWith(
'https://api.tavily.com/search',
expect.objectContaining({
dispatcher: mockProxyAgent,
dispatcher: mockProxyDispatcher,
}),
);
});
it('should not use ProxyAgent when PROXY env var is not set', async () => {
delete process.env.PROXY;
it('should not attach a dispatcher when no proxy is configured', async () => {
getEnvProxyDispatcher.mockReturnValue(undefined);
const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
await instance._call({ query: 'test query' });
expect(ProxyAgent).not.toHaveBeenCalled();
expect(getEnvProxyDispatcher).toHaveBeenCalled();
expect(fetch).toHaveBeenCalledWith(
'https://api.tavily.com/search',
expect.not.objectContaining({

View file

@ -1,6 +1,5 @@
const OpenAI = require('openai');
const { ProxyAgent } = require('undici');
const { isUserProvided, checkUserKeyExpiry } = require('@librechat/api');
const { isUserProvided, checkUserKeyExpiry, getProxyDispatcher } = require('@librechat/api');
const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
const { getUserKeyValues, getUserKeyExpiry } = require('~/models');
@ -45,10 +44,10 @@ const initializeClient = async ({ req, res, version }) => {
opts.baseURL = baseURL;
}
if (PROXY) {
const proxyAgent = new ProxyAgent(PROXY);
const proxyDispatcher = getProxyDispatcher(PROXY);
if (proxyDispatcher) {
opts.fetchOptions = {
dispatcher: proxyAgent,
dispatcher: proxyDispatcher,
};
}

View file

@ -1,10 +1,10 @@
const OpenAI = require('openai');
const { ProxyAgent } = require('undici');
const {
isUserProvided,
resolveHeaders,
constructAzureURL,
checkUserKeyExpiry,
getProxyDispatcher,
} = require('@librechat/api');
const { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } = require('librechat-data-provider');
const { getUserKeyValues, getUserKeyExpiry } = require('~/models');
@ -157,10 +157,10 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
opts.baseURL = baseURL;
}
if (PROXY) {
const proxyAgent = new ProxyAgent(PROXY);
const proxyDispatcher = getProxyDispatcher(PROXY);
if (proxyDispatcher) {
opts.fetchOptions = {
dispatcher: proxyAgent,
dispatcher: proxyDispatcher,
};
}

View file

@ -3,8 +3,7 @@ const fs = require('fs').promises;
const FormData = require('form-data');
const { Readable } = require('stream');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { genAzureEndpoint, logAxiosError } = require('@librechat/api');
const { genAzureEndpoint, logAxiosError, applyAxiosProxyConfig } = require('@librechat/api');
const { extractEnvVariable, STTProviders } = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config');
@ -303,9 +302,7 @@ class STTService {
const options = { headers };
if (process.env.PROXY) {
options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
applyAxiosProxyConfig(options, url);
try {
const response = await axios.post(url, data, options);

View file

@ -1,7 +1,6 @@
const axios = require('axios');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { genAzureEndpoint, logAxiosError } = require('@librechat/api');
const { genAzureEndpoint, logAxiosError, applyAxiosProxyConfig } = require('@librechat/api');
const { extractEnvVariable, TTSProviders } = require('librechat-data-provider');
const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio');
const { getAppConfig } = require('~/server/services/Config');
@ -267,9 +266,7 @@ class TTSService {
const options = { headers, responseType: stream ? 'stream' : 'arraybuffer' };
if (process.env.PROXY) {
options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
applyAxiosProxyConfig(options, url);
try {
return await axios.post(url, data, options);

View file

@ -1,7 +1,6 @@
const cookies = require('cookie');
const jwksRsa = require('jwks-rsa');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { SystemRoles } = require('librechat-data-provider');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const {
@ -10,6 +9,7 @@ const {
getOpenIdEmail,
getOpenIdIssuer,
normalizeOpenIdIssuer,
getHttpsProxyAgent,
math,
} = require('@librechat/api');
const { updateUser, findUser } = require('~/models');
@ -73,8 +73,9 @@ const openIdJwtLogin = (openIdConfig) => {
jwksUri: openIdConfig.serverMetadata().jwks_uri,
};
if (process.env.PROXY) {
jwksRsaOptions.requestAgent = new HttpsProxyAgent(process.env.PROXY);
const requestAgent = getHttpsProxyAgent(jwksRsaOptions.jwksUri);
if (requestAgent) {
jwksRsaOptions.requestAgent = requestAgent;
}
return new JwtStrategy(

View file

@ -28,6 +28,7 @@ jest.mock('@librechat/api', () => ({
getOpenIdEmail: jest.requireActual('@librechat/api').getOpenIdEmail,
getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
normalizeOpenIdIssuer: jest.requireActual('@librechat/api').normalizeOpenIdIssuer,
getHttpsProxyAgent: jest.fn(() => undefined),
math: jest.fn((val, fallback) => fallback),
}));
jest.mock('~/models', () => ({

View file

@ -64,7 +64,7 @@ async function customFetch(url, options) {
let fetchOptions = options;
const dispatcher = getOpenIdProxyDispatcher();
if (dispatcher) {
logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`);
logger.info('[openidStrategy] proxy dispatcher configured');
fetchOptions = {
...options,
dispatcher,

View file

@ -25,6 +25,8 @@ jest.mock('@librechat/api', () => ({
},
},
extractBaseURL: jest.fn((url) => url),
getProxyDispatcher: jest.fn(() => undefined),
applyAxiosProxyConfig: jest.fn(),
}));
jest.mock('~/server/services/Files/strategies', () => ({