From cb289e0724b46ce867b6d3fe4bd0ac6ed890dc45 Mon Sep 17 00:00:00 2001 From: Christopher Thomas Date: Wed, 15 Apr 2026 14:38:38 -0500 Subject: [PATCH] fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL (#25357) --- docs/reference/configuration.md | 15 ++ .../core/src/core/contentGenerator.test.ts | 203 +++++++++++++++++- packages/core/src/core/contentGenerator.ts | 35 ++- 3 files changed, 242 insertions(+), 11 deletions(-) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 05368f20fe..2047a9b09d 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -2148,6 +2148,21 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - When set, overrides the default API version used by the SDK. - Example: `export GOOGLE_GENAI_API_VERSION="v1"` (Windows PowerShell: `$env:GOOGLE_GENAI_API_VERSION="v1"`) +- **`GOOGLE_GEMINI_BASE_URL`**: + - Overrides the default base URL for Gemini API requests (when using + `gemini-api-key` authentication). + - Must be a valid URL. For security, it must use HTTPS unless pointing to + `localhost` (or `127.0.0.1` / `[::1]`). + - Example: `export GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"` (Windows + PowerShell: `$env:GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"`) +- **`GOOGLE_VERTEX_BASE_URL`**: + - Overrides the default base URL for Vertex AI API requests (when using + `vertex-ai` authentication). + - Must be a valid URL. For security, it must use HTTPS unless pointing to + `localhost` (or `127.0.0.1` / `[::1]`). + - Example: `export GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"` + (Windows PowerShell: + `$env:GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"`) - **`OTLP_GOOGLE_CLOUD_PROJECT`**: - Your Google Cloud Project ID for Telemetry in Google Cloud - Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index 35d7879f96..bf7eef167d 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -148,7 +148,7 @@ describe('createContentGenerator', () => { ); expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'test-api-key', - vertexai: undefined, + vertexai: false, httpOptions: expect.objectContaining({ headers: expect.objectContaining({ 'User-Agent': expect.stringMatching( @@ -365,7 +365,7 @@ describe('createContentGenerator', () => { expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'test-api-key', - vertexai: undefined, + vertexai: false, httpOptions: expect.objectContaining({ headers: expect.objectContaining({ 'User-Agent': expect.any(String), @@ -409,7 +409,7 @@ describe('createContentGenerator', () => { expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'test-api-key', - vertexai: undefined, + vertexai: false, httpOptions: expect.objectContaining({ headers: expect.objectContaining({ 'User-Agent': expect.any(String), @@ -443,7 +443,7 @@ describe('createContentGenerator', () => { expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'test-api-key', - vertexai: undefined, + vertexai: false, httpOptions: expect.objectContaining({ headers: expect.objectContaining({ 'User-Agent': expect.any(String), @@ -481,7 +481,7 @@ describe('createContentGenerator', () => { ); expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'test-api-key', - vertexai: undefined, + vertexai: false, httpOptions: expect.objectContaining({ headers: { 'User-Agent': expect.any(String), @@ -517,7 +517,7 @@ describe('createContentGenerator', () => { expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'test-api-key', - vertexai: undefined, + vertexai: false, httpOptions: expect.objectContaining({ headers: expect.objectContaining({ 'User-Agent': expect.any(String), @@ -550,7 +550,7 @@ describe('createContentGenerator', () => { expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'test-api-key', - vertexai: undefined, + vertexai: false, httpOptions: expect.objectContaining({ headers: expect.objectContaining({ 'User-Agent': expect.any(String), @@ -589,7 +589,7 @@ describe('createContentGenerator', () => { expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'test-api-key', - vertexai: undefined, + vertexai: false, httpOptions: expect.objectContaining({ headers: expect.objectContaining({ 'User-Agent': expect.any(String), @@ -638,6 +638,193 @@ describe('createContentGenerator', () => { apiVersion: 'v1alpha', }); }); + + it('should pass baseUrl to GoogleGenAI when GOOGLE_GEMINI_BASE_URL is set', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://gemini.test.local'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + + const config = await createContentGeneratorConfig( + mockConfig, + AuthType.USE_GEMINI, + ); + await createContentGenerator(config, mockConfig); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'test-api-key', + vertexai: false, + httpOptions: expect.objectContaining({ + baseUrl: 'https://gemini.test.local', + }), + }), + ); + }); + + it('should pass baseUrl to GoogleGenAI when GOOGLE_VERTEX_BASE_URL is set', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://vertex.test.local'); + vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-project'); + vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1'); + + const config = await createContentGeneratorConfig( + mockConfig, + AuthType.USE_VERTEX_AI, + ); + await createContentGenerator(config, mockConfig); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: undefined, + vertexai: true, + httpOptions: expect.objectContaining({ + baseUrl: 'https://vertex.test.local', + }), + }), + ); + }); + + it('should prefer GOOGLE_VERTEX_BASE_URL when authType is USE_VERTEX_AI without inferred vertex credentials', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://gemini.test.local'); + vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://vertex.test.local'); + + await createContentGenerator( + { + authType: AuthType.USE_VERTEX_AI, + }, + mockConfig, + ); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: undefined, + vertexai: true, + httpOptions: expect.objectContaining({ + baseUrl: 'https://vertex.test.local', + }), + }), + ); + }); + + it('should prefer an explicit baseUrl over GOOGLE_GEMINI_BASE_URL', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://env.test.local'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + + const config = await createContentGeneratorConfig( + mockConfig, + AuthType.USE_GEMINI, + undefined, + 'https://explicit.test.local', + ); + await createContentGenerator(config, mockConfig); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: 'https://explicit.test.local', + }), + }), + ); + }); + + it('should allow localhost baseUrl overrides over http', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => false, + getClientName: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + + await createContentGenerator( + { + apiKey: 'test-api-key', + authType: AuthType.USE_GEMINI, + baseUrl: 'http://127.0.0.1:8080', + }, + mockConfig, + ); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: 'http://127.0.0.1:8080', + }), + }), + ); + }); + + it('should reject invalid custom baseUrl values', async () => { + await expect( + createContentGenerator( + { + apiKey: 'test-api-key', + authType: AuthType.USE_GEMINI, + baseUrl: 'not-a-url', + }, + mockConfig, + ), + ).rejects.toThrow('Invalid custom base URL: not-a-url'); + }); + + it('should reject non-https remote custom baseUrl values', async () => { + await expect( + createContentGenerator( + { + apiKey: 'test-api-key', + authType: AuthType.USE_GEMINI, + baseUrl: 'http://example.com', + }, + mockConfig, + ), + ).rejects.toThrow('Custom base URL must use HTTPS unless it is localhost.'); + }); }); describe('createContentGeneratorConfig', () => { diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 4fc56b59b4..31e36ede41 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -101,6 +101,21 @@ export type ContentGeneratorConfig = { customHeaders?: Record; }; +const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]']; + +function validateBaseUrl(baseUrl: string): void { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw new Error(`Invalid custom base URL: ${baseUrl}`); + } + + if (url.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(url.hostname)) { + throw new Error('Custom base URL must use HTTPS unless it is localhost.'); + } +} + export async function createContentGeneratorConfig( config: Config, authType: AuthType | undefined, @@ -273,18 +288,32 @@ export async function createContentGenerator( 'x-gemini-api-privileged-user-id': `${installationId}`, }; } + let baseUrl = config.baseUrl; + if (!baseUrl) { + const envBaseUrl = + config.authType === AuthType.USE_VERTEX_AI + ? process.env['GOOGLE_VERTEX_BASE_URL'] + : process.env['GOOGLE_GEMINI_BASE_URL']; + if (envBaseUrl) { + validateBaseUrl(envBaseUrl); + baseUrl = envBaseUrl; + } + } else { + validateBaseUrl(baseUrl); + } + const httpOptions: { baseUrl?: string; headers: Record; } = { headers }; - if (config.baseUrl) { - httpOptions.baseUrl = config.baseUrl; + if (baseUrl) { + httpOptions.baseUrl = baseUrl; } const googleGenAI = new GoogleGenAI({ apiKey: config.apiKey === '' ? undefined : config.apiKey, - vertexai: config.vertexai, + vertexai: config.vertexai ?? config.authType === AuthType.USE_VERTEX_AI, httpOptions, ...(apiVersionEnv && { apiVersion: apiVersionEnv }), });