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'); + }); +});