diff --git a/api/app/clients/tools/structured/StableDiffusion.spec.js b/api/app/clients/tools/structured/StableDiffusion.spec.js index 6dd7b0dbdd..7f58c4e2a0 100644 --- a/api/app/clients/tools/structured/StableDiffusion.spec.js +++ b/api/app/clients/tools/structured/StableDiffusion.spec.js @@ -3,7 +3,6 @@ const axios = require('axios'); const mockApplySSRFSafeAgentIfDirect = jest.fn(); jest.mock('axios', () => ({ post: jest.fn() }), { virtual: true }); -jest.mock('fs'); jest.mock('sharp', () => jest.fn(), { virtual: true }); jest.mock('uuid', () => ({ v4: jest.fn() }), { virtual: true }); jest.mock('@librechat/data-schemas', () => ({ @@ -24,14 +23,10 @@ jest.mock( }), { virtual: true }, ); -jest.mock( - '@librechat/api', - () => ({ - applySSRFSafeAgentIfDirect: (...args) => mockApplySSRFSafeAgentIfDirect(...args), - getBasePath: jest.fn(), - }), - { virtual: true }, -); +jest.mock('@librechat/api', () => ({ + applySSRFSafeAgentIfDirect: (...args) => mockApplySSRFSafeAgentIfDirect(...args), + getBasePath: jest.fn(), +})); jest.mock('~/config/paths', () => ({}), { virtual: true }); const StableDiffusionAPI = require('./StableDiffusion'); diff --git a/api/server/middleware/__tests__/validateModel.spec.js b/api/server/middleware/__tests__/validateModel.spec.js index 634baeed11..41832c3f8c 100644 --- a/api/server/middleware/__tests__/validateModel.spec.js +++ b/api/server/middleware/__tests__/validateModel.spec.js @@ -1,4 +1,4 @@ -const { ViolationTypes } = require('librechat-data-provider'); +const { EModelEndpoint, Providers, ViolationTypes } = require('librechat-data-provider'); jest.mock('@librechat/api', () => ({ handleError: jest.fn(), @@ -159,6 +159,42 @@ describe('validateModel', () => { expect(handleError).not.toHaveBeenCalled(); }); + it('accepts a Vertex model from the shared Google catalog', async () => { + req.body = { model: 'gemini-3.7-flash', endpoint: Providers.VERTEXAI }; + getEndpointsConfig.mockResolvedValue({ [Providers.VERTEXAI]: { userProvide: false } }); + getModelsConfig.mockResolvedValue({ [EModelEndpoint.google]: ['gemini-3.7-flash'] }); + + await validateModel(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('accepts a model from an exact Vertex AI catalog', async () => { + req.body = { model: 'custom-vertex-model', endpoint: Providers.VERTEXAI }; + getEndpointsConfig.mockResolvedValue({ [Providers.VERTEXAI]: { userProvide: false } }); + getModelsConfig.mockResolvedValue({ + [EModelEndpoint.google]: ['gemini-3.7-flash'], + [Providers.VERTEXAI]: ['custom-vertex-model'], + }); + + await validateModel(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('rejects a Vertex model absent from the shared Google catalog', async () => { + req.body = { model: 'gemini-not-available', endpoint: Providers.VERTEXAI }; + getEndpointsConfig.mockResolvedValue({ [Providers.VERTEXAI]: { userProvide: false } }); + getModelsConfig.mockResolvedValue({ [EModelEndpoint.google]: ['gemini-3.7-flash'] }); + + await validateModel(req, res, next); + + expect(handleError).toHaveBeenCalledWith(res, { text: 'Illegal model request' }); + expect(next).not.toHaveBeenCalled(); + }); + it('rejects when endpoint has no models loaded', async () => { getModelsConfig.mockResolvedValue({ openAI: undefined }); diff --git a/api/server/middleware/validateModel.js b/api/server/middleware/validateModel.js index 71a931f0d1..397c00659d 100644 --- a/api/server/middleware/validateModel.js +++ b/api/server/middleware/validateModel.js @@ -1,5 +1,5 @@ const { handleError } = require('@librechat/api'); -const { ViolationTypes } = require('librechat-data-provider'); +const { resolveModelCatalogKey, ViolationTypes } = require('librechat-data-provider'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { getEndpointsConfig } = require('~/server/services/Config'); const { logViolation } = require('~/cache'); @@ -43,7 +43,7 @@ const validateModel = async (req, res, next) => { return handleError(res, { text: 'Models not loaded' }); } - const availableModels = modelsConfig[endpoint]; + const availableModels = modelsConfig[resolveModelCatalogKey(endpoint, modelsConfig)]; if (!availableModels) { return handleError(res, { text: 'Endpoint models not loaded' }); } diff --git a/api/server/services/Config/loadDefaultModels.spec.js b/api/server/services/Config/loadDefaultModels.spec.js new file mode 100644 index 0000000000..aecd668f58 --- /dev/null +++ b/api/server/services/Config/loadDefaultModels.spec.js @@ -0,0 +1,62 @@ +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + getAnthropicModels: jest.fn(), + getAppConfigOptionsFromUser: jest.fn(), + getBedrockModels: jest.fn(), + getGoogleModels: jest.fn(), + getOpenAIModels: jest.fn(), + mergeHeaders: jest.fn(), +})); +jest.mock('./app'); +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { error: jest.fn() }, +})); + +const { logger } = require('@librechat/data-schemas'); +const { EModelEndpoint, Providers } = require('librechat-data-provider'); +const { + getAnthropicModels, + getBedrockModels, + getGoogleModels, + getOpenAIModels, +} = require('@librechat/api'); +const loadDefaultModels = require('./loadDefaultModels'); + +describe('loadDefaultModels', () => { + const request = { config: {}, user: { id: 'user-1' } }; + + beforeEach(() => { + jest.clearAllMocks(); + getOpenAIModels.mockResolvedValue(['gpt-5']); + getAnthropicModels.mockResolvedValue(['claude-sonnet']); + getBedrockModels.mockReturnValue(['amazon.nova-pro-v1:0']); + getGoogleModels.mockReturnValue(['gemini-3.7-flash']); + }); + + it('returns the Google catalog once under its configured endpoint', async () => { + const models = await loadDefaultModels(request); + + expect(models).toEqual( + expect.objectContaining({ + [EModelEndpoint.openAI]: ['gpt-5'], + [EModelEndpoint.google]: ['gemini-3.7-flash'], + [EModelEndpoint.anthropic]: ['claude-sonnet'], + [EModelEndpoint.bedrock]: ['amazon.nova-pro-v1:0'], + }), + ); + expect(models[Providers.VERTEXAI]).toBeUndefined(); + expect(getGoogleModels).toHaveBeenCalledTimes(1); + }); + + it('keeps the configured Google catalog empty when its model source fails', async () => { + const error = new Error('Google models unavailable'); + getGoogleModels.mockReturnValue(Promise.reject(error)); + + const models = await loadDefaultModels(request); + + expect(models[EModelEndpoint.google]).toEqual([]); + expect(models[Providers.VERTEXAI]).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith('Error getting Google models:', error); + }); +}); diff --git a/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx b/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx index ed3c732719..c717d340e1 100644 --- a/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx +++ b/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx @@ -1,7 +1,12 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useRecoilState } from 'recoil'; import { useQueryClient } from '@tanstack/react-query'; -import { QueryKeys, alternateName, isAgentsEndpoint } from 'librechat-data-provider'; +import { + QueryKeys, + alternateName, + isAgentsEndpoint, + resolveModelCatalogKey, +} from 'librechat-data-provider'; import { Input, Label, @@ -77,7 +82,9 @@ const EditPresetDialog = ({ return; } - const models = modelsConfig[presetEndpoint] as string[] | undefined; + const models = modelsConfig[resolveModelCatalogKey(presetEndpoint, modelsConfig)] as + | string[] + | undefined; if (!models) { return; } diff --git a/client/src/components/Input/ModelSelect/ModelSelect.tsx b/client/src/components/Input/ModelSelect/ModelSelect.tsx index 959fd60ecc..f7a7b9c77b 100644 --- a/client/src/components/Input/ModelSelect/ModelSelect.tsx +++ b/client/src/components/Input/ModelSelect/ModelSelect.tsx @@ -1,3 +1,4 @@ +import { resolveModelCatalogKey } from 'librechat-data-provider'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; import type { TConversation } from 'librechat-data-provider'; import type { TSetOption } from '~/common'; @@ -29,7 +30,7 @@ export default function ModelSelect({ } const { endpoint: _endpoint, endpointType } = conversation; - const models = modelsQuery.data?.[_endpoint] ?? []; + const models = modelsQuery.data?.[resolveModelCatalogKey(_endpoint, modelsQuery.data)] ?? []; const endpoint = endpointType ?? _endpoint; const OptionComponent = multiChatOptions[endpoint]; diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index a6389ae9fc..a3edd7f78d 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -13,6 +13,7 @@ import { LocalStorageKeys, PermissionBits, removeCodeExecutionCaller, + resolveModelCatalogKey, resolveStatefulCodeEnvironment, isAssistantsEndpoint, } from 'librechat-data-provider'; @@ -613,7 +614,7 @@ export default function AgentPanel() { status: 'error', }); } - if (!(models[provider] ?? []).includes(model)) { + if (!(models[resolveModelCatalogKey(provider, models)] ?? []).includes(model)) { return showToast({ message: localize('com_error_model_not_found'), status: 'error', diff --git a/client/src/components/SidePanel/Agents/ModelPanel.test.tsx b/client/src/components/SidePanel/Agents/ModelPanel.test.tsx index fe0c8fddad..4754b83242 100644 --- a/client/src/components/SidePanel/Agents/ModelPanel.test.tsx +++ b/client/src/components/SidePanel/Agents/ModelPanel.test.tsx @@ -2,6 +2,7 @@ * @jest-environment jsdom */ import React from 'react'; +import { Providers } from 'librechat-data-provider'; import { FormProvider, useForm } from 'react-hook-form'; import { fireEvent, render } from '@testing-library/react'; import type { AgentForm } from '~/common'; @@ -146,6 +147,54 @@ describe('ModelPanel', () => { expect(localStorage.getItem('lastAgentModel')).toBe('alternate-model'); }); + it('selects the Google catalog for a Vertex AI provider', () => { + const providers = [ + { label: 'Original', value: 'original' }, + { label: 'Vertex AI', value: Providers.VERTEXAI }, + ]; + const { getByTestId } = render( + , + ); + + fireEvent.click(getByTestId(`com_ui_provider-${Providers.VERTEXAI}`)); + + expect(getByTestId('com_ui_model-selected')).toHaveTextContent('gemini-3.7-flash'); + expect(localStorage.getItem('lastAgentProvider')).toBe(Providers.VERTEXAI); + expect(localStorage.getItem('lastAgentModel')).toBe('gemini-3.7-flash'); + }); + + it('selects an exact Vertex AI catalog when configured', () => { + const providers = [ + { label: 'Original', value: 'original' }, + { label: 'Vertex AI', value: Providers.VERTEXAI }, + ]; + const { getByTestId } = render( + , + ); + + fireEvent.click(getByTestId(`com_ui_provider-${Providers.VERTEXAI}`)); + + expect(getByTestId('com_ui_model-selected')).toHaveTextContent('custom-vertex-model'); + expect(localStorage.getItem('lastAgentProvider')).toBe(Providers.VERTEXAI); + expect(localStorage.getItem('lastAgentModel')).toBe('custom-vertex-model'); + }); + it('preserves the model when the current provider is selected again', () => { const { getByTestId } = render( (provider ? (modelsData[provider] ?? []) : []), + () => (provider ? (modelsData[resolveModelCatalogKey(provider, modelsData)] ?? []) : []), [modelsData, provider], ); const modelsPending = !modelsReady && !modelsError; @@ -158,7 +159,8 @@ export default function ModelPanel({ if (value === provider) { return; } - const nextModel = modelsData[value]?.[0] ?? ''; + const nextModel = + modelsData[resolveModelCatalogKey(value, modelsData)]?.[0] ?? ''; field.onChange(value); setValue('model', nextModel); localStorage.setItem(LocalStorageKeys.LAST_AGENT_PROVIDER, value); diff --git a/client/src/hooks/Chat/useTokenLimits.ts b/client/src/hooks/Chat/useTokenLimits.ts index e2454aae77..b7a96614ca 100644 --- a/client/src/hooks/Chat/useTokenLimits.ts +++ b/client/src/hooks/Chat/useTokenLimits.ts @@ -1,15 +1,9 @@ import { useMemo } from 'react'; -import { Providers, EModelEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; +import { isAgentsEndpoint, resolveModelCatalogKey } from 'librechat-data-provider'; import type { TConversation, TModelTokenomics } from 'librechat-data-provider'; import { useGetStartupConfig, useTokenConfigQuery, useGetAgentByIdQuery } from '~/data-provider'; import { getModelSpec } from '~/utils'; -/** Gemini tokenomics are advertised under the `google` endpoint, so a - * Vertex-backed agent (`provider: 'vertexai'`) must look up there. */ -function normalizeTokenConfigKey(endpoint: string): string { - return endpoint === Providers.VERTEXAI ? EModelEndpoint.google : endpoint; -} - export interface TokenLimits { /** Statically resolved max context; live snapshots override this at run time */ maxContextTokens?: number; @@ -52,7 +46,7 @@ export default function useTokenLimits(conversation: TConversation | null): Toke lookupEndpoint = specPreset.endpoint ?? lookupEndpoint; lookupModel = lookupModel || (specPreset.model ?? ''); } - lookupEndpoint = normalizeTokenConfigKey(lookupEndpoint); + lookupEndpoint = resolveModelCatalogKey(lookupEndpoint, tokenConfig); const rates = tokenConfig?.[lookupEndpoint]?.[lookupModel]; const maxContextTokens = diff --git a/client/src/hooks/Conversations/useDefaultConvo.ts b/client/src/hooks/Conversations/useDefaultConvo.ts index 697854924b..2702214d4e 100644 --- a/client/src/hooks/Conversations/useDefaultConvo.ts +++ b/client/src/hooks/Conversations/useDefaultConvo.ts @@ -1,6 +1,10 @@ import { useCallback } from 'react'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; -import { excludedKeys, getDefaultParamsEndpoint } from 'librechat-data-provider'; +import { + excludedKeys, + getDefaultParamsEndpoint, + resolveModelCatalogKey, +} from 'librechat-data-provider'; import type { TEndpointsConfig, TModelsConfig, @@ -30,7 +34,7 @@ const useDefaultConvo = () => { endpointsConfig, }); - const models = modelsConfig[endpoint ?? ''] || []; + const models = modelsConfig[resolveModelCatalogKey(endpoint, modelsConfig)] || []; const conversation = { ..._convo }; if (cleanInput === true) { for (const key in conversation) { diff --git a/client/src/utils/agentModelSelection.ts b/client/src/utils/agentModelSelection.ts index 067d219680..0829c1972d 100644 --- a/client/src/utils/agentModelSelection.ts +++ b/client/src/utils/agentModelSelection.ts @@ -1,3 +1,5 @@ +import { resolveModelCatalogKey } from 'librechat-data-provider'; + type ProviderOption = string | { value?: string | number | null }; export function getAvailableModelSelection(model: string, models: readonly string[]): string { @@ -16,7 +18,7 @@ export function getAvailableAgentSelection({ models: Record; }): { provider: string; model: string } { const providerExists = - models[provider] != null && + models[resolveModelCatalogKey(provider, models)] != null && providers.some((option) => typeof option === 'string' ? option === provider : option.value === provider, ); @@ -27,6 +29,9 @@ export function getAvailableAgentSelection({ return { provider, - model: getAvailableModelSelection(model, models[provider] ?? []), + model: getAvailableModelSelection( + model, + models[resolveModelCatalogKey(provider, models)] ?? [], + ), }; } diff --git a/packages/api/src/agents/validation.spec.ts b/packages/api/src/agents/validation.spec.ts index d283ce3680..cced15f646 100644 --- a/packages/api/src/agents/validation.spec.ts +++ b/packages/api/src/agents/validation.spec.ts @@ -1,10 +1,19 @@ import { + EModelEndpoint, MAX_SUBAGENTS, setMaxSubagents, MAX_SUBAGENT_GRAPH_NODES, MAX_GRAPH_SUBAGENT_MEMBERS, + Providers, } from 'librechat-data-provider'; -import { agentCreateSchema, agentUpdateSchema, agentSubagentsSchema } from './validation'; +import type { Agent } from 'librechat-data-provider'; +import type { Request, Response } from 'express'; +import { + agentCreateSchema, + agentUpdateSchema, + agentSubagentsSchema, + validateAgentModel, +} from './validation'; describe('agentSubagentsSchema', () => { const graph = { @@ -343,3 +352,56 @@ describe('agentUpdateSchema with subagents', () => { }); }); }); + +describe('validateAgentModel', () => { + const request = {} as Request; + const response = {} as Response; + const logViolation = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + logViolation.mockClear(); + }); + + it('uses the Google catalog for a Vertex AI agent', async () => { + const result = await validateAgentModel({ + req: request, + res: response, + agent: { provider: Providers.VERTEXAI, model: 'gemini-3.7-flash' } as Agent, + modelsConfig: { [EModelEndpoint.google]: ['gemini-3.7-flash'] }, + logViolation, + }); + + expect(result).toEqual({ isValid: true }); + expect(logViolation).not.toHaveBeenCalled(); + }); + + it('uses an exact Vertex AI catalog when configured', async () => { + const result = await validateAgentModel({ + req: request, + res: response, + agent: { provider: Providers.VERTEXAI, model: 'custom-vertex-model' } as Agent, + modelsConfig: { + [EModelEndpoint.google]: ['gemini-3.7-flash'], + [Providers.VERTEXAI]: ['custom-vertex-model'], + }, + logViolation, + }); + + expect(result).toEqual({ isValid: true }); + expect(logViolation).not.toHaveBeenCalled(); + }); + + it('rejects a model absent from the shared Google catalog', async () => { + const result = await validateAgentModel({ + req: request, + res: response, + agent: { provider: Providers.VERTEXAI, model: 'gemini-not-available' } as Agent, + modelsConfig: { [EModelEndpoint.google]: ['gemini-3.7-flash'] }, + logViolation, + }); + + expect(result.isValid).toBe(false); + expect(result.error?.message).toContain('illegal_model_request'); + expect(logViolation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts index ba1a94c696..727cf179d3 100644 --- a/packages/api/src/agents/validation.ts +++ b/packages/api/src/agents/validation.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { MemoryScope, getMaxSubagents, + resolveModelCatalogKey, ViolationTypes, ErrorTypes, MAX_SUBAGENT_GRAPH_NODES, @@ -923,7 +924,7 @@ export async function validateAgentModel( }; } - const availableModels = modelsConfig[endpoint]; + const availableModels = modelsConfig[resolveModelCatalogKey(endpoint, modelsConfig)]; if (!availableModels) { return { isValid: false, diff --git a/packages/data-provider/specs/providers.spec.ts b/packages/data-provider/specs/providers.spec.ts index b4233740ec..0c6dddef62 100644 --- a/packages/data-provider/specs/providers.spec.ts +++ b/packages/data-provider/specs/providers.spec.ts @@ -1,10 +1,11 @@ import { ProviderId, resolveProviderId, + resolveModelCatalogKey, endpointToProvider, knownEndpointToProvider, } from '../src/providers'; -import { EModelEndpoint } from '../src/schemas'; +import { EModelEndpoint, Providers } from '../src/schemas'; import { KnownEndpoints } from '../src/config'; describe('ProviderId', () => { @@ -78,3 +79,23 @@ describe('mapping tables', () => { expect(knownEndpointToProvider[KnownEndpoints['together.ai']]).toBe(ProviderId.together); }); }); + +describe('resolveModelCatalogKey', () => { + it('uses the Google endpoint catalog for Vertex AI', () => { + expect(resolveModelCatalogKey(Providers.VERTEXAI)).toBe(EModelEndpoint.google); + }); + + it('preserves an exact Vertex AI catalog over the native fallback', () => { + expect( + resolveModelCatalogKey(Providers.VERTEXAI, { + [EModelEndpoint.google]: ['gemini-3.7-flash'], + [Providers.VERTEXAI]: ['custom-vertex-model'], + }), + ).toBe(Providers.VERTEXAI); + }); + + it('preserves providers that own their own catalog', () => { + expect(resolveModelCatalogKey(EModelEndpoint.google)).toBe(EModelEndpoint.google); + expect(resolveModelCatalogKey('custom-provider')).toBe('custom-provider'); + }); +}); diff --git a/packages/data-provider/src/providers.ts b/packages/data-provider/src/providers.ts index 3a38ed7fa1..e43e58abda 100644 --- a/packages/data-provider/src/providers.ts +++ b/packages/data-provider/src/providers.ts @@ -1,4 +1,4 @@ -import { EModelEndpoint } from './schemas'; +import { EModelEndpoint, Providers } from './schemas'; import { KnownEndpoints } from './config'; /** Canonical provider identity used for branding across client and server. */ @@ -78,6 +78,10 @@ const providerAliases: Record = { togetherai: ProviderId.together, }; +const modelCatalogAliases: Partial> = { + [Providers.VERTEXAI]: EModelEndpoint.google, +}; + const normalize = (input: string): string => input.toLowerCase().replace(/[\s._-]/g, ''); const providerByNormalizedId = Object.values(ProviderId).reduce>( @@ -96,3 +100,12 @@ export function resolveProviderId(input?: string | null): ProviderId | null { const key = normalize(input); return providerByNormalizedId[key] ?? providerAliases[key] ?? null; } + +/** Resolves a runtime provider to its model catalog, using a native alias only when needed. */ +export function resolveModelCatalogKey( + provider?: string | null, + catalogs?: Partial>, +): string { + const key = provider ?? ''; + return catalogs?.[key] != null ? key : (modelCatalogAliases[key] ?? key); +}