🌐 fix: Expose Gemini Models to Vertex AI Agents (#15234)

* 🌐 fix: Expose Gemini Models to Vertex AI Agents

* ♻️ refactor: Resolve Shared Vertex Model Catalogs

* fix: Preserve Exact Vertex Model Catalogs

* style: Format Agent Model Selection

* test: Preserve Native FS in Stable Diffusion Spec
This commit is contained in:
Danny Avila 2026-08-27 06:50:23 -04:00 committed by GitHub
parent 3d808dc906
commit 8b1fcc0fc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 287 additions and 34 deletions

View file

@ -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');

View file

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

View file

@ -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' });
}

View file

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

View file

@ -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;
}

View file

@ -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];

View file

@ -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',

View file

@ -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(
<TestForm
defaultProvider="original"
defaultModel="original-model"
models={{ original: ['original-model'], google: ['gemini-3.7-flash'] }}
modelsReady={true}
providers={providers}
/>,
);
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(
<TestForm
defaultProvider="original"
defaultModel="original-model"
models={{
original: ['original-model'],
google: ['gemini-3.7-flash'],
[Providers.VERTEXAI]: ['custom-vertex-model'],
}}
modelsReady={true}
providers={providers}
/>,
);
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(
<TestForm

View file

@ -8,6 +8,7 @@ import {
getSettingsKeys,
getEndpointField,
LocalStorageKeys,
resolveModelCatalogKey,
SettingDefinition,
agentParamSettings,
applyModelAwareDefaults,
@ -58,7 +59,7 @@ export default function ModelPanel({
return value ?? '';
}, [providerOption]);
const models = useMemo(
() => (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);

View file

@ -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 =

View file

@ -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) {

View file

@ -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<string, string[] | undefined>;
}): { 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)] ?? [],
),
};
}

View file

@ -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<unknown, unknown, unknown>;
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);
});
});

View file

@ -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,

View file

@ -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');
});
});

View file

@ -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<string, ProviderId> = {
togetherai: ProviderId.together,
};
const modelCatalogAliases: Partial<Record<string, string>> = {
[Providers.VERTEXAI]: EModelEndpoint.google,
};
const normalize = (input: string): string => input.toLowerCase().replace(/[\s._-]/g, '');
const providerByNormalizedId = Object.values(ProviderId).reduce<Record<string, ProviderId>>(
@ -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<T>(
provider?: string | null,
catalogs?: Partial<Record<string, T>>,
): string {
const key = provider ?? '';
return catalogs?.[key] != null ? key : (modelCatalogAliases[key] ?? key);
}