diff --git a/packages/api/src/endpoints/google/llm.spec.ts b/packages/api/src/endpoints/google/llm.spec.ts new file mode 100644 index 0000000000..d9aa1a702a --- /dev/null +++ b/packages/api/src/endpoints/google/llm.spec.ts @@ -0,0 +1,779 @@ +import { Providers } from '@librechat/agents'; +import { AuthKeys } from 'librechat-data-provider'; +import type * as t from '~/types'; +import { getGoogleConfig, getSafetySettings, knownGoogleParams } from './llm'; + +describe('getGoogleConfig', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + delete process.env.GOOGLE_EXCLUDE_SAFETY_SETTINGS; + delete process.env.GOOGLE_LOC; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('Basic Configuration', () => { + it('should create a basic configuration with API key', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + }, + }); + + expect(result.provider).toBe(Providers.GOOGLE); + expect(result.llmConfig).toHaveProperty('apiKey', 'test-api-key'); + expect(result.llmConfig).toHaveProperty('model', 'gemini-1.5-flash'); + expect(result.llmConfig).toHaveProperty('maxRetries', 2); + expect(result.tools).toEqual([]); + }); + + it('should handle JSON string credentials', () => { + const credentials = JSON.stringify({ + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key-from-json', + }); + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-pro', + }, + }); + + expect(result.llmConfig).toHaveProperty('apiKey', 'test-api-key-from-json'); + }); + + it('should handle acceptRawApiKey flag', () => { + const result = getGoogleConfig( + 'raw-api-key-string', + { + modelOptions: { + model: 'gemini-1.5-flash', + }, + }, + true, + ); + + expect(result.llmConfig).toHaveProperty('apiKey', 'raw-api-key-string'); + }); + + it('should handle model options including temperature and topP/topK', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + temperature: 0.7, + topP: 0.9, + topK: 40, + }, + }); + + expect(result.llmConfig).toHaveProperty('temperature', 0.7); + expect(result.llmConfig).toHaveProperty('topP', 0.9); + expect(result.llmConfig).toHaveProperty('topK', 40); + }); + + it('should handle maxOutputTokens', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + maxOutputTokens: 4096, + }, + }); + + expect(result.llmConfig).toHaveProperty('maxOutputTokens', 4096); + }); + }); + + describe('Empty String Handling (Issue Fix)', () => { + it('should remove empty string maxOutputTokens from config', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + // Simulating empty string from form - cast to any to bypass TypeScript + const modelOptions = { + model: 'gemini-1.5-flash', + maxOutputTokens: '', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: modelOptions as unknown as t.GoogleParameters, + }); + + expect(result.llmConfig).not.toHaveProperty('maxOutputTokens'); + }); + + it('should remove empty string temperature from config', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const modelOptions = { + model: 'gemini-1.5-flash', + temperature: '', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: modelOptions as unknown as t.GoogleParameters, + }); + + expect(result.llmConfig).not.toHaveProperty('temperature'); + }); + + it('should remove empty string topP from config', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const modelOptions = { + model: 'gemini-1.5-flash', + topP: '', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: modelOptions as unknown as t.GoogleParameters, + }); + + expect(result.llmConfig).not.toHaveProperty('topP'); + }); + + it('should remove empty string topK from config', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const modelOptions = { + model: 'gemini-1.5-flash', + topK: '', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: modelOptions as unknown as t.GoogleParameters, + }); + + expect(result.llmConfig).not.toHaveProperty('topK'); + }); + + it('should preserve valid numeric values while removing empty strings', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const modelOptions = { + model: 'gemini-1.5-flash', + temperature: 0.5, + maxOutputTokens: '', // Empty string + topP: 0.9, + topK: '', // Empty string + }; + + const result = getGoogleConfig(credentials, { + modelOptions: modelOptions as unknown as t.GoogleParameters, + }); + + expect(result.llmConfig).toHaveProperty('temperature', 0.5); + expect(result.llmConfig).toHaveProperty('topP', 0.9); + expect(result.llmConfig).not.toHaveProperty('maxOutputTokens'); + expect(result.llmConfig).not.toHaveProperty('topK'); + }); + + it('should preserve zero values (not treat them as empty)', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + temperature: 0, + topK: 0, + }, + }); + + expect(result.llmConfig).toHaveProperty('temperature', 0); + expect(result.llmConfig).toHaveProperty('topK', 0); + }); + }); + + describe('Vertex AI Configuration', () => { + it('should configure Vertex AI with service account credentials', () => { + const credentials = { + [AuthKeys.GOOGLE_SERVICE_KEY]: { + project_id: 'test-project', + client_email: 'test@test-project.iam.gserviceaccount.com', + private_key: 'test-private-key', + }, + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-pro', + }, + }); + + expect(result.provider).toBe(Providers.VERTEXAI); + expect(result.llmConfig).toHaveProperty('authOptions'); + expect((result.llmConfig as Record).authOptions).toMatchObject({ + projectId: 'test-project', + credentials: expect.objectContaining({ + project_id: 'test-project', + }), + }); + expect(result.llmConfig).toHaveProperty('location', 'us-central1'); + }); + + it('should use GOOGLE_LOC env variable for Vertex AI location', () => { + process.env.GOOGLE_LOC = 'europe-west1'; + + const credentials = { + [AuthKeys.GOOGLE_SERVICE_KEY]: { + project_id: 'test-project', + }, + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-pro', + }, + }); + + expect(result.llmConfig).toHaveProperty('location', 'europe-west1'); + }); + + it('should handle service key as JSON string', () => { + const credentials = { + [AuthKeys.GOOGLE_SERVICE_KEY]: JSON.stringify({ + project_id: 'test-project', + client_email: 'test@test.iam.gserviceaccount.com', + }), + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-pro', + }, + }); + + expect(result.provider).toBe(Providers.VERTEXAI); + }); + }); + + describe('Thinking Configuration', () => { + it('should enable thinking for Google provider with valid budget', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.0-flash-thinking-exp', + thinking: true, + thinkingBudget: 5000, + }, + }); + + expect(result.llmConfig).toHaveProperty('thinkingConfig'); + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + thinkingBudget: 5000, + includeThoughts: true, + }); + }); + + it('should enable thinking with dynamic budget (-1)', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.0-flash-thinking-exp', + thinking: true, + thinkingBudget: -1, + }, + }); + + expect(result.llmConfig).toHaveProperty('thinkingConfig'); + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + thinkingBudget: -1, + includeThoughts: true, + }); + }); + + it('should not enable thinking when thinking is false', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.0-flash', + thinking: false, + thinkingBudget: 5000, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('thinkingConfig'); + }); + + it('should not enable thinking when budget is 0', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.0-flash', + thinking: true, + thinkingBudget: 0, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('thinkingConfig'); + }); + + it('should enable thinking for Vertex AI provider', () => { + const credentials = { + [AuthKeys.GOOGLE_SERVICE_KEY]: { + project_id: 'test-project', + }, + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.0-flash-thinking-exp', + thinking: true, + thinkingBudget: 3000, + }, + }); + + expect(result.provider).toBe(Providers.VERTEXAI); + expect(result.llmConfig).toHaveProperty('thinkingBudget', 3000); + expect(result.llmConfig).toHaveProperty('includeThoughts', true); + }); + }); + + describe('Web Search Functionality', () => { + it('should enable web search when web_search is true', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + web_search: true, + }, + }); + + expect(result.tools).toContainEqual({ googleSearch: {} }); + }); + + it('should not include web search tools when web_search is false', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + web_search: false, + }, + }); + + expect(result.tools).not.toContainEqual({ googleSearch: {} }); + }); + + it('should enable web search via defaultParams', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + }, + defaultParams: { + web_search: true, + }, + }); + + expect(result.tools).toContainEqual({ googleSearch: {} }); + }); + + it('should enable web search via addParams', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + }, + addParams: { + web_search: true, + }, + }); + + expect(result.tools).toContainEqual({ googleSearch: {} }); + }); + + it('should disable web search via dropParams', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + web_search: true, + }, + dropParams: ['web_search'], + }); + + expect(result.tools).not.toContainEqual({ googleSearch: {} }); + }); + }); + + describe('Default and Add Parameters', () => { + it('should apply default parameters when fields are undefined', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + }, + defaultParams: { + temperature: 0.5, + topP: 0.9, + }, + }); + + expect(result.llmConfig).toHaveProperty('temperature', 0.5); + expect(result.llmConfig).toHaveProperty('topP', 0.9); + }); + + it('should NOT override existing values with default parameters', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + temperature: 0.8, + }, + defaultParams: { + temperature: 0.5, + }, + }); + + expect(result.llmConfig).toHaveProperty('temperature', 0.8); + }); + + it('should apply addParams and override defaults', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + }, + defaultParams: { + temperature: 0.5, + }, + addParams: { + temperature: 0.9, + seed: 42, + }, + }); + + expect(result.llmConfig).toHaveProperty('temperature', 0.9); + expect(result.llmConfig).toHaveProperty('seed', 42); + }); + + it('should only apply known Google params from defaultParams', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + }, + defaultParams: { + temperature: 0.7, + unknown_param: 'should_not_appear', + }, + }); + + expect(result.llmConfig).toHaveProperty('temperature', 0.7); + expect(result.llmConfig).not.toHaveProperty('unknown_param'); + }); + }); + + describe('Drop Parameters', () => { + it('should drop specified parameters from llmConfig', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + temperature: 0.7, + topP: 0.9, + }, + dropParams: ['temperature'], + }); + + expect(result.llmConfig).not.toHaveProperty('temperature'); + expect(result.llmConfig).toHaveProperty('topP', 0.9); + }); + + it('should handle dropping multiple parameters', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + temperature: 0.7, + topP: 0.9, + topK: 40, + }, + dropParams: ['temperature', 'topK'], + }); + + expect(result.llmConfig).not.toHaveProperty('temperature'); + expect(result.llmConfig).not.toHaveProperty('topK'); + expect(result.llmConfig).toHaveProperty('topP', 0.9); + }); + }); + + describe('Reverse Proxy Configuration', () => { + it('should include reverse proxy URL when provided', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + }, + reverseProxyUrl: 'https://custom-proxy.example.com', + }); + + expect(result.llmConfig).toHaveProperty('baseUrl', 'https://custom-proxy.example.com'); + }); + + it('should include custom auth header when authHeader is true', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-1.5-flash', + }, + reverseProxyUrl: 'https://custom-proxy.example.com', + authHeader: true, + }); + + expect(result.llmConfig).toHaveProperty('customHeaders'); + expect((result.llmConfig as Record).customHeaders).toMatchObject({ + Authorization: 'Bearer test-api-key', + }); + }); + }); + + describe('Error Handling', () => { + it('should throw error when missing credentials', () => { + expect(() => { + getGoogleConfig(undefined, { + modelOptions: { + model: 'gemini-1.5-flash', + }, + }); + }).toThrow('Invalid credentials provided'); + }); + + it('should throw error when credentials are empty object', () => { + expect(() => { + getGoogleConfig( + {}, + { + modelOptions: { + model: 'gemini-1.5-flash', + }, + }, + ); + }).toThrow('Invalid credentials provided'); + }); + + it('should throw error when JSON parsing fails', () => { + expect(() => { + getGoogleConfig('invalid-json', { + modelOptions: { + model: 'gemini-1.5-flash', + }, + }); + }).toThrow('Error parsing string credentials'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty modelOptions', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: {}, + }); + + // Empty string model is removed by removeNullishValues with removeEmptyStrings=true + expect(result.llmConfig).not.toHaveProperty('model'); + expect(result.llmConfig).toHaveProperty('maxRetries', 2); + }); + + it('should handle undefined modelOptions', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, {}); + + // Empty string model is removed by removeNullishValues with removeEmptyStrings=true + expect(result.llmConfig).not.toHaveProperty('model'); + }); + + it('should handle no options parameter', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials); + + // Empty string model is removed by removeNullishValues with removeEmptyStrings=true + expect(result.llmConfig).not.toHaveProperty('model'); + expect(result.provider).toBe(Providers.GOOGLE); + }); + + it('should handle nullish values removal', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const modelOptions = { + model: 'gemini-1.5-flash', + temperature: undefined, + topP: null, + topK: 0, // Should be preserved + }; + + const result = getGoogleConfig(credentials, { + modelOptions: modelOptions as unknown as t.GoogleParameters, + }); + + expect(result.llmConfig).not.toHaveProperty('temperature'); + expect(result.llmConfig).not.toHaveProperty('topP'); + expect(result.llmConfig).toHaveProperty('topK', 0); + }); + }); +}); + +describe('getSafetySettings', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + delete process.env.GOOGLE_EXCLUDE_SAFETY_SETTINGS; + delete process.env.GOOGLE_SAFETY_SEXUALLY_EXPLICIT; + delete process.env.GOOGLE_SAFETY_HATE_SPEECH; + delete process.env.GOOGLE_SAFETY_HARASSMENT; + delete process.env.GOOGLE_SAFETY_DANGEROUS_CONTENT; + delete process.env.GOOGLE_SAFETY_CIVIC_INTEGRITY; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should return default safety settings', () => { + const settings = getSafetySettings('gemini-1.5-flash'); + + expect(settings).toHaveLength(5); + expect(settings).toContainEqual({ + category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', + threshold: 'HARM_BLOCK_THRESHOLD_UNSPECIFIED', + }); + }); + + it('should return undefined when GOOGLE_EXCLUDE_SAFETY_SETTINGS is enabled', () => { + process.env.GOOGLE_EXCLUDE_SAFETY_SETTINGS = 'true'; + + const settings = getSafetySettings('gemini-1.5-flash'); + + expect(settings).toBeUndefined(); + }); + + it('should map OFF to BLOCK_NONE for Gemini 1.x models', () => { + process.env.GOOGLE_SAFETY_SEXUALLY_EXPLICIT = 'OFF'; + + const settings = getSafetySettings('gemini-1.5-flash'); + + expect(settings).toContainEqual({ + category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', + threshold: 'BLOCK_NONE', + }); + }); + + it('should use custom thresholds from environment variables', () => { + process.env.GOOGLE_SAFETY_HATE_SPEECH = 'BLOCK_MEDIUM_AND_ABOVE'; + + const settings = getSafetySettings('gemini-1.5-flash'); + + expect(settings).toContainEqual({ + category: 'HARM_CATEGORY_HATE_SPEECH', + threshold: 'BLOCK_MEDIUM_AND_ABOVE', + }); + }); +}); + +describe('knownGoogleParams', () => { + it('should contain essential Google parameters', () => { + expect(knownGoogleParams.has('model')).toBe(true); + expect(knownGoogleParams.has('temperature')).toBe(true); + expect(knownGoogleParams.has('maxOutputTokens')).toBe(true); + expect(knownGoogleParams.has('topP')).toBe(true); + expect(knownGoogleParams.has('topK')).toBe(true); + expect(knownGoogleParams.has('apiKey')).toBe(true); + expect(knownGoogleParams.has('safetySettings')).toBe(true); + }); + + it('should not contain non-Google parameters', () => { + expect(knownGoogleParams.has('max_tokens')).toBe(false); + expect(knownGoogleParams.has('frequency_penalty')).toBe(false); + expect(knownGoogleParams.has('presence_penalty')).toBe(false); + }); +}); diff --git a/packages/api/src/endpoints/google/llm.ts b/packages/api/src/endpoints/google/llm.ts index b486ae6517..289bc0e952 100644 --- a/packages/api/src/endpoints/google/llm.ts +++ b/packages/api/src/endpoints/google/llm.ts @@ -157,15 +157,18 @@ export function getGoogleConfig( let enableWebSearch = web_search; - const llmConfig: GoogleClientOptions | VertexAIClientOptions = removeNullishValues({ - ...(modelOptions || {}), - model: modelOptions?.model ?? '', - maxRetries: 2, - topP: modelOptions?.topP ?? undefined, - topK: modelOptions?.topK ?? undefined, - temperature: modelOptions?.temperature ?? undefined, - maxOutputTokens: modelOptions?.maxOutputTokens ?? undefined, - }); + const llmConfig: GoogleClientOptions | VertexAIClientOptions = removeNullishValues( + { + ...(modelOptions || {}), + model: modelOptions?.model ?? '', + maxRetries: 2, + topP: modelOptions?.topP ?? undefined, + topK: modelOptions?.topK ?? undefined, + temperature: modelOptions?.temperature ?? undefined, + maxOutputTokens: modelOptions?.maxOutputTokens ?? undefined, + }, + true, + ); /** Used only for Safety Settings */ llmConfig.safetySettings = getSafetySettings(llmConfig.model); diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 9a4b627e17..aea1a9bf09 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -903,7 +903,7 @@ export const googleBaseSchema = tConversationSchema.pick({ }); export const googleSchema = googleBaseSchema - .transform((obj: Partial) => removeNullishValues(obj)) + .transform((obj: Partial) => removeNullishValues(obj, true)) .catch(() => ({})); /** @@ -1101,7 +1101,7 @@ export const compactGoogleSchema = googleBaseSchema delete newObj.topK; } - return removeNullishValues(newObj); + return removeNullishValues(newObj, true); }) .catch(() => ({}));