mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🌐 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:
parent
3d808dc906
commit
8b1fcc0fc2
16 changed files with 287 additions and 34 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
62
api/server/services/Config/loadDefaultModels.spec.js
Normal file
62
api/server/services/Config/loadDefaultModels.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)] ?? [],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue