From fc6f127b2138d89f90a91fe9a7feb7baa1a253c4 Mon Sep 17 00:00:00 2001 From: Joseph Licata <54822374+usnavy13@users.noreply.github.com> Date: Mon, 12 Jan 2026 09:51:48 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=89=20fix:=20Add=20Proxy=20Support=20t?= =?UTF-8?q?o=20Gemini=20Image=20Gen=20Tool=20(#11302)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Add proxy support for Google APIs in GeminiImageGen - Implemented a proxy wrapper for globalThis.fetch to route requests to googleapis.com through a specified proxy. - Added tests to verify the proxy configuration behavior, ensuring correct dispatcher application for Google API calls and preserving existing options. Co-authored-by: [Your Name] * chore: remove comment --------- Co-authored-by: [Your Name] Co-authored-by: Danny Avila --- .../tools/structured/GeminiImageGen.js | 19 +++ .../specs/GeminiImageGen-proxy.spec.js | 125 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js diff --git a/api/app/clients/tools/structured/GeminiImageGen.js b/api/app/clients/tools/structured/GeminiImageGen.js index c6ee58a61e..c0e5a0ce1d 100644 --- a/api/app/clients/tools/structured/GeminiImageGen.js +++ b/api/app/clients/tools/structured/GeminiImageGen.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const sharp = require('sharp'); const { v4 } = require('uuid'); +const { ProxyAgent } = require('undici'); const { GoogleGenAI } = require('@google/genai'); const { tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); @@ -21,6 +22,24 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { spendTokens } = require('~/models/spendTokens'); const { getFiles } = require('~/models/File'); +/** + * Configure proxy support for Google APIs + * 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 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 }; + } + return originalFetch.call(this, url, options); + }; +} + /** * Get the default service key file path (consistent with main Google endpoint) * @returns {string} - The default path to the service key file diff --git a/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js b/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js new file mode 100644 index 0000000000..027d2659d6 --- /dev/null +++ b/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js @@ -0,0 +1,125 @@ +const { ProxyAgent } = require('undici'); + +/** + * These tests verify the proxy wrapper behavior for GeminiImageGen. + * Instead of loading the full module (which has many dependencies), + * we directly test the wrapper logic that would be applied. + */ +describe('GeminiImageGen Proxy Configuration', () => { + let originalEnv; + let originalFetch; + + beforeAll(() => { + originalEnv = { ...process.env }; + originalFetch = globalThis.fetch; + }); + + beforeEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + }); + + afterEach(() => { + process.env = originalEnv; + globalThis.fetch = originalFetch; + }); + + /** + * Simulates the proxy wrapper that GeminiImageGen applies at module load. + * This is the same logic from GeminiImageGen.js lines 30-42. + */ + function applyProxyWrapper() { + if (process.env.PROXY) { + 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 }; + } + return _originalFetch.call(this, url, options); + }; + } + } + + it('should wrap globalThis.fetch when PROXY env is set', () => { + process.env.PROXY = 'http://proxy.example.com:8080'; + + const fetchBeforeWrap = globalThis.fetch; + + applyProxyWrapper(); + + expect(globalThis.fetch).not.toBe(fetchBeforeWrap); + }); + + it('should not wrap globalThis.fetch when PROXY env is not set', () => { + delete process.env.PROXY; + + const fetchBeforeWrap = globalThis.fetch; + + applyProxyWrapper(); + + expect(globalThis.fetch).toBe(fetchBeforeWrap); + }); + + it('should add dispatcher to googleapis.com URLs', async () => { + process.env.PROXY = 'http://proxy.example.com:8080'; + + let capturedOptions = null; + const mockFetch = jest.fn((url, options) => { + capturedOptions = options; + return Promise.resolve({ ok: true }); + }); + globalThis.fetch = mockFetch; + + applyProxyWrapper(); + + await globalThis.fetch('https://generativelanguage.googleapis.com/v1/models', {}); + + expect(capturedOptions).toBeDefined(); + expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent); + }); + + it('should not add dispatcher to non-googleapis.com URLs', async () => { + process.env.PROXY = 'http://proxy.example.com:8080'; + + let capturedOptions = null; + const mockFetch = jest.fn((url, options) => { + capturedOptions = options; + return Promise.resolve({ ok: true }); + }); + globalThis.fetch = mockFetch; + + applyProxyWrapper(); + + await globalThis.fetch('https://api.openai.com/v1/images', {}); + + expect(capturedOptions).toBeDefined(); + expect(capturedOptions.dispatcher).toBeUndefined(); + }); + + it('should preserve existing options when adding dispatcher', async () => { + process.env.PROXY = 'http://proxy.example.com:8080'; + + let capturedOptions = null; + const mockFetch = jest.fn((url, options) => { + capturedOptions = options; + return Promise.resolve({ ok: true }); + }); + globalThis.fetch = mockFetch; + + applyProxyWrapper(); + + const customHeaders = { 'X-Custom-Header': 'test' }; + await globalThis.fetch('https://aiplatform.googleapis.com/v1/models', { + headers: customHeaders, + method: 'POST', + }); + + expect(capturedOptions).toBeDefined(); + expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent); + expect(capturedOptions.headers).toEqual(customHeaders); + expect(capturedOptions.method).toBe('POST'); + }); +});