🌐 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

@ -179,9 +179,13 @@ NODE_MAX_OLD_SPACE_SIZE=6144
# ENDPOINTS=openAI,assistants,azureOpenAI,google,anthropic
# Optional outbound proxy for server-side requests, including remote MCP HTTP/SSE transports.
# Remote MCP transports also honor HTTP_PROXY, HTTPS_PROXY, and NO_PROXY when PROXY is unset.
# Optional outbound proxy for server-side requests.
# PROXY applies to both HTTP and HTTPS targets. When PROXY is unset, LibreChat honors
# HTTP_PROXY, HTTPS_PROXY, and NO_PROXY/no_proxy for supported server-side clients.
PROXY=
# HTTP_PROXY=
# HTTPS_PROXY=
# NO_PROXY=
#===================================#
# Known Endpoints - librechat.yaml #

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', () => ({

View file

@ -23,6 +23,13 @@ services:
- MEILI_HOST=http://meilisearch:7700
- RAG_PORT=${RAG_PORT:-8000}
- RAG_API_URL=http://rag_api:${RAG_PORT:-8000}
- PROXY=${PROXY:-}
- HTTP_PROXY=${HTTP_PROXY:-}
- HTTPS_PROXY=${HTTPS_PROXY:-}
- NO_PROXY=${NO_PROXY:-localhost,127.0.0.1,::1},${no_proxy:-},mongodb,chat-mongodb,meilisearch,chat-meilisearch,rag_api,vectordb,host.docker.internal
- http_proxy=${http_proxy:-}
- https_proxy=${https_proxy:-}
- no_proxy=${no_proxy:-localhost,127.0.0.1,::1},${NO_PROXY:-},mongodb,chat-mongodb,meilisearch,chat-meilisearch,rag_api,vectordb,host.docker.internal
volumes:
- type: bind
source: ./librechat.yaml

View file

@ -20,6 +20,13 @@ services:
- MEILI_HOST=http://meilisearch:7700
- RAG_PORT=${RAG_PORT:-8000}
- RAG_API_URL=http://rag_api:${RAG_PORT:-8000}
- PROXY=${PROXY:-}
- HTTP_PROXY=${HTTP_PROXY:-}
- HTTPS_PROXY=${HTTPS_PROXY:-}
- NO_PROXY=${NO_PROXY:-localhost,127.0.0.1,::1},${no_proxy:-},mongodb,chat-mongodb,meilisearch,chat-meilisearch,rag_api,vectordb,host.docker.internal
- http_proxy=${http_proxy:-}
- https_proxy=${https_proxy:-}
- no_proxy=${no_proxy:-localhost,127.0.0.1,::1},${NO_PROXY:-},mongodb,chat-mongodb,meilisearch,chat-meilisearch,rag_api,vectordb,host.docker.internal
volumes:
- type: bind
source: ./.env

View file

@ -1,83 +0,0 @@
import { EnvHttpProxyAgent } from 'undici';
import { getOpenIdProxyDispatcher } from './proxy';
jest.mock('undici', () => ({
EnvHttpProxyAgent: jest.fn(),
}));
const MockEnvHttpProxyAgent = EnvHttpProxyAgent as jest.MockedClass<typeof EnvHttpProxyAgent>;
describe('getOpenIdProxyDispatcher', () => {
beforeEach(() => {
MockEnvHttpProxyAgent.mockClear();
delete process.env.PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
});
afterAll(() => {
delete process.env.PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
});
it('returns undefined when PROXY is not set', () => {
expect(getOpenIdProxyDispatcher()).toBeUndefined();
expect(MockEnvHttpProxyAgent).not.toHaveBeenCalled();
});
it('creates a NO_PROXY-aware agent for both protocols when PROXY is set', () => {
process.env.PROXY = 'http://corporate-proxy-create:8080';
const dispatcher = getOpenIdProxyDispatcher();
expect(dispatcher).toBeInstanceOf(EnvHttpProxyAgent);
expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({
httpProxy: 'http://corporate-proxy-create:8080',
httpsProxy: 'http://corporate-proxy-create:8080',
});
});
it('reuses the same agent across calls', () => {
process.env.PROXY = 'http://corporate-proxy-reuse:8080';
process.env.NO_PROXY = 'localhost,.internal-domain.com';
const first = getOpenIdProxyDispatcher();
const second = getOpenIdProxyDispatcher();
expect(second).toBe(first);
expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(1);
});
it('rebuilds the agent when NO_PROXY changes', () => {
process.env.PROXY = 'http://corporate-proxy-no-proxy:8080';
process.env.NO_PROXY = 'localhost';
getOpenIdProxyDispatcher();
process.env.NO_PROXY = 'localhost,.internal-domain.com';
getOpenIdProxyDispatcher();
expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(2);
});
it('rebuilds the agent when lowercase no_proxy changes', () => {
process.env.PROXY = 'http://corporate-proxy-lowercase:8080';
process.env.no_proxy = 'localhost';
getOpenIdProxyDispatcher();
process.env.no_proxy = 'localhost,.internal-domain.com';
getOpenIdProxyDispatcher();
expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(2);
});
it('rebuilds the agent when PROXY changes', () => {
process.env.PROXY = 'http://corporate-proxy-change:8080';
getOpenIdProxyDispatcher();
process.env.PROXY = 'http://other-proxy:3128';
getOpenIdProxyDispatcher();
expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(2);
});
});

View file

@ -1,19 +1 @@
import { EnvHttpProxyAgent } from 'undici';
import type { Dispatcher } from 'undici';
let proxyDispatcher: EnvHttpProxyAgent | undefined;
let proxyDispatcherKey: string | undefined;
export function getOpenIdProxyDispatcher(): Dispatcher | undefined {
const proxy = process.env.PROXY;
if (!proxy) return undefined;
const noProxy = process.env.no_proxy ?? process.env.NO_PROXY ?? '';
const key = `${proxy}|${noProxy}`;
if (!proxyDispatcher || proxyDispatcherKey !== key) {
proxyDispatcher = new EnvHttpProxyAgent({ httpProxy: proxy, httpsProxy: proxy });
proxyDispatcherKey = key;
}
return proxyDispatcher;
}
export { getOpenIdProxyDispatcher } from '~/utils/proxy';

View file

@ -1,4 +1,3 @@
import { Dispatcher, ProxyAgent } from 'undici';
import { logger } from '@librechat/data-schemas';
import { AnthropicClientOptions } from '@librechat/agents';
import {
@ -8,6 +7,7 @@ import {
ThinkingDisplay,
AuthKeys,
} from 'librechat-data-provider';
import type { Dispatcher } from 'undici';
import type {
AnthropicLLMConfigResult,
AnthropicConfigOptions,
@ -26,6 +26,7 @@ import {
isAnthropicVertexCredentials,
getVertexDeploymentName,
} from './vertex';
import { getProxyDispatcher } from '~/utils/proxy';
const WEB_SEARCH_BETA = 'web-search-2025-03-05';
@ -227,10 +228,10 @@ function getLLMConfig(
requestOptions.clientOptions.defaultHeaders = headers;
}
if (options.proxy && requestOptions.clientOptions) {
const proxyAgent = new ProxyAgent(options.proxy);
const proxyDispatcher = getProxyDispatcher(options.proxy);
if (proxyDispatcher && requestOptions.clientOptions) {
requestOptions.clientOptions.fetchOptions = {
dispatcher: proxyAgent,
dispatcher: proxyDispatcher,
};
}

View file

@ -1,12 +1,12 @@
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import {
AuthType,
EModelEndpoint,
BEDROCK_OUTPUT_128K_BETA,
BEDROCK_FINE_GRAINED_TOOL_STREAMING_BETA,
} from 'librechat-data-provider';
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { initializeBedrock } from './initialize';
import type { BaseInitializeParams, BedrockLLMConfigResult } from '~/types';
import { initializeBedrock } from './initialize';
import { checkUserKeyExpiry } from '~/utils';
jest.mock('https-proxy-agent', () => ({
@ -80,6 +80,13 @@ describe('initializeBedrock', () => {
delete process.env.BEDROCK_AWS_SESSION_TOKEN;
delete process.env.BEDROCK_REVERSE_PROXY;
delete process.env.PROXY;
delete process.env.proxy;
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.NO_PROXY;
delete process.env.http_proxy;
delete process.env.https_proxy;
delete process.env.no_proxy;
process.env.BEDROCK_AWS_ACCESS_KEY_ID = 'test-access-key';
process.env.BEDROCK_AWS_SECRET_ACCESS_KEY = 'test-secret-key';
process.env.BEDROCK_AWS_DEFAULT_REGION = 'us-east-1';
@ -397,6 +404,17 @@ describe('initializeBedrock', () => {
);
});
it('should honor NO_PROXY for reverse proxy endpoints when PROXY is set', async () => {
process.env.PROXY = 'http://proxy:8080';
process.env.NO_PROXY = 'custom-bedrock-endpoint.com';
process.env.BEDROCK_REVERSE_PROXY = 'custom-bedrock-endpoint.com';
const params = createMockParams();
const result = (await initializeBedrock(params)) as BedrockLLMConfigResult;
expect(result.llmConfig).not.toHaveProperty('client');
expect(result.llmConfig).toHaveProperty('endpointHost', 'custom-bedrock-endpoint.com');
});
it('should use AWS profile provider when PROXY is set and static credentials are unset', async () => {
delete process.env.BEDROCK_AWS_ACCESS_KEY_ID;
delete process.env.BEDROCK_AWS_SECRET_ACCESS_KEY;

View file

@ -1,8 +1,6 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
import type { BedrockRuntimeClientConfig } from '@aws-sdk/client-bedrock-runtime';
import {
AuthType,
EModelEndpoint,
@ -11,6 +9,7 @@ import {
bedrockOutputParser,
removeNullishValues,
} from 'librechat-data-provider';
import type { BedrockRuntimeClientConfig } from '@aws-sdk/client-bedrock-runtime';
import type {
BaseInitializeParams,
InitializeResultBase,
@ -18,6 +17,7 @@ import type {
GuardrailConfiguration,
InferenceProfileConfig,
} from '~/types';
import { getHttpsProxyAgent } from '~/utils/proxy';
import { checkUserKeyExpiry } from '~/utils';
const BEDROCK_CREDENTIALS_ERROR = 'Bedrock credentials not provided. Please provide them again.';
@ -28,6 +28,16 @@ type ParsedBedrockUserCredentials = Partial<Record<UserCredentialKey, UserCreden
apiKey?: string;
};
function getBedrockProxyTarget(region?: string, reverseProxy?: string): string | undefined {
const trimmedReverseProxy = reverseProxy?.trim();
if (trimmedReverseProxy) return `https://${trimmedReverseProxy}`;
const trimmedRegion = region?.trim();
if (!trimmedRegion) return undefined;
return `https://bedrock-runtime.${trimmedRegion}.amazonaws.com`;
}
function isParsedBedrockUserCredentials(value: unknown): value is ParsedBedrockUserCredentials {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
@ -77,8 +87,8 @@ function getUserCredentialValue(
* HTTP/HTTPS proxies and reverse proxies.
*
* Proxy Support:
* - When the PROXY environment variable is set, creates a custom BedrockRuntimeClient
* with an HttpsProxyAgent to route all Bedrock API calls through the specified proxy
* - When proxy env vars are set, creates a custom BedrockRuntimeClient
* with an HttpsProxyAgent to route Bedrock API calls through the resolved proxy
* - The custom client is fully configured with credentials, region, and endpoint,
* and is passed directly to ChatBedrockConverse via the 'client' parameter
*
@ -117,7 +127,6 @@ export async function initializeBedrock({
BEDROCK_AWS_BEARER_TOKEN,
BEDROCK_REVERSE_PROXY,
BEDROCK_AWS_DEFAULT_REGION,
PROXY,
} = process.env;
const { key: expiresAt } = req.body;
@ -255,8 +264,11 @@ export async function initializeBedrock({
credentials.secretAccessKey !== '';
const hasBearerToken = typeof bearerToken === 'string' && bearerToken !== '';
if (PROXY || hasBearerToken) {
const proxyAgent = PROXY ? new HttpsProxyAgent(PROXY) : undefined;
const bedrockRegion = typeof llmConfig.region === 'string' ? llmConfig.region : undefined;
const proxyAgent = getHttpsProxyAgent(
getBedrockProxyTarget(bedrockRegion, BEDROCK_REVERSE_PROXY),
);
if (proxyAgent || hasBearerToken) {
const credentialProvider =
!hasCompleteCredentials && !hasBearerToken && BEDROCK_AWS_PROFILE
? fromNodeProviderChain({ profile: BEDROCK_AWS_PROFILE })

View file

@ -1,7 +1,6 @@
import axios from 'axios';
import crypto from 'crypto';
import { logger } from '@librechat/data-schemas';
import { HttpsProxyAgent } from 'https-proxy-agent';
import {
Time,
CacheKeys,
@ -10,6 +9,7 @@ import {
defaultModels,
} from 'librechat-data-provider';
import type { IUser } from '@librechat/data-schemas';
import type { AxiosRequestConfig } from 'axios';
import {
processModelData,
extractBaseURL,
@ -18,6 +18,7 @@ import {
deriveBaseURL,
logAxiosError,
inputSchema,
applyAxiosProxyConfig,
} from '~/utils';
import { standardCache, tokenConfigCache } from '~/cache';
@ -173,10 +174,9 @@ export async function fetchModels({
user: userObject,
});
const options: {
const options: AxiosRequestConfig & {
headers: Record<string, string>;
timeout: number;
httpsAgent?: HttpsProxyAgent<string>;
} = {
headers: {
...resolvedHeaders,
@ -202,10 +202,6 @@ export async function fetchModels({
}
}
if (process.env.PROXY) {
options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
if (process.env.OPENAI_ORGANIZATION && baseURL?.includes('openai')) {
options.headers['OpenAI-Organization'] = process.env.OPENAI_ORGANIZATION;
}
@ -214,6 +210,7 @@ export async function fetchModels({
if (user && userIdQuery) {
url.searchParams.append('user', user);
}
applyAxiosProxyConfig(options, url);
const res = await axios.get(url.toString(), options);
const input = res.data;

View file

@ -1,4 +1,3 @@
import { ProxyAgent } from 'undici';
import { Providers } from '@librechat/agents';
import { KnownEndpoints, EModelEndpoint, ReasoningParameterFormat } from 'librechat-data-provider';
import type * as t from '~/types';
@ -6,6 +5,7 @@ import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm
import { getOpenAILLMConfig, extractDefaultParams } from './llm';
import { getGoogleConfig } from '~/endpoints/google/llm';
import { transformToOpenAIConfig } from './transform';
import { getProxyDispatcher } from '~/utils/proxy';
import { constructAzureURL } from '~/utils/azure';
import { createFetch } from '~/utils/generators';
@ -207,10 +207,10 @@ export function getOpenAIConfig(
configOptions.defaultQuery = defaultQuery;
}
if (proxy) {
const proxyAgent = new ProxyAgent(proxy);
const proxyDispatcher = getProxyDispatcher(proxy);
if (proxyDispatcher) {
configOptions.fetchOptions = {
dispatcher: proxyAgent,
dispatcher: proxyDispatcher,
};
}

View file

@ -1721,10 +1721,16 @@ describe('MistralOCR Service', () => {
describe('Proxy Configuration', () => {
const originalProxy = process.env.PROXY;
const originalHttpProxy = process.env.HTTP_PROXY;
const originalHttpsProxy = process.env.HTTPS_PROXY;
const originalNoProxy = process.env.NO_PROXY;
beforeEach(() => {
// Reset the HttpsProxyAgent mock to its default implementation
(HttpsProxyAgent as unknown as jest.Mock).mockImplementation((url) => ({ proxyUrl: url }));
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.NO_PROXY;
// Clear any previous axios mock calls
mockAxios.post!.mockClear();
mockAxios.get!.mockClear();
@ -1737,6 +1743,21 @@ describe('MistralOCR Service', () => {
} else {
delete process.env.PROXY;
}
if (originalHttpProxy) {
process.env.HTTP_PROXY = originalHttpProxy;
} else {
delete process.env.HTTP_PROXY;
}
if (originalHttpsProxy) {
process.env.HTTPS_PROXY = originalHttpsProxy;
} else {
delete process.env.HTTPS_PROXY;
}
if (originalNoProxy) {
process.env.NO_PROXY = originalNoProxy;
} else {
delete process.env.NO_PROXY;
}
// Clear mocks after each test to prevent leaking
mockAxios.post!.mockClear();
mockAxios.get!.mockClear();

View file

@ -2,7 +2,6 @@ import * as fs from 'fs';
import * as path from 'path';
import FormData from 'form-data';
import { logger } from '@librechat/data-schemas';
import { HttpsProxyAgent } from 'https-proxy-agent';
import {
FileSources,
envVarRegex,
@ -22,6 +21,7 @@ import type {
OCRImage,
} from '~/types';
import { logAxiosError, createAxiosInstance } from '~/utils/axios';
import { applyAxiosProxyConfig } from '~/utils/proxy';
import { readFileAsBuffer } from '~/utils/files';
import { loadServiceKey } from '~/utils/key';
@ -88,9 +88,7 @@ export async function uploadDocumentToMistral({
maxContentLength: Infinity,
};
if (process.env.PROXY) {
config.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
applyAxiosProxyConfig(config, `${baseURL}/files`);
return axios
.post(`${baseURL}/files`, form, config)
@ -117,9 +115,7 @@ export async function getSignedUrl({
},
};
if (process.env.PROXY) {
config.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
applyAxiosProxyConfig(config, `${baseURL}/files/${fileId}/url?expiry=${expiry}`);
return axios
.get(`${baseURL}/files/${fileId}/url?expiry=${expiry}`, config)
@ -161,11 +157,8 @@ export async function performOCR({
},
};
if (process.env.PROXY) {
config.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
const ocrURL = baseURL.endsWith('/ocr') ? baseURL : `${baseURL}/ocr`;
applyAxiosProxyConfig(config, ocrURL);
return axios
.post(
@ -211,9 +204,7 @@ export async function deleteMistralFile({
},
};
if (process.env.PROXY) {
config.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
applyAxiosProxyConfig(config, `${baseURL}/files/${fileId}`);
try {
const result = await axios.delete(`${baseURL}/files/${fileId}`, config);
@ -580,9 +571,7 @@ async function exchangeJWTForAccessToken(jwt: string): Promise<string> {
},
};
if (process.env.PROXY) {
config.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
applyAxiosProxyConfig(config, 'https://oauth2.googleapis.com/token');
const response = await axios.post(
'https://oauth2.googleapis.com/token',
@ -653,9 +642,7 @@ async function performGoogleVertexOCR({
},
};
if (process.env.PROXY) {
config.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
applyAxiosProxyConfig(config, baseURL);
return axios
.post(baseURL, requestBody, config)

View file

@ -23,6 +23,11 @@ jest.mock('~/utils', () => ({
math: jest.fn(() => 60000),
}));
jest.mock('~/utils/proxy', () => ({
getEnvProxyDispatcher: jest.fn(),
getHttpsProxyAgent: jest.fn(),
}));
const mockGetSigningKey = jest.fn();
const mockGetSigningKeys = jest.fn();
@ -32,7 +37,6 @@ jest.mock('jwks-rsa', () =>
jest.mock('undici', () => ({
fetch: jest.fn(),
ProxyAgent: jest.fn((proxy: string) => ({ proxy })),
}));
jest.mock('jsonwebtoken', () => ({
@ -48,16 +52,18 @@ jest.mock('../auth/openid', () => {
import jwt from 'jsonwebtoken';
import jwksRsa from 'jwks-rsa';
import { SystemRoles } from 'librechat-data-provider';
import { ProxyAgent, fetch as undiciFetch } from 'undici';
import { fetch as undiciFetch } from 'undici';
import { logger, tenantStorage } from '@librechat/data-schemas';
import { clearRemoteAgentAuthCache, createRemoteAgentAuth } from './remoteAgentAuth';
import { findOpenIDUser, getOpenIdEmail } from '../auth/openid';
import { isEnabled, math } from '~/utils';
import { getEnvProxyDispatcher, getHttpsProxyAgent } from '~/utils/proxy';
const mockFetch = undiciFetch as jest.Mock;
const mockProxyAgent = ProxyAgent as unknown as jest.Mock;
const mockMath = math as jest.Mock;
const mockIsEnabled = isEnabled as jest.Mock;
const mockGetEnvProxyDispatcher = getEnvProxyDispatcher as jest.Mock;
const mockGetHttpsProxyAgent = getHttpsProxyAgent as jest.Mock;
const realFindOpenIDUser =
jest.requireActual<typeof import('../auth/openid')>('../auth/openid').findOpenIDUser;
const mockFindOpenIDUser = findOpenIDUser as jest.MockedFunction<typeof findOpenIDUser>;
@ -252,6 +258,8 @@ describe('createRemoteAgentAuth', () => {
mockFetch.mockReset();
mockMath.mockReturnValue(60000);
mockIsEnabled.mockImplementation((value?: string) => value === 'true');
mockGetEnvProxyDispatcher.mockReturnValue(undefined);
mockGetHttpsProxyAgent.mockReturnValue(undefined);
mockFindOpenIDUser.mockImplementation(realFindOpenIDUser);
mockNext = jest.fn();
});
@ -1091,8 +1099,9 @@ describe('createRemoteAgentAuth', () => {
expect(mockNext).not.toHaveBeenCalled();
});
it('uses a proxy agent for discovery when PROXY is set', async () => {
process.env.PROXY = 'http://proxy.example.com';
it('uses a proxy dispatcher for discovery when configured', async () => {
const proxyDispatcher = { dispatch: jest.fn() };
mockGetEnvProxyDispatcher.mockReturnValue(proxyDispatcher);
const issuer = 'https://issuer-proxy.example.com';
mockFetch.mockResolvedValue({
@ -1108,10 +1117,9 @@ describe('createRemoteAgentAuth', () => {
mockNext,
);
expect(mockProxyAgent).toHaveBeenCalledWith('http://proxy.example.com');
expect(mockFetch).toHaveBeenCalledWith(
`${issuer}/.well-known/openid-configuration`,
expect.objectContaining({ dispatcher: { proxy: 'http://proxy.example.com' } }),
expect.objectContaining({ dispatcher: proxyDispatcher }),
);
});

View file

@ -1,22 +1,22 @@
import jwt from 'jsonwebtoken';
import jwksRsa from 'jwks-rsa';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { ProxyAgent, fetch as undiciFetch } from 'undici';
import { fetch as undiciFetch } from 'undici';
import { getTenantId, logger, tenantStorage } from '@librechat/data-schemas';
import { SystemRoles, isRemoteOidcUrlAllowed } from 'librechat-data-provider';
import type { RequestHandler, Request, Response, NextFunction } from 'express';
import type { AppConfig, IUser, RoleMethods, UserMethods } from '@librechat/data-schemas';
import type { RequestHandler, Request, Response, NextFunction } from 'express';
import type { Algorithm, JwtPayload, VerifyOptions } from 'jsonwebtoken';
import type { TAgentsEndpoint } from 'librechat-data-provider';
import type { RequestInit } from 'undici';
import type { GetAppConfigOptions } from '../app/service';
import { findOpenIDUser, getOpenIdEmail, normalizeOpenIdIssuer } from '../auth/openid';
import {
getLibreChatRolesForOpenIdSync,
getOpenIdRolesForOpenIdSync,
getOpenIdRoleSyncOptions,
selectOpenIdRole,
} from '../auth/openidRoleSync';
import { findOpenIDUser, getOpenIdEmail, normalizeOpenIdIssuer } from '../auth/openid';
import { getEnvProxyDispatcher, getHttpsProxyAgent } from '~/utils/proxy';
import { isEnabled, math } from '~/utils';
export interface RemoteAgentAuthDeps {
@ -127,9 +127,10 @@ function getJwksCacheOptions(): JwksCacheOptions {
function buildDiscoveryOptions(controller: AbortController): RequestInit {
const options: RequestInit = { signal: controller.signal };
const dispatcher = getEnvProxyDispatcher();
if (process.env.PROXY) {
options.dispatcher = new ProxyAgent(process.env.PROXY);
if (dispatcher) {
options.dispatcher = dispatcher;
}
return options;
@ -196,8 +197,9 @@ function buildJwksClient(uri: string, cacheOptions: JwksCacheOptions): jwksRsa.J
jwksUri: uri,
};
if (process.env.PROXY) {
options.requestAgent = new HttpsProxyAgent(process.env.PROXY);
const requestAgent = getHttpsProxyAgent(uri);
if (requestAgent) {
options.requestAgent = requestAgent;
}
return jwksRsa(options);

View file

@ -18,6 +18,7 @@ export * from './math';
export * from './oidc';
export * from './openid';
export * from './promise';
export * from './proxy';
export * from './ports';
export * from './sanitizeTitle';
export * from './text';

View file

@ -0,0 +1,199 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
import { EnvHttpProxyAgent, ProxyAgent } from 'undici';
import {
applyAxiosProxyConfig,
getEnvProxyDispatcher,
getHttpsProxyAgent,
getProxyDispatcher,
getProxyEnvConfig,
getProxyUrlForUrl,
shouldBypassProxy,
} from './proxy';
jest.mock('https-proxy-agent', () => ({
HttpsProxyAgent: jest.fn(),
}));
jest.mock('undici', () => ({
EnvHttpProxyAgent: jest.fn(),
ProxyAgent: jest.fn(),
}));
const MockEnvHttpProxyAgent = EnvHttpProxyAgent as jest.MockedClass<typeof EnvHttpProxyAgent>;
const MockProxyAgent = ProxyAgent as jest.MockedClass<typeof ProxyAgent>;
const MockHttpsProxyAgent = HttpsProxyAgent as jest.MockedClass<typeof HttpsProxyAgent>;
describe('proxy helpers', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.PROXY;
delete process.env.proxy;
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.NO_PROXY;
delete process.env.http_proxy;
delete process.env.https_proxy;
delete process.env.no_proxy;
});
afterAll(() => {
process.env = originalEnv;
});
it('returns undefined when no proxy env is configured', () => {
expect(getProxyEnvConfig()).toBeUndefined();
expect(getEnvProxyDispatcher()).toBeUndefined();
expect(MockEnvHttpProxyAgent).not.toHaveBeenCalled();
});
it('uses PROXY for both protocols and passes no_proxy through to undici', () => {
process.env.PROXY = 'http://corporate-proxy:8080';
process.env.no_proxy = 'localhost,.internal.example';
expect(getProxyEnvConfig()).toEqual({
httpProxy: 'http://corporate-proxy:8080',
httpsProxy: 'http://corporate-proxy:8080',
noProxy: 'localhost,.internal.example',
});
expect(getEnvProxyDispatcher()).toBeInstanceOf(EnvHttpProxyAgent);
expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({
httpProxy: 'http://corporate-proxy:8080',
httpsProxy: 'http://corporate-proxy:8080',
noProxy: 'localhost,.internal.example',
});
});
it('uses lowercase standard proxy env before uppercase values', () => {
process.env.HTTP_PROXY = 'http://upper-http:8080';
process.env.HTTPS_PROXY = 'http://upper-https:8080';
process.env.NO_PROXY = 'upper.example';
process.env.http_proxy = 'http://lower-http:8080';
process.env.https_proxy = 'http://lower-https:8080';
process.env.no_proxy = 'lower.example';
expect(getProxyEnvConfig()).toEqual({
httpProxy: 'http://lower-http:8080',
httpsProxy: 'http://lower-https:8080',
noProxy: 'lower.example',
});
});
it('reuses the env dispatcher until proxy env changes', () => {
process.env.PROXY = 'http://corporate-proxy-reuse:8080';
process.env.NO_PROXY = 'localhost';
const first = getEnvProxyDispatcher();
const second = getEnvProxyDispatcher();
expect(second).toBe(first);
expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(1);
process.env.NO_PROXY = 'localhost,.internal.example';
getEnvProxyDispatcher();
expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(2);
});
it('creates explicit proxy dispatchers when a non-env proxy is provided', () => {
const first = getProxyDispatcher('http://explicit-proxy:8080');
const second = getProxyDispatcher('http://explicit-proxy:8080');
expect(second).toBe(first);
expect(MockProxyAgent).toHaveBeenCalledTimes(1);
expect(MockProxyAgent).toHaveBeenCalledWith('http://explicit-proxy:8080');
});
it('uses the env dispatcher when the explicit proxy matches PROXY', () => {
process.env.PROXY = 'http://matching-proxy:8080';
getProxyDispatcher('http://matching-proxy:8080');
expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({
httpProxy: 'http://matching-proxy:8080',
httpsProxy: 'http://matching-proxy:8080',
noProxy: undefined,
});
expect(MockProxyAgent).not.toHaveBeenCalled();
});
it('matches NO_PROXY wildcard, domains, host ports, and whitespace separators', () => {
const noProxy = 'localhost, .internal.example api.example.com:8443 *.service.local *wild.local';
expect(shouldBypassProxy('https://localhost/login', noProxy)).toBe(true);
expect(shouldBypassProxy('https://sso.internal.example/login', noProxy)).toBe(true);
expect(shouldBypassProxy('https://api.example.com:8443/models', noProxy)).toBe(true);
expect(shouldBypassProxy('https://api.example.com/models', noProxy)).toBe(false);
expect(shouldBypassProxy('https://foo.service.local/search', noProxy)).toBe(true);
expect(shouldBypassProxy('https://api.wild.local/search', noProxy)).toBe(true);
expect(shouldBypassProxy('https://example.com', '*')).toBe(true);
});
it('resolves target-aware proxy URLs and respects NO_PROXY for axios-style agents', () => {
process.env.HTTP_PROXY = 'http://http-proxy:8080';
process.env.HTTPS_PROXY = 'http://https-proxy:8080';
process.env.NO_PROXY = 'internal.example';
expect(getProxyUrlForUrl('http://api.external.example/models')).toBe('http://http-proxy:8080');
expect(getProxyUrlForUrl('https://api.external.example/models')).toBe(
'http://https-proxy:8080',
);
expect(getProxyUrlForUrl('https://sso.internal.example/login')).toBeUndefined();
});
it('falls back to HTTP_PROXY for HTTPS axios-style agents', () => {
process.env.HTTP_PROXY = 'http://single-proxy:8080';
expect(getProxyUrlForUrl('https://api.external.example/models')).toBe(
'http://single-proxy:8080',
);
const config = applyAxiosProxyConfig({}, 'https://api.external.example/models');
expect(config).toEqual({
httpsAgent: expect.any(Object),
proxy: false,
});
expect(MockHttpsProxyAgent).toHaveBeenCalledWith('http://single-proxy:8080');
});
it('applies cached HttpsProxyAgent and disables axios native proxy handling', () => {
process.env.HTTPS_PROXY = 'http://https-proxy:8080';
const config = applyAxiosProxyConfig({}, 'https://api.external.example/models');
const agent = getHttpsProxyAgent('https://api.external.example/models');
expect(config).toEqual({
httpsAgent: agent,
proxy: false,
});
expect(MockHttpsProxyAgent).toHaveBeenCalledTimes(1);
expect(MockHttpsProxyAgent).toHaveBeenCalledWith('http://https-proxy:8080');
});
it('disables axios native proxy handling for NO_PROXY targets', () => {
process.env.proxy = 'http://axios-default-proxy:8080';
process.env.NO_PROXY = 'internal.example';
expect(applyAxiosProxyConfig({}, 'https://sso.internal.example/login')).toEqual({
proxy: false,
});
expect(MockHttpsProxyAgent).not.toHaveBeenCalled();
});
it('applies axios native proxy config to plain HTTP targets', () => {
process.env.HTTP_PROXY = 'http://http-proxy:8080';
expect(applyAxiosProxyConfig({}, 'http://api.external.example/models')).toEqual({
proxy: {
host: 'http-proxy',
port: 8080,
protocol: 'http',
},
});
expect(MockHttpsProxyAgent).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,237 @@
import { HttpsProxyAgent } from 'https-proxy-agent';
import { EnvHttpProxyAgent, ProxyAgent } from 'undici';
import type { AxiosRequestConfig, AxiosProxyConfig } from 'axios';
import type { Dispatcher } from 'undici';
export type ProxyEnvConfig = {
httpProxy?: string;
httpsProxy?: string;
noProxy?: string;
};
type HttpsProxyAgentInstance = InstanceType<typeof HttpsProxyAgent>;
type ProxyResolution = {
proxyUrl?: string;
bypassed: boolean;
};
let envProxyDispatcher: EnvHttpProxyAgent | undefined;
let envProxyDispatcherKey: string | undefined;
const explicitDispatchers = new Map<string, ProxyAgent>();
const httpsProxyAgents = new Map<string, HttpsProxyAgentInstance>();
function getTrimmedEnv(...keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) return value;
}
return undefined;
}
export function getProxyEnvConfig(): ProxyEnvConfig | undefined {
const proxy = getTrimmedEnv('PROXY', 'proxy');
const noProxy = getTrimmedEnv('no_proxy', 'NO_PROXY');
if (proxy) {
return { httpProxy: proxy, httpsProxy: proxy, noProxy };
}
const httpProxy = getTrimmedEnv('http_proxy', 'HTTP_PROXY');
const httpsProxy = getTrimmedEnv('https_proxy', 'HTTPS_PROXY');
if (!httpProxy && !httpsProxy) return undefined;
return { httpProxy, httpsProxy, noProxy };
}
function getProxyConfigKey(config: ProxyEnvConfig): string {
return [config.httpProxy ?? '', config.httpsProxy ?? '', config.noProxy ?? ''].join('|');
}
export function getEnvProxyDispatcher(): Dispatcher | undefined {
const proxyConfig = getProxyEnvConfig();
if (!proxyConfig) return undefined;
const key = getProxyConfigKey(proxyConfig);
if (!envProxyDispatcher || envProxyDispatcherKey !== key) {
envProxyDispatcher = new EnvHttpProxyAgent(proxyConfig);
envProxyDispatcherKey = key;
}
return envProxyDispatcher;
}
function getExplicitProxyDispatcher(proxyUrl: string): Dispatcher {
const cached = explicitDispatchers.get(proxyUrl);
if (cached) return cached;
const dispatcher = new ProxyAgent(proxyUrl);
explicitDispatchers.set(proxyUrl, dispatcher);
return dispatcher;
}
export function getProxyDispatcher(proxyUrl?: string | null): Dispatcher | undefined {
const trimmedProxy = proxyUrl?.trim();
if (!trimmedProxy) return getEnvProxyDispatcher();
const proxyConfig = getProxyEnvConfig();
if (proxyConfig?.httpProxy === trimmedProxy && proxyConfig?.httpsProxy === trimmedProxy) {
return getEnvProxyDispatcher();
}
return getExplicitProxyDispatcher(trimmedProxy);
}
function parseUrl(value: string | URL | undefined): URL | undefined {
if (!value) return undefined;
if (value instanceof URL) return value;
try {
return new URL(value);
} catch {
return undefined;
}
}
function normalizeHostname(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
}
function getDefaultPort(protocol: string): number {
if (protocol === 'http:' || protocol === 'ws:') return 80;
if (protocol === 'https:' || protocol === 'wss:') return 443;
return 0;
}
function getNoProxyEntry(entry: string): { host: string; port: number } {
const trimmed = entry.trim();
const bracketed = trimmed.match(/^\[([^\]]+)\](?::(\d+))?$/);
if (bracketed) {
return {
host: normalizeHostname(bracketed[1]),
port: bracketed[2] ? Number.parseInt(bracketed[2], 10) : 0,
};
}
const parsed = (trimmed.match(/:/g) ?? []).length === 1 ? trimmed.match(/^(.+):(\d+)$/) : null;
const host = normalizeHostname(parsed ? parsed[1] : trimmed);
return {
host: host === '*' ? host : host.replace(/^\*\.?/, '.'),
port: parsed ? Number.parseInt(parsed[2], 10) : 0,
};
}
function hostMatchesNoProxy(hostname: string, entryHost: string): boolean {
if (!entryHost) return false;
if (entryHost === '*') return true;
const normalizedEntry = entryHost.startsWith('.') ? entryHost.slice(1) : entryHost;
return hostname === normalizedEntry || hostname.endsWith(`.${normalizedEntry}`);
}
export function shouldBypassProxy(targetUrl: string | URL, noProxy?: string): boolean {
if (!noProxy?.trim()) return false;
const url = parseUrl(targetUrl);
if (!url) return false;
const hostname = normalizeHostname(url.hostname);
const port = Number.parseInt(url.port, 10) || getDefaultPort(url.protocol);
return noProxy
.split(/[\s,]+/)
.filter(Boolean)
.some((entry) => {
const parsed = getNoProxyEntry(entry);
return parsed.port > 0 && parsed.port !== port
? false
: hostMatchesNoProxy(hostname, parsed.host);
});
}
function getProxyResolution(targetUrl?: string | URL): ProxyResolution {
const proxyConfig = getProxyEnvConfig();
if (!proxyConfig) return { bypassed: false };
const url = parseUrl(targetUrl);
if (url && shouldBypassProxy(url, proxyConfig.noProxy)) {
return { bypassed: true };
}
if (!url) return { proxyUrl: proxyConfig.httpsProxy ?? proxyConfig.httpProxy, bypassed: false };
if (url.protocol === 'http:' || url.protocol === 'ws:') {
return { proxyUrl: proxyConfig.httpProxy, bypassed: false };
}
if (url.protocol === 'https:' || url.protocol === 'wss:') {
return { proxyUrl: proxyConfig.httpsProxy ?? proxyConfig.httpProxy, bypassed: false };
}
return { bypassed: false };
}
export function getProxyUrlForUrl(targetUrl?: string | URL): string | undefined {
return getProxyResolution(targetUrl).proxyUrl;
}
export function getHttpsProxyAgent(targetUrl?: string | URL): HttpsProxyAgentInstance | undefined {
const url = parseUrl(targetUrl);
if (url && url.protocol !== 'https:' && url.protocol !== 'wss:') return undefined;
const proxyUrl = getProxyUrlForUrl(targetUrl);
if (!proxyUrl) return undefined;
const cached = httpsProxyAgents.get(proxyUrl);
if (cached) return cached;
const agent = new HttpsProxyAgent(proxyUrl);
httpsProxyAgents.set(proxyUrl, agent);
return agent;
}
function getAxiosProxyConfig(proxyUrl: string): AxiosProxyConfig {
const url = new URL(proxyUrl);
const proxyConfig: Partial<AxiosProxyConfig> = {
host: url.hostname.replace(/^\[|\]$/g, ''),
protocol: url.protocol.replace(':', ''),
};
if (url.port) {
proxyConfig.port = Number.parseInt(url.port, 10);
}
if (url.username || url.password) {
proxyConfig.auth = {
username: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
};
}
return proxyConfig as AxiosProxyConfig;
}
export function applyAxiosProxyConfig(
config: AxiosRequestConfig,
targetUrl?: string | URL,
): AxiosRequestConfig {
const resolution = getProxyResolution(targetUrl);
if (resolution.bypassed) {
config.proxy = false;
return config;
}
const url = parseUrl(targetUrl);
if (url && (url.protocol === 'http:' || url.protocol === 'ws:') && resolution.proxyUrl) {
config.proxy = getAxiosProxyConfig(resolution.proxyUrl);
return config;
}
const agent = getHttpsProxyAgent(targetUrl);
if (!agent) return config;
config.httpsAgent = agent;
config.proxy = false;
return config;
}
export const getOpenIdProxyDispatcher: () => Dispatcher | undefined = getEnvProxyDispatcher;