From 98704f28c1e74dc06faaacfae3268c126143512e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 14 Jun 2026 10:47:49 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=90=20fix:=20Centralize=20Outbound=20P?= =?UTF-8?q?roxy=20Handling=20(#13726)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .env.example | 8 +- api/app/clients/tools/structured/DALLE3.js | 16 +- api/app/clients/tools/structured/FluxAPI.js | 22 +- .../tools/structured/GeminiImageGen.js | 8 +- .../tools/structured/OpenAIImageTools.js | 26 +- .../clients/tools/structured/TavilySearch.js | 8 +- .../tools/structured/TavilySearchResults.js | 8 +- .../structured/specs/DALLE3-proxy.spec.js | 24 +- .../specs/GeminiImageGen-proxy.spec.js | 12 +- .../specs/TavilySearchResults.spec.js | 25 +- .../Endpoints/assistants/initalize.js | 9 +- .../Endpoints/azureAssistants/initialize.js | 8 +- api/server/services/Files/Audio/STTService.js | 7 +- api/server/services/Files/Audio/TTSService.js | 7 +- api/strategies/openIdJwtStrategy.js | 7 +- api/strategies/openIdJwtStrategy.spec.js | 1 + api/strategies/openidStrategy.js | 2 +- .../tools/structured/OpenAIImageTools.test.js | 2 + deploy-compose.yml | 7 + docker-compose.yml | 7 + packages/api/src/auth/proxy.spec.ts | 83 ------ packages/api/src/auth/proxy.ts | 20 +- packages/api/src/endpoints/anthropic/llm.ts | 9 +- .../src/endpoints/bedrock/initialize.spec.ts | 22 +- .../api/src/endpoints/bedrock/initialize.ts | 26 +- packages/api/src/endpoints/models.ts | 11 +- packages/api/src/endpoints/openai/config.ts | 8 +- packages/api/src/files/mistral/crud.spec.ts | 21 ++ packages/api/src/files/mistral/crud.ts | 27 +- .../src/middleware/remoteAgentAuth.spec.ts | 22 +- .../api/src/middleware/remoteAgentAuth.ts | 18 +- packages/api/src/utils/index.ts | 1 + packages/api/src/utils/proxy.spec.ts | 199 +++++++++++++++ packages/api/src/utils/proxy.ts | 237 ++++++++++++++++++ 34 files changed, 668 insertions(+), 250 deletions(-) delete mode 100644 packages/api/src/auth/proxy.spec.ts create mode 100644 packages/api/src/utils/proxy.spec.ts create mode 100644 packages/api/src/utils/proxy.ts diff --git a/.env.example b/.env.example index f58f154adc..a35e685be0 100644 --- a/.env.example +++ b/.env.example @@ -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 # diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index 0f885ed636..5bcd87a0ce 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -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(); diff --git a/api/app/clients/tools/structured/FluxAPI.js b/api/app/clients/tools/structured/FluxAPI.js index e251b2da65..fd0464c34e 100644 --- a/api/app/clients/tools/structured/FluxAPI.js +++ b/api/app/clients/tools/structured/FluxAPI.js @@ -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(); diff --git a/api/app/clients/tools/structured/GeminiImageGen.js b/api/app/clients/tools/structured/GeminiImageGen.js index ed8dd5c762..04265bbba9 100644 --- a/api/app/clients/tools/structured/GeminiImageGen.js +++ b/api/app/clients/tools/structured/GeminiImageGen.js @@ -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); }; diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js index 0d7ee643e3..d92d17b77e 100644 --- a/api/app/clients/tools/structured/OpenAIImageTools.js +++ b/api/app/clients/tools/structured/OpenAIImageTools.js @@ -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 = { diff --git a/api/app/clients/tools/structured/TavilySearch.js b/api/app/clients/tools/structured/TavilySearch.js index e45f6d2bf8..a90b75b9f8 100644 --- a/api/app/clients/tools/structured/TavilySearch.js +++ b/api/app/clients/tools/structured/TavilySearch.js @@ -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); diff --git a/api/app/clients/tools/structured/TavilySearchResults.js b/api/app/clients/tools/structured/TavilySearchResults.js index 4d46402c99..9e9aa3d34c 100644 --- a/api/app/clients/tools/structured/TavilySearchResults.js +++ b/api/app/clients/tools/structured/TavilySearchResults.js @@ -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); diff --git a/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js b/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js index 262842b3c2..b958ed7b5b 100644 --- a/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js +++ b/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js @@ -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 diff --git a/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js b/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js index 027d2659d6..dbdda6e454 100644 --- a/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js +++ b/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js @@ -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'); }); diff --git a/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js b/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js index 891a8cdc19..7184e08204 100644 --- a/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js +++ b/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js @@ -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({ diff --git a/api/server/services/Endpoints/assistants/initalize.js b/api/server/services/Endpoints/assistants/initalize.js index d5a246dff7..4b31f63fdd 100644 --- a/api/server/services/Endpoints/assistants/initalize.js +++ b/api/server/services/Endpoints/assistants/initalize.js @@ -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, }; } diff --git a/api/server/services/Endpoints/azureAssistants/initialize.js b/api/server/services/Endpoints/azureAssistants/initialize.js index e81f0bcd8a..fde02b1589 100644 --- a/api/server/services/Endpoints/azureAssistants/initialize.js +++ b/api/server/services/Endpoints/azureAssistants/initialize.js @@ -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, }; } diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index 2caea1ffe0..af46b9cc79 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -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); diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js index 80f4239cc6..301bbe90f8 100644 --- a/api/server/services/Files/Audio/TTSService.js +++ b/api/server/services/Files/Audio/TTSService.js @@ -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); diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index ab1bcd1c0a..14f50f3f04 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -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( diff --git a/api/strategies/openIdJwtStrategy.spec.js b/api/strategies/openIdJwtStrategy.spec.js index 4a1871110c..5b4bc86c49 100644 --- a/api/strategies/openIdJwtStrategy.spec.js +++ b/api/strategies/openIdJwtStrategy.spec.js @@ -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', () => ({ diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 561878aa70..d9c2684314 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -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, diff --git a/api/test/app/clients/tools/structured/OpenAIImageTools.test.js b/api/test/app/clients/tools/structured/OpenAIImageTools.test.js index aa0726b916..b83ed5335c 100644 --- a/api/test/app/clients/tools/structured/OpenAIImageTools.test.js +++ b/api/test/app/clients/tools/structured/OpenAIImageTools.test.js @@ -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', () => ({ diff --git a/deploy-compose.yml b/deploy-compose.yml index 72ce653f9a..e581aed80f 100644 --- a/deploy-compose.yml +++ b/deploy-compose.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index dde7b653d4..dd3880a767 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/packages/api/src/auth/proxy.spec.ts b/packages/api/src/auth/proxy.spec.ts deleted file mode 100644 index 6c8c73da66..0000000000 --- a/packages/api/src/auth/proxy.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { EnvHttpProxyAgent } from 'undici'; -import { getOpenIdProxyDispatcher } from './proxy'; - -jest.mock('undici', () => ({ - EnvHttpProxyAgent: jest.fn(), -})); - -const MockEnvHttpProxyAgent = EnvHttpProxyAgent as jest.MockedClass; - -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); - }); -}); diff --git a/packages/api/src/auth/proxy.ts b/packages/api/src/auth/proxy.ts index 87117d6218..4fb33c5fd0 100644 --- a/packages/api/src/auth/proxy.ts +++ b/packages/api/src/auth/proxy.ts @@ -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'; diff --git a/packages/api/src/endpoints/anthropic/llm.ts b/packages/api/src/endpoints/anthropic/llm.ts index 7881797f61..a12119e5b9 100644 --- a/packages/api/src/endpoints/anthropic/llm.ts +++ b/packages/api/src/endpoints/anthropic/llm.ts @@ -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, }; } diff --git a/packages/api/src/endpoints/bedrock/initialize.spec.ts b/packages/api/src/endpoints/bedrock/initialize.spec.ts index 009776fd20..a07fd9195a 100644 --- a/packages/api/src/endpoints/bedrock/initialize.spec.ts +++ b/packages/api/src/endpoints/bedrock/initialize.spec.ts @@ -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; diff --git a/packages/api/src/endpoints/bedrock/initialize.ts b/packages/api/src/endpoints/bedrock/initialize.ts index c3e987f6c8..09307dfba0 100644 --- a/packages/api/src/endpoints/bedrock/initialize.ts +++ b/packages/api/src/endpoints/bedrock/initialize.ts @@ -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; timeout: number; - httpsAgent?: HttpsProxyAgent; } = { 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; diff --git a/packages/api/src/endpoints/openai/config.ts b/packages/api/src/endpoints/openai/config.ts index 3cc1c68cc1..c059d2beba 100644 --- a/packages/api/src/endpoints/openai/config.ts +++ b/packages/api/src/endpoints/openai/config.ts @@ -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, }; } diff --git a/packages/api/src/files/mistral/crud.spec.ts b/packages/api/src/files/mistral/crud.spec.ts index 9556781063..b401a852fa 100644 --- a/packages/api/src/files/mistral/crud.spec.ts +++ b/packages/api/src/files/mistral/crud.spec.ts @@ -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(); diff --git a/packages/api/src/files/mistral/crud.ts b/packages/api/src/files/mistral/crud.ts index c818fab8b8..db08cde9aa 100644 --- a/packages/api/src/files/mistral/crud.ts +++ b/packages/api/src/files/mistral/crud.ts @@ -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 { }, }; - 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) diff --git a/packages/api/src/middleware/remoteAgentAuth.spec.ts b/packages/api/src/middleware/remoteAgentAuth.spec.ts index 72decf4a3c..3e87378915 100644 --- a/packages/api/src/middleware/remoteAgentAuth.spec.ts +++ b/packages/api/src/middleware/remoteAgentAuth.spec.ts @@ -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('../auth/openid').findOpenIDUser; const mockFindOpenIDUser = findOpenIDUser as jest.MockedFunction; @@ -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 }), ); }); diff --git a/packages/api/src/middleware/remoteAgentAuth.ts b/packages/api/src/middleware/remoteAgentAuth.ts index 49b160d4dc..c198292dc2 100644 --- a/packages/api/src/middleware/remoteAgentAuth.ts +++ b/packages/api/src/middleware/remoteAgentAuth.ts @@ -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); diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 2b4ac88245..ff40512a5d 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -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'; diff --git a/packages/api/src/utils/proxy.spec.ts b/packages/api/src/utils/proxy.spec.ts new file mode 100644 index 0000000000..774dd627e0 --- /dev/null +++ b/packages/api/src/utils/proxy.spec.ts @@ -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; +const MockProxyAgent = ProxyAgent as jest.MockedClass; +const MockHttpsProxyAgent = HttpsProxyAgent as jest.MockedClass; + +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(); + }); +}); diff --git a/packages/api/src/utils/proxy.ts b/packages/api/src/utils/proxy.ts new file mode 100644 index 0000000000..bb227ee942 --- /dev/null +++ b/packages/api/src/utils/proxy.ts @@ -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; +type ProxyResolution = { + proxyUrl?: string; + bypassed: boolean; +}; + +let envProxyDispatcher: EnvHttpProxyAgent | undefined; +let envProxyDispatcherKey: string | undefined; +const explicitDispatchers = new Map(); +const httpsProxyAgents = new Map(); + +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 = { + 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;