diff --git a/.env.example b/.env.example index c87c160dfe..9709d53079 100644 --- a/.env.example +++ b/.env.example @@ -419,10 +419,10 @@ GOOGLE_KEY=user_provided # GOOGLE_AUTH_HEADER=true # Gemini API (AI Studio) -# GOOGLE_MODELS=gemini-3.6-flash,gemini-3.5-flash,gemini-3.5-flash-lite,gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite +# GOOGLE_MODELS=gemini-3.7-flash,gemini-3.6-flash,gemini-3.5-flash,gemini-3.5-flash-lite,gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite # Vertex AI -# GOOGLE_MODELS=gemini-3.6-flash,gemini-3.5-flash,gemini-3.5-flash-lite,gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001 +# GOOGLE_MODELS=gemini-3.7-flash,gemini-3.6-flash,gemini-3.5-flash,gemini-3.5-flash-lite,gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001 # GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001 diff --git a/api/package.json b/api/package.json index 3c7d530d15..3fadd61ef0 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.4.6", + "@librechat/agents": "^3.4.7", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 97bfdf023c..73f10239be 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -356,6 +356,9 @@ describe('getModelMaxTokens', () => { expect(getModelMaxTokens('gemini-3.6-flash', EModelEndpoint.google)).toBe( maxTokensMap[EModelEndpoint.google]['gemini-3.6-flash'], ); + expect(getModelMaxTokens('gemini-3.7-flash', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini-3.7-flash'], + ); expect(getModelMaxTokens('gemini-2.5-pro', EModelEndpoint.google)).toBe( maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'], ); diff --git a/package-lock.json b/package-lock.json index c01c2b7ad0..e8a2e6c600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.4.6", + "@librechat/agents": "^3.4.7", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -10628,9 +10628,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.4.6", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.4.6.tgz", - "integrity": "sha512-ZgFNhOGZpFUGZVh+K+E18SzV53iXbmnhN8Z+B/5KbIPwrZ3WLXw9whJ2xCpVpDk0VBGy9oZu8SSPD4cl0eUiQQ==", + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.4.7.tgz", + "integrity": "sha512-0JPTM/rYjudtl6uhVbAMDyq1vxa40oeacVLWuEF+S611hwpO9kuXho+X4+XbTrRAGTzkGJrYBsvc/ddzxtEBAA==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.115.0", @@ -42849,7 +42849,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.4.6", + "@librechat/agents": "^3.4.7", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/package.json b/packages/api/package.json index d1194a55d3..04197a3245 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -113,7 +113,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.4.6", + "@librechat/agents": "^3.4.7", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/src/endpoints/google/llm.spec.ts b/packages/api/src/endpoints/google/llm.spec.ts index c370602fc3..9a5daa5a32 100644 --- a/packages/api/src/endpoints/google/llm.spec.ts +++ b/packages/api/src/endpoints/google/llm.spec.ts @@ -964,6 +964,111 @@ describe('getGoogleConfig', () => { expect(result.llmConfig).not.toHaveProperty('thinking_budget'); }); + it('should default Gemini 3.7 Flash to medium thinkingLevel', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3.7-flash', + }, + }); + + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + includeThoughts: true, + thinkingLevel: 'MEDIUM', + }); + }); + + it('should remove legacy sampling params for Gemini 3.7 Flash', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const modelOptions = { + model: 'gemini-3.7-flash', + temperature: 0.7, + topP: 0.9, + topK: 40, + top_p: 0.9, + top_k: 40, + presencePenalty: 0.5, + frequencyPenalty: 0.5, + thinking_budget: 5000, + } as unknown as t.GoogleParameters; + + const result = getGoogleConfig(credentials, { modelOptions }); + + expect(result.llmConfig).not.toHaveProperty('temperature'); + expect(result.llmConfig).not.toHaveProperty('topP'); + expect(result.llmConfig).not.toHaveProperty('topK'); + expect(result.llmConfig).not.toHaveProperty('top_p'); + expect(result.llmConfig).not.toHaveProperty('top_k'); + expect(result.llmConfig).not.toHaveProperty('presencePenalty'); + expect(result.llmConfig).not.toHaveProperty('frequencyPenalty'); + expect(result.llmConfig).not.toHaveProperty('thinking_budget'); + }); + + it.each([ + [ThinkingLevel.low, 'LOW'], + [ThinkingLevel.medium, 'MEDIUM'], + [ThinkingLevel.high, 'HIGH'], + ])('should preserve explicit Gemini 3.7 Flash thinkingLevel "%s"', (level, expected) => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3.7-flash', + thinkingLevel: level, + }, + }); + + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + includeThoughts: true, + thinkingLevel: expected, + }); + }); + + it('should substitute minimal thinkingLevel with low for Gemini 3.7 Flash', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3.7-flash', + thinkingLevel: ThinkingLevel.minimal, + }, + }); + + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + includeThoughts: true, + thinkingLevel: 'LOW', + }); + }); + + it('should apply Gemini 3.7 Flash handling to versioned aliases', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'models/gemini-3.7-flash-latest', + temperature: 0.7, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('temperature'); + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + includeThoughts: true, + thinkingLevel: 'MEDIUM', + }); + }); + it('should remove unsupported penalty params for Gemini 3.5 Flash-Lite', () => { const credentials = { [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', diff --git a/packages/api/src/endpoints/google/llm.ts b/packages/api/src/endpoints/google/llm.ts index a46450deda..d793c16788 100644 --- a/packages/api/src/endpoints/google/llm.ts +++ b/packages/api/src/endpoints/google/llm.ts @@ -13,6 +13,15 @@ type GoogleThinkingConfig = { thinkingLevel?: GoogleThinkingLevel; }; +type GeminiFlashThinkingRule = { + /** Model id, matched exactly or as a `${id}-` prefix for versioned aliases */ + id: string; + /** Thinking level applied when the request doesn't set one */ + default: GoogleThinkingLevel; + /** Levels the model rejects, mapped to the nearest level it accepts */ + substitutions?: Partial>; +}; + /** * Gemini Flash models (3.5+) that drop the deprecated sampling parameters * (`temperature`/`topP`/`topK`) and `thinkingBudget` in favor of the qualitative @@ -22,12 +31,17 @@ type GoogleThinkingConfig = { * default thinking level when the request doesn't set one. Ordered * most-specific-first so `gemini-3.5-flash-lite` resolves before the * `gemini-3.5-flash` prefix. + * + * Gemini 3.7 Flash supports only `low`/`medium`/`high` and errors on `minimal`, + * so an explicit `minimal` is substituted with the nearest supported level. * @see https://ai.google.dev/gemini-api/docs/latest-model#api-changes-and-parameter-updates + * @see https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash */ -const geminiFlashThinkingDefaults: ReadonlyArray = [ - ['gemini-3.6-flash', 'MEDIUM'], - ['gemini-3.5-flash-lite', 'MINIMAL'], - ['gemini-3.5-flash', 'MEDIUM'], +const geminiFlashThinkingRules: ReadonlyArray = [ + { id: 'gemini-3.7-flash', default: 'MEDIUM', substitutions: { MINIMAL: 'LOW' } }, + { id: 'gemini-3.6-flash', default: 'MEDIUM' }, + { id: 'gemini-3.5-flash-lite', default: 'MINIMAL' }, + { id: 'gemini-3.5-flash', default: 'MEDIUM' }, ]; const geminiFlashLegacyParams = [ @@ -149,12 +163,12 @@ function normalizeGoogleThinkingLevel(value: unknown): GoogleThinkingLevel | und return normalized; } -function getGeminiFlashDefaultThinkingLevel(model: string): GoogleThinkingLevel | undefined { +function getGeminiFlashThinkingRule(model: string): GeminiFlashThinkingRule | undefined { const normalized = model.toLowerCase(); const modelId = normalized.split('/').pop() ?? normalized; - for (const [id, level] of geminiFlashThinkingDefaults) { - if (modelId === id || modelId.startsWith(`${id}-`)) { - return level; + for (const rule of geminiFlashThinkingRules) { + if (modelId === rule.id || modelId.startsWith(`${rule.id}-`)) { + return rule; } } return undefined; @@ -173,7 +187,7 @@ export function stripGeminiFlashBlockedParams params: T, model: string | undefined, ): T { - if (params == null || getGeminiFlashDefaultThinkingLevel(model ?? '') == null) { + if (params == null || getGeminiFlashThinkingRule(model ?? '') == null) { return params; } const sanitized = { ...params }; @@ -233,8 +247,8 @@ function applyGeminiFlashOverrides({ if (typeof model !== 'string') { return; } - const defaultThinkingLevel = getGeminiFlashDefaultThinkingLevel(model); - if (!defaultThinkingLevel) { + const thinkingRule = getGeminiFlashThinkingRule(model); + if (!thinkingRule) { return; } @@ -268,8 +282,14 @@ function applyGeminiFlashOverrides({ thinkingConfig.includeThoughts = true; } - if (!shouldDropThinkingLevel && !thinkingConfig.thinkingLevel) { - thinkingConfig.thinkingLevel = defaultThinkingLevel; + if (!shouldDropThinkingLevel) { + if (!thinkingConfig.thinkingLevel) { + thinkingConfig.thinkingLevel = thinkingRule.default; + } + const substitute = thinkingRule.substitutions?.[thinkingConfig.thinkingLevel]; + if (substitute) { + thinkingConfig.thinkingLevel = substitute; + } } if (Object.keys(thinkingConfig).length > 0) { diff --git a/packages/api/src/endpoints/openai/config.google.spec.ts b/packages/api/src/endpoints/openai/config.google.spec.ts index e112929767..818cf85587 100644 --- a/packages/api/src/endpoints/openai/config.google.spec.ts +++ b/packages/api/src/endpoints/openai/config.google.spec.ts @@ -153,37 +153,40 @@ describe('getOpenAIConfig - Google Compatibility', () => { expect(result.tools).toEqual([]); }); - it('should strip Flash-blocked addParams so the transform cannot re-add them', () => { - const apiKey = JSON.stringify({ GOOGLE_API_KEY: 'test-google-key' }); - const endpoint = 'Gemini (Custom)'; - const options = { - modelOptions: { - model: 'gemini-3.6-flash', - }, - customParams: { - defaultParamsEndpoint: 'google', - }, - addParams: { - temperature: 0.8, - topP: 0.95, - topK: 40, - presencePenalty: 0.5, - frequencyPenalty: 0.5, - maxOutputTokens: 8192, // Supported Google param, should survive - }, - reverseProxyUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', - }; + it.each(['gemini-3.6-flash', 'gemini-3.7-flash'])( + 'should strip Flash-blocked addParams so the transform cannot re-add them (%s)', + (model) => { + const apiKey = JSON.stringify({ GOOGLE_API_KEY: 'test-google-key' }); + const endpoint = 'Gemini (Custom)'; + const options = { + modelOptions: { + model, + }, + customParams: { + defaultParamsEndpoint: 'google', + }, + addParams: { + temperature: 0.8, + topP: 0.95, + topK: 40, + presencePenalty: 0.5, + frequencyPenalty: 0.5, + maxOutputTokens: 8192, // Supported Google param, should survive + }, + reverseProxyUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', + }; - const result = getOpenAIConfig(apiKey, options, endpoint); + const result = getOpenAIConfig(apiKey, options, endpoint); - expect(result.llmConfig).not.toHaveProperty('temperature'); - expect(result.llmConfig).not.toHaveProperty('topP'); - expect(result.llmConfig).not.toHaveProperty('presencePenalty'); - expect(result.llmConfig).not.toHaveProperty('frequencyPenalty'); - expect(result.llmConfig.modelKwargs ?? {}).not.toHaveProperty('topK'); - expect(result.llmConfig.modelKwargs ?? {}).not.toHaveProperty('presencePenalty'); - expect(result.llmConfig.modelKwargs).toMatchObject({ maxOutputTokens: 8192 }); - }); + expect(result.llmConfig).not.toHaveProperty('temperature'); + expect(result.llmConfig).not.toHaveProperty('topP'); + expect(result.llmConfig).not.toHaveProperty('presencePenalty'); + expect(result.llmConfig).not.toHaveProperty('frequencyPenalty'); + expect(result.llmConfig.modelKwargs ?? {}).not.toHaveProperty('topK'); + expect(result.llmConfig.modelKwargs ?? {}).not.toHaveProperty('presencePenalty'); + expect(result.llmConfig.modelKwargs).toMatchObject({ maxOutputTokens: 8192 }); + }, + ); it('should drop Google native params with dropParams', () => { const apiKey = JSON.stringify({ GOOGLE_API_KEY: 'test-google-key' }); diff --git a/packages/api/src/utils/tokens.spec.ts b/packages/api/src/utils/tokens.spec.ts index 9d89d674d2..a6e4cb0e78 100644 --- a/packages/api/src/utils/tokens.spec.ts +++ b/packages/api/src/utils/tokens.spec.ts @@ -47,3 +47,16 @@ describe('gpt-5.6 tiers', () => { expect(getModelMaxTokens('gpt-5', EModelEndpoint.openAI)).toBe(400000); }); }); + +describe('Gemini 3.7 Flash', () => { + it('resolves the 1,048,576-token context window', () => { + expect(getModelMaxTokens('gemini-3.7-flash', EModelEndpoint.google)).toBe(1048576); + }); + + it('matches the longest key over the shorter gemini-3 pattern for aliases', () => { + expect(getModelMaxTokens('models/gemini-3.7-flash-latest', EModelEndpoint.google)).toBe( + 1048576, + ); + expect(getModelMaxTokens('gemini-3', EModelEndpoint.google)).toBe(1000000); + }); +}); diff --git a/packages/api/src/utils/tokens.ts b/packages/api/src/utils/tokens.ts index 5b44a06047..1b1dafa7cb 100644 --- a/packages/api/src/utils/tokens.ts +++ b/packages/api/src/utils/tokens.ts @@ -135,6 +135,7 @@ const googleModels = { 'gemini-3.5-flash': 1048576, 'gemini-3.5-flash-lite': 1048576, 'gemini-3.6-flash': 1048576, + 'gemini-3.7-flash': 1048576, }; const anthropicModels = { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 4b2ed75743..0e71a2062c 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2271,6 +2271,8 @@ export const defaultModels = { [EModelEndpoint.assistants]: [...sharedOpenAIModels, 'chatgpt-4o-latest'], [EModelEndpoint.agents]: sharedOpenAIModels, // TODO: Add agent models (agentsModels) [EModelEndpoint.google]: [ + // Gemini 3.7 Models + 'gemini-3.7-flash', // Gemini 3.6 Models 'gemini-3.6-flash', // Gemini 3.5 Models diff --git a/packages/data-schemas/src/methods/tx.spec.ts b/packages/data-schemas/src/methods/tx.spec.ts index 559595fadc..1a0e8e9483 100644 --- a/packages/data-schemas/src/methods/tx.spec.ts +++ b/packages/data-schemas/src/methods/tx.spec.ts @@ -1526,6 +1526,7 @@ describe('Google Model Tests', () => { 'gemini-3.1-pro-preview', 'gemini-3.1-pro-preview-customtools', 'gemini-3.1-flash-lite-preview', + 'gemini-3.7-flash', 'gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.5-flash-lite', @@ -1576,6 +1577,7 @@ describe('Google Model Tests', () => { 'gemini-3.1-pro-preview': 'gemini-3.1', 'gemini-3.1-pro-preview-customtools': 'gemini-3.1', 'gemini-3.1-flash-lite-preview': 'gemini-3.1-flash-lite', + 'gemini-3.7-flash': 'gemini-3.7-flash', 'gemini-3.6-flash': 'gemini-3.6-flash', 'gemini-3.5-flash': 'gemini-3.5-flash', 'gemini-3.5-flash-lite': 'gemini-3.5-flash-lite', @@ -1713,6 +1715,35 @@ describe('Google Model Tests', () => { ); }); + it('should return correct rates for Gemini 3.7 Flash', () => { + const model = 'gemini-3.7-flash'; + expect(getMultiplier({ model, tokenType: 'prompt', endpoint: EModelEndpoint.google })).toBe( + tokenValues['gemini-3.7-flash'].prompt, + ); + expect(getMultiplier({ model, tokenType: 'completion', endpoint: EModelEndpoint.google })).toBe( + tokenValues['gemini-3.7-flash'].completion, + ); + expect(getCacheMultiplier({ model, cacheType: 'write' })).toBe( + cacheTokenValues['gemini-3.7-flash'].write, + ); + expect(getCacheMultiplier({ model, cacheType: 'read' })).toBe( + cacheTokenValues['gemini-3.7-flash'].read, + ); + }); + + it('should apply the introductory Flash rates to Gemini 3.6 and 3.7 Flash', () => { + for (const model of ['gemini-3.6-flash', 'gemini-3.7-flash']) { + expect(getMultiplier({ model, tokenType: 'prompt', endpoint: EModelEndpoint.google })).toBe( + 0.75, + ); + expect( + getMultiplier({ model, tokenType: 'completion', endpoint: EModelEndpoint.google }), + ).toBe(3.75); + expect(getCacheMultiplier({ model, cacheType: 'write' })).toBe(0.75); + expect(getCacheMultiplier({ model, cacheType: 'read' })).toBe(0.075); + } + }); + it('should return correct rates for Gemini 3.5 Flash-Lite', () => { const model = 'gemini-3.5-flash-lite'; expect(getMultiplier({ model, tokenType: 'prompt', endpoint: EModelEndpoint.google })).toBe( diff --git a/packages/data-schemas/src/methods/tx.ts b/packages/data-schemas/src/methods/tx.ts index b90922511a..ae83c2df27 100644 --- a/packages/data-schemas/src/methods/tx.ts +++ b/packages/data-schemas/src/methods/tx.ts @@ -205,7 +205,9 @@ export const tokenValues: Record 'gemini-3.1-flash-lite': { prompt: 0.25, completion: 1.5 }, 'gemini-3.5-flash': { prompt: 1.5, completion: 9 }, 'gemini-3.5-flash-lite': { prompt: 0.3, completion: 2.5 }, - 'gemini-3.6-flash': { prompt: 1.5, completion: 7.5 }, + // Gemini 3.6/3.7 Flash introductory pricing through 2026-12-31; revert to { prompt: 1.5, completion: 7.5 } after. + 'gemini-3.6-flash': { prompt: 0.75, completion: 3.75 }, + 'gemini-3.7-flash': { prompt: 0.75, completion: 3.75 }, 'gemini-pro-vision': { prompt: 0.5, completion: 1.5 }, grok: { prompt: 2.0, completion: 10.0 }, 'grok-beta': { prompt: 5.0, completion: 15.0 }, @@ -374,8 +376,9 @@ export const cacheTokenValues: Record = 'gemini-3.5-flash': { write: 1.5, read: 0.15 }, // Gemini 3.5 Flash-Lite - cache write: $0.30/1M, cache read: $0.03/1M 'gemini-3.5-flash-lite': { write: 0.3, read: 0.03 }, - // Gemini 3.6 Flash - cache write: $1.50/1M, cache read: $0.15/1M - 'gemini-3.6-flash': { write: 1.5, read: 0.15 }, + // Gemini 3.6/3.7 Flash introductory pricing through 2026-12-31; revert to { write: 1.5, read: 0.15 } after. + 'gemini-3.6-flash': { write: 0.75, read: 0.075 }, + 'gemini-3.7-flash': { write: 0.75, read: 0.075 }, }; /**