mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🌐 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:
parent
16bbc4b97e
commit
98704f28c1
34 changed files with 668 additions and 250 deletions
|
|
@ -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 #
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
199
packages/api/src/utils/proxy.spec.ts
Normal file
199
packages/api/src/utils/proxy.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
237
packages/api/src/utils/proxy.ts
Normal file
237
packages/api/src/utils/proxy.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue