diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 7b81f5a145..529879ce9a 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -292,6 +292,20 @@ describe('GET /api/config', () => { expect(response.statusCode).toBe(500); expect(response.body).toHaveProperty('error'); }); + + it('should not expose endpointsDropParamsMap to unauthenticated callers', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + endpoints: { + custom: [{ name: 'custom-provider', dropParams: ['temperature'] }], + }, + }); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('endpointsDropParamsMap'); + }); }); describe('authenticated (req.user exists)', () => { @@ -689,6 +703,95 @@ describe('GET /api/config', () => { }); }); + describe('endpointsDropParamsMap', () => { + it('maps dropParams for array-configured custom endpoints', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + endpoints: { + custom: [ + { name: 'custom-provider', dropParams: ['temperature', 'top_p'] }, + { name: 'no-drop-provider' }, + ], + }, + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.endpointsDropParamsMap).toEqual({ + 'custom-provider': ['temperature', 'top_p'], + }); + }); + + it('normalizes an ollama custom endpoint name to lowercase', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + endpoints: { + custom: [{ name: 'Ollama', dropParams: ['stop'] }], + }, + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.endpointsDropParamsMap).toEqual({ ollama: ['stop'] }); + }); + + it('keeps azureOpenAI dropParams model-specific instead of merging across groups', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + endpoints: { + azureOpenAI: { + groupMap: { + groupA: { dropParams: ['temperature'] }, + groupB: { dropParams: ['temperature', 'top_p'] }, + }, + modelGroupMap: { + 'model-a': { group: 'groupA' }, + 'model-b': { group: 'groupB' }, + }, + }, + }, + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.endpointsDropParamsMap.azureOpenAI).toEqual({ + 'model-a': ['temperature'], + 'model-b': ['temperature', 'top_p'], + }); + }); + + it('excludes endpoints without dropParams and non-param endpoints like agents', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + endpoints: { + custom: [{ name: 'no-drop-provider' }], + azureOpenAI: { + groupMap: { groupA: {} }, + modelGroupMap: { 'model-a': { group: 'groupA' } }, + }, + agents: [{ name: 'agents-provider', dropParams: ['temperature'] }], + }, + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.endpointsDropParamsMap).toEqual({}); + }); + + it('returns an empty map when appConfig has no endpoints', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.endpointsDropParamsMap).toEqual({}); + }); + }); + describe('buildInfo payload', () => { const populatedBuildInfo = { commit: 'abcdef1234567890abcdef1234567890abcdef12', diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 6e16abfd9f..7439ccec9d 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -11,6 +11,7 @@ const { sanitizeModelSpecs, excludeHiddenModelSpecs, isFileSnapshotEnabled, + getEndpointsDropParamsMap, } = require('@librechat/api'); const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider'); const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas'); @@ -249,6 +250,8 @@ router.get('/', async function (req, res) { const appConfig = await getAppConfig(getAppConfigOptionsFromUser(req.user)); + const endpointsDropParamsMap = getEndpointsDropParamsMap(appConfig?.endpoints); + const balanceConfig = getBalanceConfig(appConfig); const cloudFront = buildCloudFrontStartupConfig(); const langfuseFanoutEnabled = isLangfuseFanoutEnabled(); @@ -308,6 +311,7 @@ router.get('/', async function (req, res) { ...(cloudFront ? { cloudFront } : {}), ...(rum ? { rum } : {}), fileUploadSseEnabled: isEnabled(process.env.FILE_UPLOAD_SSE_ENABLED), + endpointsDropParamsMap: endpointsDropParamsMap, }; const webSearch = buildWebSearchConfig(appConfig); diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 3ea8cfc854..481ed519eb 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -19,6 +19,7 @@ import { import type { Agent, AgentUpdateParams } from 'librechat-data-provider'; import type { FieldNamesMarkedBoolean } from 'react-hook-form'; import type { TranslationKeys } from '~/hooks/useLocalize'; +import type { AgentParameterConfig } from './parameters'; import type { AgentForm, StringOption } from '~/common'; import { useCreateAgentMutation, @@ -32,6 +33,7 @@ import { getAvailableAgentSelection, getDefaultAgentFormValues, } from '~/utils'; +import { pruneAgentModelParameters, resolveAgentParameterSettings } from './parameters'; import { useResourcePermissions } from '~/hooks/useResourcePermissions'; import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks'; import { useAgentPanelContext } from '~/Providers/AgentPanelContext'; @@ -68,14 +70,18 @@ function getUpdateToastMessage( * @param {string | null} [agent_id] - Agent identifier, if the agent already exists. * @returns {{ payload: Partial; provider: string; model: string }} Payload metadata. */ -export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | null) { +export function composeAgentUpdatePayload( + data: AgentForm, + agent_id?: string | null, + parameterConfig?: AgentParameterConfig, +) { const { name, artifacts, description, instructions, model: _model, - model_parameters, + model_parameters: currentModelParameters, provider: _provider, agent_ids, edges, @@ -110,6 +116,12 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n const model = _model ?? ''; const provider = (typeof _provider === 'string' ? _provider : (_provider as StringOption).value) ?? ''; + const modelParameterSettings = parameterConfig + ? resolveAgentParameterSettings({ ...parameterConfig, model, provider }) + : undefined; + const model_parameters = modelParameterSettings + ? pruneAgentModelParameters(currentModelParameters, modelParameterSettings) + : currentModelParameters; return { payload: { @@ -297,6 +309,7 @@ export default function AgentPanel() { const { activePanel, agentsConfig, + startupConfig, setActivePanel, endpointsConfig, setCurrentAgentId, @@ -570,7 +583,14 @@ export default function AgentPanel() { async (data: AgentForm) => { const tools = Array.from(new Set([...(data.tools ?? []), ...resolveCapabilityTools(data)])); - const { payload: basePayload, provider, model } = composeAgentUpdatePayload(data, agent_id); + const { + payload: basePayload, + provider, + model, + } = composeAgentUpdatePayload(data, agent_id, { + endpointsConfig, + startupConfig, + }); if (agent_id) { if (data.avatar_action === 'upload' && isAvatarUploadOnlyDirty(dirtyFields)) { @@ -626,12 +646,14 @@ export default function AgentPanel() { agent_id, create, dirtyFields, + endpointsConfig, handleAvatarUpload, models, modelsError, modelsReady, update, showToast, + startupConfig, localize, ], ); diff --git a/client/src/components/SidePanel/Agents/ModelPanel.test.tsx b/client/src/components/SidePanel/Agents/ModelPanel.test.tsx index 4754b83242..c7c2fbb657 100644 --- a/client/src/components/SidePanel/Agents/ModelPanel.test.tsx +++ b/client/src/components/SidePanel/Agents/ModelPanel.test.tsx @@ -2,12 +2,16 @@ * @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 { fireEvent, render, waitFor } from '@testing-library/react'; +import { Providers, EModelEndpoint, agentParamSettings } from 'librechat-data-provider'; +import type { TStartupConfig } from 'librechat-data-provider'; +import type { UseFormReturn } from 'react-hook-form'; import type { AgentForm } from '~/common'; import ModelPanel from './ModelPanel'; +const mockStartupConfig = jest.fn, []>(() => ({})); + jest.mock('@librechat/client', () => ({ Alert: ({ children }: { children: React.ReactNode }) =>
{children}
, Button: ({ children, onClick, type }: React.ButtonHTMLAttributes) => ( @@ -59,6 +63,7 @@ jest.mock('~/components/SidePanel/Parameters/components', () => ({ jest.mock('~/data-provider', () => ({ useGetEndpointsQuery: () => ({ data: {} }), + useGetStartupConfig: () => ({ data: mockStartupConfig() }), })); jest.mock('~/Providers', () => ({ @@ -75,14 +80,18 @@ jest.mock('~/utils', () => ({ function TestForm({ defaultModel = '', + defaultModelParameters = {}, defaultProvider = '', + formRef, models, modelsError = false, modelsReady, providers = [{ label: 'Custom', value: 'custom' }], }: { defaultModel?: string; + defaultModelParameters?: Partial; defaultProvider?: string; + formRef?: React.MutableRefObject | null>; models: Record; modelsError?: boolean; modelsReady: boolean; @@ -92,10 +101,14 @@ function TestForm({ defaultValues: { provider: defaultProvider, model: defaultModel, - model_parameters: {}, + model_parameters: defaultModelParameters as AgentForm['model_parameters'], }, }); + if (formRef) { + formRef.current = methods; + } + return ( { - beforeEach(() => localStorage.clear()); + beforeEach(() => { + localStorage.clear(); + mockStartupConfig.mockReturnValue({}); + }); it('disables model selection until the model catalogue is ready', () => { const { getByTestId } = render( @@ -258,4 +274,93 @@ describe('ModelPanel', () => { expect(container.querySelector('#provider')).not.toBeNull(); expect(container.querySelector('#model')).not.toBeNull(); }); + + it('prunes a saved model_parameters value once its endpoint drops the matching param', async () => { + mockStartupConfig.mockReturnValue({ + endpointsDropParamsMap: { [EModelEndpoint.openAI]: ['topP'] }, + }); + + const formRef: React.MutableRefObject | null> = { current: null }; + render( + , + ); + + await waitFor(() => { + expect(formRef.current?.getValues('model_parameters')).toEqual({ + model: 'deployment-override', + temperature: 0.5, + }); + }); + }); + + it('keeps a saved model_parameters value while its control is still visible', () => { + const formRef: React.MutableRefObject | null> = { current: null }; + render( + , + ); + + expect(formRef.current?.getValues('model_parameters')).toEqual({ + temperature: 0.5, + top_p: 0.9, + }); + }); + + it('keeps saved model_parameters when the provider has no known parameter settings', () => { + const formRef: React.MutableRefObject | null> = { current: null }; + render( + , + ); + + expect(formRef.current?.getValues('model_parameters')).toEqual({ + temperature: 0.5, + top_p: 0.9, + }); + }); + + it('prunes saved model_parameters when every known setting is dropped', async () => { + mockStartupConfig.mockReturnValue({ + endpointsDropParamsMap: { + [EModelEndpoint.openAI]: (agentParamSettings[EModelEndpoint.openAI] ?? []).map( + ({ key }) => key, + ), + }, + }); + + const formRef: React.MutableRefObject | null> = { current: null }; + render( + , + ); + + await waitFor(() => { + expect(formRef.current?.getValues('model_parameters')).toEqual({}); + }); + }); }); diff --git a/client/src/components/SidePanel/Agents/ModelPanel.tsx b/client/src/components/SidePanel/Agents/ModelPanel.tsx index 17fbaa3381..ed5442641c 100644 --- a/client/src/components/SidePanel/Agents/ModelPanel.tsx +++ b/client/src/components/SidePanel/Agents/ModelPanel.tsx @@ -1,22 +1,13 @@ -import React, { useMemo } from 'react'; -import keyBy from 'lodash/keyBy'; +import React, { useMemo, useEffect } from 'react'; import { ChevronLeft, RotateCcw } from 'lucide-react'; import { Alert, Button, ControlCombobox } from '@librechat/client'; import { useFormContext, useWatch, Controller } from 'react-hook-form'; -import { - alternateName, - getSettingsKeys, - getEndpointField, - LocalStorageKeys, - resolveModelCatalogKey, - SettingDefinition, - agentParamSettings, - applyModelAwareDefaults, -} from 'librechat-data-provider'; +import { alternateName, LocalStorageKeys, resolveModelCatalogKey } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common'; +import { pruneAgentModelParameters, resolveAgentParameterSettings } from './parameters'; import { componentMapping } from '~/components/SidePanel/Parameters/components'; -import { useGetEndpointsQuery } from '~/data-provider'; +import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; import { useLiveAnnouncer } from '~/Providers'; import { useLocalize } from '~/hooks'; import { Panel } from '~/common'; @@ -45,7 +36,7 @@ export default function ModelPanel({ const localize = useLocalize(); const { announcePolite } = useLiveAnnouncer(); - const { control, setValue } = useFormContext(); + const { control, setValue, getValues } = useFormContext(); const model = useWatch({ control, name: 'model' }); const providerOption = useWatch({ control, name: 'provider' }); @@ -66,33 +57,39 @@ export default function ModelPanel({ const selectionDisabled = !modelsReady || modelsError; const { data: endpointsConfig = {} } = useGetEndpointsQuery(); + const { data: startupConfig } = useGetStartupConfig(); const bedrockRegions = useMemo(() => { return endpointsConfig?.[provider]?.availableRegions ?? []; }, [endpointsConfig, provider]); - const endpointType = useMemo( - () => getEndpointField(endpointsConfig, provider, 'type'), - [provider, endpointsConfig], + const parameterSettings = useMemo( + () => + resolveAgentParameterSettings({ + endpointsConfig, + model: model ?? '', + provider, + startupConfig, + }), + [endpointsConfig, model, provider, startupConfig], ); + const { parameters } = parameterSettings; - const parameters = useMemo((): SettingDefinition[] => { - const customParams = endpointsConfig[provider]?.customParams ?? {}; - const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, model ?? ''); - const overriddenEndpointKey = customParams.defaultParamsEndpoint ?? endpointKey; - const defaultParams = - agentParamSettings[combinedKey] ?? agentParamSettings[overriddenEndpointKey] ?? []; - const overriddenParams = endpointsConfig[provider]?.customParams?.paramDefinitions ?? []; - const overriddenParamsMap = keyBy(overriddenParams, 'key'); - const modelAwareParams = applyModelAwareDefaults( - defaultParams.filter((param) => param != null), - overriddenEndpointKey, - model ?? '', - ); - return modelAwareParams.map( - (param) => (overriddenParamsMap[param.key] as SettingDefinition) ?? param, - ); - }, [endpointType, endpointsConfig, model, provider]); + /** + * Prunes `model_parameters` entries that no longer have a visible control (e.g. a + * parameter newly added to the endpoint's `dropParams`), mirroring the conversation + * panel's pruning effect. Otherwise the stale value stays saved and silently reactivates + * if the endpoint config later stops dropping it, with no control to inspect or clear it. + */ + useEffect(() => { + const currentParameters = getValues('model_parameters') ?? ({} as t.AgentModelParameters); + const prunedParameters = pruneAgentModelParameters(currentParameters, parameterSettings); + if (prunedParameters === currentParameters) { + return; + } + + setValue('model_parameters', prunedParameters); + }, [parameterSettings, getValues, setValue]); const setOption = (optionKey: keyof t.AgentModelParameters) => (value: t.AgentParameterValue) => { setValue(`model_parameters.${optionKey}`, value); diff --git a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts index ebfd26e856..ced3a9ec36 100644 --- a/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts +++ b/client/src/components/SidePanel/Agents/__tests__/AgentPanel.helpers.spec.ts @@ -1,7 +1,7 @@ /** * @jest-environment jsdom */ -import { Constants, type Agent } from 'librechat-data-provider'; +import { Constants, EModelEndpoint, type Agent } from 'librechat-data-provider'; import type { AgentModelParameters } from 'librechat-data-provider'; import type { FieldNamesMarkedBoolean } from 'react-hook-form'; import type { AgentForm } from '~/common'; @@ -171,6 +171,35 @@ describe('composeAgentUpdatePayload', () => { expect(payload.skills_enabled).toBe(false); expect(payload.skill_authoring_enabled).toBe(true); }); + + it('prunes dropped model parameters during submission', () => { + const form = createForm(); + form.provider = EModelEndpoint.openAI; + form.model_parameters.model = 'deployment-override'; + + const { payload } = composeAgentUpdatePayload(form, 'agent_123', { + endpointsConfig: {}, + startupConfig: { + endpointsDropParamsMap: { [EModelEndpoint.openAI]: ['topP'] }, + }, + }); + + expect(payload.model_parameters?.temperature).toBe(1); + expect(payload.model_parameters?.top_p).toBeUndefined(); + expect(payload.model_parameters?.model).toBe('deployment-override'); + }); + + it('preserves model parameters during submission when the provider schema is unknown', () => { + const form = createForm(); + form.provider = 'removed-provider'; + + const { payload } = composeAgentUpdatePayload(form, 'agent_123', { + endpointsConfig: {}, + startupConfig: {}, + }); + + expect(payload.model_parameters).toEqual(form.model_parameters); + }); }); describe('persistAvatarChanges', () => { diff --git a/client/src/components/SidePanel/Agents/parameters.ts b/client/src/components/SidePanel/Agents/parameters.ts new file mode 100644 index 0000000000..fe6e4a3fd2 --- /dev/null +++ b/client/src/components/SidePanel/Agents/parameters.ts @@ -0,0 +1,98 @@ +import keyBy from 'lodash/keyBy'; +import { + agentParamSettings, + applyModelAwareDefaults, + getEndpointField, + getSettingsKeys, + normalizeEndpointName, + resolveDropParamsUIKeys, +} from 'librechat-data-provider'; +import type { + AgentModelParameters, + SettingDefinition, + TEndpointsConfig, + TStartupConfig, +} from 'librechat-data-provider'; + +export type AgentParameterConfig = { + endpointsConfig?: TEndpointsConfig | null; + startupConfig?: Pick | null; +}; + +export type ResolvedAgentParameterSettings = { + parameters: SettingDefinition[]; + schemaResolved: boolean; +}; + +const NON_CONTROL_PARAMETER_KEYS = new Set(['model']); + +export function resolveAgentParameterSettings({ + endpointsConfig, + model, + provider, + startupConfig, +}: AgentParameterConfig & { + model: string; + provider: string; +}): ResolvedAgentParameterSettings { + const resolvedEndpointsConfig = endpointsConfig ?? {}; + const endpointType = getEndpointField(resolvedEndpointsConfig, provider, 'type'); + const customParams = resolvedEndpointsConfig[provider]?.customParams; + const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, model); + const overriddenEndpointKey = customParams?.defaultParamsEndpoint ?? endpointKey; + const dropParamsMap = startupConfig?.endpointsDropParamsMap; + const dropParamsEntry = + dropParamsMap?.[provider] ?? dropParamsMap?.[normalizeEndpointName(provider)]; + const resolvedDropParams = Array.isArray(dropParamsEntry) + ? dropParamsEntry + : dropParamsEntry?.[model]; + const dropParamsSet = resolveDropParamsUIKeys( + Array.isArray(resolvedDropParams) ? resolvedDropParams : undefined, + overriddenEndpointKey, + ); + const defaultParams = + agentParamSettings[combinedKey] ?? agentParamSettings[overriddenEndpointKey]; + const overriddenParams = customParams?.paramDefinitions; + const overriddenParamsMap = keyBy(overriddenParams ?? [], 'key'); + const modelAwareParams = applyModelAwareDefaults( + (defaultParams ?? []).filter((param) => param != null && !dropParamsSet.has(param.key)), + overriddenEndpointKey, + model, + ); + + return { + schemaResolved: defaultParams != null || overriddenParams != null, + parameters: modelAwareParams.map( + (param) => (overriddenParamsMap[param.key] as SettingDefinition) ?? param, + ), + }; +} + +export function pruneAgentModelParameters( + currentParameters: AgentModelParameters | undefined, + settings: ResolvedAgentParameterSettings, +): AgentModelParameters { + const normalizedParameters = currentParameters ?? ({} as AgentModelParameters); + if (!settings.schemaResolved) { + return normalizedParameters; + } + + const parameterKeys = new Set(settings.parameters.map((setting) => setting.key)); + const currentKeys = Object.keys(normalizedParameters) as Array; + let prunedParameters: Partial | undefined; + + for (const key of currentKeys) { + if ( + NON_CONTROL_PARAMETER_KEYS.has(key) || + parameterKeys.has(key) || + normalizedParameters[key] == null + ) { + continue; + } + + prunedParameters ??= { ...normalizedParameters }; + delete prunedParameters[key]; + } + + return (prunedParameters ?? normalizedParameters) as AgentModelParameters; +} diff --git a/client/src/components/SidePanel/Parameters/Panel.tsx b/client/src/components/SidePanel/Parameters/Panel.tsx index 0939adecca..6bceb74ee8 100644 --- a/client/src/components/SidePanel/Parameters/Panel.tsx +++ b/client/src/components/SidePanel/Parameters/Panel.tsx @@ -10,17 +10,20 @@ import { SettingDefinition, tConvoUpdateSchema, applyModelAwareDefaults, + normalizeEndpointName, + resolveDropParamsUIKeys, } from 'librechat-data-provider'; import type { TPreset } from 'librechat-data-provider'; +import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; import { useChatContext, useLiveAnnouncer } from '~/Providers'; import { SaveAsPresetDialog } from '~/components/Endpoints'; import { useSetIndexOptions, useLocalize } from '~/hooks'; -import { useGetEndpointsQuery } from '~/data-provider'; import { componentMapping } from './components'; import { logger, cn } from '~/utils'; export default function Parameters() { const localize = useLocalize(); + const { data: startupConfig } = useGetStartupConfig(); const { conversation, setConversation } = useChatContext(); const { announcePolite } = useLiveAnnouncer(); const { setOption } = useSetIndexOptions(); @@ -47,18 +50,28 @@ export default function Parameters() { const customParams = endpointsConfig[provider]?.customParams ?? {}; const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, model); const overriddenEndpointKey = customParams.defaultParamsEndpoint ?? endpointKey; + const dropParamsMap = startupConfig?.endpointsDropParamsMap; + const dropParamsEntry = + dropParamsMap?.[provider] ?? dropParamsMap?.[normalizeEndpointName(provider)]; + const resolvedDropParams = Array.isArray(dropParamsEntry) + ? dropParamsEntry + : dropParamsEntry?.[model]; + const dropParamsSet = resolveDropParamsUIKeys( + Array.isArray(resolvedDropParams) ? resolvedDropParams : undefined, + overriddenEndpointKey, + ); const defaultParams = paramSettings[combinedKey] ?? paramSettings[overriddenEndpointKey] ?? []; const overriddenParams = endpointsConfig[provider]?.customParams?.paramDefinitions ?? []; const overriddenParamsMap = keyBy(overriddenParams, 'key'); const modelAwareParams = applyModelAwareDefaults( - defaultParams.filter((param) => param != null), + defaultParams.filter((param) => param != null && !dropParamsSet.has(param.key)), overriddenEndpointKey, model, ); return modelAwareParams.map( (param) => (overriddenParamsMap[param.key] as SettingDefinition) ?? param, ); - }, [endpointType, endpointsConfig, model, provider]); + }, [endpointType, endpointsConfig, model, provider, startupConfig]); useEffect(() => { if (!parameters) { diff --git a/packages/api/src/app/config.test.ts b/packages/api/src/app/config.test.ts index b04a58d292..b26c533e33 100644 --- a/packages/api/src/app/config.test.ts +++ b/packages/api/src/app/config.test.ts @@ -1,8 +1,19 @@ import { logger, encryptV3 } from '@librechat/data-schemas'; import { FileSources, EModelEndpoint } from 'librechat-data-provider'; -import type { TCustomConfig, TEndpoint } from 'librechat-data-provider'; +import type { + TCustomConfig, + TEndpoint, + TAzureConfig, + TAzureGroupMap, + TAzureModelGroupMap, +} from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; -import { getTransactionsConfig, getBalanceConfig, getCustomEndpointConfig } from './config'; +import { + getBalanceConfig, + getCustomEndpointConfig, + getTransactionsConfig, + getEndpointsDropParamsMap, +} from './config'; // Helper function to create a minimal AppConfig for testing const createTestAppConfig = (overrides: Partial = {}): AppConfig => { @@ -32,6 +43,31 @@ const createTestAppConfig = (overrides: Partial = {}): AppConfig => { }; }; +/** Builds azureOpenAI `groupMap` entries with the required `apiKey`/`models` fields. */ +const createAzureGroupMap = (groups: Record): TAzureGroupMap => + Object.fromEntries( + Object.entries(groups).map(([groupName, dropParams]) => [ + groupName, + { + apiKey: 'test-key', + models: {}, + ...(dropParams ? { dropParams } : {}), + }, + ]), + ); + +/** Builds a minimal, valid azureOpenAI endpoint config for testing `getEndpointsDropParamsMap`. */ +const createAzureConfig = ( + groupMap: TAzureGroupMap, + modelGroupMap: TAzureModelGroupMap, +): TAzureConfig => ({ + isValid: true, + errors: [], + modelNames: Object.keys(modelGroupMap), + groupMap, + modelGroupMap, +}); + jest.mock('@librechat/data-schemas', () => { process.env.CREDS_KEY = process.env.CREDS_KEY ?? '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; @@ -381,3 +417,95 @@ describe('getCustomEndpointConfig', () => { }); }); }); + +describe('getEndpointsDropParamsMap', () => { + it('returns an empty map when endpoints is undefined', () => { + expect(getEndpointsDropParamsMap(undefined)).toEqual({}); + }); + + it('returns an empty map when no configured endpoint has dropParams', () => { + const result = getEndpointsDropParamsMap({ + [EModelEndpoint.custom]: [{ name: 'no-drop-provider', apiKey: 'k' } as TEndpoint], + }); + expect(result).toEqual({}); + }); + + it('maps dropParams for array-configured custom endpoints', () => { + const result = getEndpointsDropParamsMap({ + [EModelEndpoint.custom]: [ + { name: 'custom-provider', dropParams: ['temperature', 'top_p'] } as TEndpoint, + { name: 'no-drop-provider' } as TEndpoint, + ], + }); + expect(result).toEqual({ + 'custom-provider': ['temperature', 'top_p'], + }); + }); + + it('normalizes an ollama custom endpoint name to lowercase', () => { + const result = getEndpointsDropParamsMap({ + [EModelEndpoint.custom]: [{ name: 'Ollama', dropParams: ['stop'] } as TEndpoint], + }); + expect(result).toEqual({ ollama: ['stop'] }); + }); + + it('keeps azureOpenAI dropParams model-specific instead of merging across groups', () => { + const endpoints: AppConfig['endpoints'] = { + [EModelEndpoint.azureOpenAI]: createAzureConfig( + createAzureGroupMap({ + groupA: ['temperature'], + groupB: ['temperature', 'top_p'], + }), + { + 'model-a': { group: 'groupA' }, + 'model-b': { group: 'groupB' }, + }, + ), + }; + + const result = getEndpointsDropParamsMap(endpoints); + + expect(result[EModelEndpoint.azureOpenAI]).toEqual({ + 'model-a': ['temperature'], + 'model-b': ['temperature', 'top_p'], + }); + }); + + it('omits an azureOpenAI model from the map when its group has no dropParams', () => { + const endpoints: AppConfig['endpoints'] = { + [EModelEndpoint.azureOpenAI]: createAzureConfig( + createAzureGroupMap({ + groupA: ['temperature'], + groupB: undefined, + }), + { + 'model-a': { group: 'groupA' }, + 'model-b': { group: 'groupB' }, + }, + ), + }; + + expect(getEndpointsDropParamsMap(endpoints)).toEqual({ + [EModelEndpoint.azureOpenAI]: { 'model-a': ['temperature'] }, + }); + }); + + it('excludes azureOpenAI when no group has dropParams', () => { + const endpoints: AppConfig['endpoints'] = { + [EModelEndpoint.azureOpenAI]: createAzureConfig(createAzureGroupMap({ groupA: undefined }), { + 'model-a': { group: 'groupA' }, + }), + }; + + expect(getEndpointsDropParamsMap(endpoints)).toEqual({}); + }); + + it('ignores endpoint shapes without dropParams support, like agents', () => { + const endpoints = { + [EModelEndpoint.custom]: [{ name: 'no-drop-provider' } as TEndpoint], + [EModelEndpoint.agents]: { titleConvo: true }, + } as AppConfig['endpoints']; + + expect(getEndpointsDropParamsMap(endpoints)).toEqual({}); + }); +}); diff --git a/packages/api/src/app/config.ts b/packages/api/src/app/config.ts index e651e8fbac..c9e68b617b 100644 --- a/packages/api/src/app/config.ts +++ b/packages/api/src/app/config.ts @@ -4,7 +4,12 @@ import { removeNullishValues, normalizeEndpointName, } from 'librechat-data-provider'; -import type { TCustomConfig, TEndpoint, TTransactionsConfig } from 'librechat-data-provider'; +import type { + TEndpoint, + TCustomConfig, + EndpointsDropParamsMap, + TTransactionsConfig, +} from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; import { resolveCustomEndpointSecrets } from '~/admin/secrets'; import { isEnabled } from '~/utils'; @@ -69,3 +74,43 @@ export const getCustomEndpointConfig = ({ ); return endpointConfig && resolveCustomEndpointSecrets(endpointConfig); }; + +/** + * Builds a map of normalized endpoint name -> dropParams for the endpoint shapes that + * support per-endpoint dropParams: array-configured `custom` endpoints map directly to + * their dropParams, while `azureOpenAI` maps to a per-model lookup (model name -> + * dropParams) built from `modelGroupMap`/`groupMap`, mirroring the per-request group + * resolution in `initializeOpenAI` so a parameter dropped for one group doesn't hide it + * for models in another group. + */ +export function getEndpointsDropParamsMap( + endpoints: AppConfig['endpoints'], +): EndpointsDropParamsMap { + const result: EndpointsDropParamsMap = {}; + if (!endpoints) { + return result; + } + + endpoints[EModelEndpoint.custom]?.forEach((endpoint) => { + if (endpoint?.dropParams && endpoint.dropParams.length > 0) { + result[normalizeEndpointName(endpoint.name ?? '')] = endpoint.dropParams; + } + }); + + const azureConfig = endpoints[EModelEndpoint.azureOpenAI]; + if (azureConfig?.groupMap && azureConfig?.modelGroupMap) { + const { groupMap, modelGroupMap } = azureConfig; + const modelDropParams: Record = {}; + Object.entries(modelGroupMap).forEach(([modelName, modelConfig]) => { + const dropParams = modelConfig && groupMap[modelConfig.group]?.dropParams; + if (dropParams && dropParams.length > 0) { + modelDropParams[modelName] = dropParams; + } + }); + if (Object.keys(modelDropParams).length > 0) { + result[normalizeEndpointName(EModelEndpoint.azureOpenAI)] = modelDropParams; + } + } + + return result; +} diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 69db39fb50..2a01bba5d2 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2004,6 +2004,13 @@ export type TRumConfig = { export type StartupConfigContext = 'share'; +/** + * Per-endpoint dropParams for the startup config. Most endpoints (e.g. `custom`) map a + * provider name directly to its dropParams. `azureOpenAI` instead maps a provider name to + * a per-model dropParams lookup, since each Azure group can drop different parameters. + */ +export type EndpointsDropParamsMap = Record>; + export type TStartupConfig = { appTitle: string; socialLogins?: string[]; @@ -2101,6 +2108,7 @@ export type TStartupConfig = { buildDate?: string | null; }; fileUploadSseEnabled?: boolean; + endpointsDropParamsMap?: EndpointsDropParamsMap; }; export type TSharedLinkStartupInterface = Pick< diff --git a/packages/data-provider/src/parameterSettings.spec.ts b/packages/data-provider/src/parameterSettings.spec.ts index f38e838084..eb191f749d 100644 --- a/packages/data-provider/src/parameterSettings.spec.ts +++ b/packages/data-provider/src/parameterSettings.spec.ts @@ -1,6 +1,10 @@ import type { SettingDefinition } from './generate'; -import { applyModelAwareDefaults, paramSettings } from './parameterSettings'; -import { EModelEndpoint } from './types'; +import { + applyModelAwareDefaults, + resolveDropParamsUIKeys, + paramSettings, +} from './parameterSettings'; +import { EModelEndpoint, Providers } from './types'; const googleParams = paramSettings[EModelEndpoint.google] as SettingDefinition[]; const anthropicParams = paramSettings[EModelEndpoint.anthropic] as SettingDefinition[]; @@ -131,3 +135,38 @@ describe('maxContextTokens bounds', () => { expect(bounded).toEqual([]); }); }); + +describe('resolveDropParamsUIKeys', () => { + it('aliases backend param names to their UI keys for OpenAI-compatible endpoints', () => { + expect( + resolveDropParamsUIKeys( + ['maxTokens', 'topP', 'frequencyPenalty', 'presencePenalty'], + EModelEndpoint.openAI, + ), + ).toEqual(new Set(['max_tokens', 'top_p', 'frequency_penalty', 'presence_penalty'])); + }); + + it('aliases backend param names for azureOpenAI, custom, and openRouter endpoints', () => { + expect(resolveDropParamsUIKeys(['maxTokens'], EModelEndpoint.azureOpenAI)).toEqual( + new Set(['max_tokens']), + ); + expect(resolveDropParamsUIKeys(['topP'], EModelEndpoint.custom)).toEqual(new Set(['top_p'])); + expect(resolveDropParamsUIKeys(['topP'], Providers.OPENROUTER)).toEqual(new Set(['top_p'])); + }); + + it('preserves native keys for a custom endpoint overridden to anthropic/google, since their UI key already matches the backend name', () => { + expect(resolveDropParamsUIKeys(['topP'], EModelEndpoint.anthropic)).toEqual(new Set(['topP'])); + expect(resolveDropParamsUIKeys(['topP'], EModelEndpoint.google)).toEqual(new Set(['topP'])); + }); + + it('preserves native keys for bedrock endpoints', () => { + expect( + resolveDropParamsUIKeys(['maxTokens', 'topP'], `${EModelEndpoint.bedrock}-anthropic`), + ).toEqual(new Set(['maxTokens', 'topP'])); + }); + + it('returns an empty set when dropParams is undefined or empty', () => { + expect(resolveDropParamsUIKeys(undefined, EModelEndpoint.openAI)).toEqual(new Set()); + expect(resolveDropParamsUIKeys([], EModelEndpoint.openAI)).toEqual(new Set()); + }); +}); diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index e6898c1074..9c3b48ab03 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -1190,6 +1190,48 @@ export const paramSettings: Record = [EModelEndpoint.google]: googleConfig, }; +/** + * Maps effective backend param names for OpenAI-compatible/Azure endpoints (as deleted from + * `llmConfig` via `dropParams`, e.g. `maxTokens`) to their corresponding UI/conversation keys + * (e.g. `max_tokens`). Native providers (anthropic, google, bedrock, ...) already render these + * same camelCase names as their UI key (e.g. `topP`), so this alias must only be applied to + * OpenAI-compatible parameter sets — see `resolveDropParamsUIKeys`. + */ +const dropParamsBackendToUIKey: Record = { + maxTokens: 'max_tokens', + topP: 'top_p', + frequencyPenalty: 'frequency_penalty', + presencePenalty: 'presence_penalty', +}; + +/** Endpoint keys whose parameter settings render the OpenAI-compatible (snake_case) UI keys. */ +const openAILikeParamEndpointKeys: Set = new Set([ + EModelEndpoint.openAI, + EModelEndpoint.azureOpenAI, + EModelEndpoint.custom, + Providers.OPENROUTER, +]); + +/** + * Normalizes an admin-configured `dropParams` list into the UI/conversation keys used to hide + * the matching controls in the settings panels. `endpointKey` should be the same key used to + * resolve the panel's parameter settings (e.g. `overriddenEndpointKey`); the backend-name alias + * is only applied for OpenAI-compatible endpoints, since native providers (anthropic, google, + * bedrock, ...) already use these backend names as their UI key. + */ +export function resolveDropParamsUIKeys( + dropParams: string[] | undefined, + endpointKey: string, +): Set { + if (!dropParams || dropParams.length === 0) { + return new Set(); + } + if (!openAILikeParamEndpointKeys.has(endpointKey)) { + return new Set(dropParams); + } + return new Set(dropParams.map((param) => dropParamsBackendToUIKey[param] ?? param)); +} + const openAIColumns = { col1: openAICol1, col2: openAICol2,