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