🪁 fix: Honor Endpoint Drop Params in Parameter Panels (#15610)

* Remove parameters from UI when parameter is dropped through dropParams

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Addressed copilot and codex review suggestions

* 1. Normalize drop parameter names in client/src/components/SidePanel/Parameters/Panel.tsx
  Admin-configured dropParams for Azure/OpenAI-compatible custom endpoints use the effective backend field names (maxTokens, topP, frequencyPenalty, presencePenalty), but the panel's filter compared them against the UI's
  snake_case keys (max_tokens, top_p, etc.), so the controls stayed visible and silently discarded whatever the user set. Added a dropParamsBackendToUIKey map (in packages/data-provider/src/parameterSettings.ts) and normalize
  each dropParams entry through it before filtering.

  2. Apply the same drop-parameter filtering in client/src/components/SidePanel/Agents/ModelPanel.tsx
  The Agent builder's model panel built its parameter list independently and never consulted endpointsDropParamsMap at all, so it kept offering controls for Azure/custom providers whose values the backend drops. Reused the
  same resolution logic (including the new backend→UI key normalization) there, sourced from useGetStartupConfig.

  3. Remove any escapes from Azure fixtures in packages/api/src/app/config.test.ts
  Three tests built azure groupMap/modelGroupMap fixtures via ... as any as AppConfig['endpoints'], bypassing type checking for that shape. Replaced them with two typed helpers, createAzureGroupMap and createAzureConfig, built
  from the existing TAzureConfig/TAzureGroupMap/TAzureModelGroupMap types, so the fixtures are now fully type-checked with no any.

* HAL-1081 Deploy librechat open

  1. Gate the backend-name alias to OpenAI-compatible parameter sets (packages/data-provider/src/parameterSettings.ts)
  dropParamsBackendToUIKey had been applied unconditionally, rewriting a dropped topP to top_p even for a custom endpoint whose defaultParamsEndpoint is anthropic/google (or
  a native bedrock-* endpoint) — where topP is the UI key, so the rewrite broke hiding the control. Replaced the plain map with resolveDropParamsUIKeys(dropParams,
  endpointKey), which only aliases backend names for OpenAI-compatible endpoint keys (openAI, azureOpenAI, custom, openRouter) and passes native-provider keys through
  unchanged otherwise. Updated both Panel.tsx and ModelPanel.tsx to call it with overriddenEndpointKey, and added a resolveDropParamsUIKeys test suite in
  parameterSettings.spec.ts.

  2. Prune stale model_parameters when a control becomes hidden (client/src/components/SidePanel/Agents/ModelPanel.tsx)
  When a parameter an agent already had set gets added to its endpoint's dropParams, the control disappeared but the value stayed in model_parameters and was saved unchanged
  by composeAgentUpdatePayload — invisible to the user and silently reactivated if the endpoint later stopped dropping it. Added a useEffect, mirroring the conversation
  panel's existing pruning effect, that strips any model_parameters key no longer present in the currently-visible parameters list. Also fixed a pre-existing test regression
  (missing useGetStartupConfig mock) and added two tests covering pruning-on-drop and value retention when the control stays visible.

* fix: Preserve parameters for unknown agent providers

* fix: Sanitize agent parameters on submission

* fix: Preserve agent model overrides during pruning

---------

Co-authored-by: Marc Amick <MarcAmick@jhu.edu>
Co-authored-by: MarcAmick <5194465+MarcAmick@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Danny Avila 2026-09-04 13:52:23 -04:00 • committed by GitHub
parent 3200f0133a
commit c302ae6f35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 683 additions and 50 deletions

View file

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

View file

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

View file

@ -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<AgentForm>; 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,
],
);

View file

@ -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<Partial<TStartupConfig>, []>(() => ({}));
jest.mock('@librechat/client', () => ({
Alert: ({ children }: { children: React.ReactNode }) => <div role="alert">{children}</div>,
Button: ({ children, onClick, type }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
@ -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<AgentForm['model_parameters']>;
defaultProvider?: string;
formRef?: React.MutableRefObject<UseFormReturn<AgentForm> | null>;
models: Record<string, string[]>;
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 (
<FormProvider {...methods}>
<ModelPanel
@ -110,7 +123,10 @@ function TestForm({
}
describe('ModelPanel', () => {
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<UseFormReturn<AgentForm> | null> = { current: null };
render(
<TestForm
defaultProvider={EModelEndpoint.openAI}
defaultModel="gpt-4o"
defaultModelParameters={{ model: 'deployment-override', temperature: 0.5, top_p: 0.9 }}
formRef={formRef}
models={{ [EModelEndpoint.openAI]: ['gpt-4o'] }}
modelsReady={true}
/>,
);
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<UseFormReturn<AgentForm> | null> = { current: null };
render(
<TestForm
defaultProvider={EModelEndpoint.openAI}
defaultModel="gpt-4o"
defaultModelParameters={{ temperature: 0.5, top_p: 0.9 }}
formRef={formRef}
models={{ [EModelEndpoint.openAI]: ['gpt-4o'] }}
modelsReady={true}
/>,
);
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<UseFormReturn<AgentForm> | null> = { current: null };
render(
<TestForm
defaultProvider="removed-provider"
defaultModel="removed-model"
defaultModelParameters={{ temperature: 0.5, top_p: 0.9 }}
formRef={formRef}
models={{ 'removed-provider': ['removed-model'] }}
modelsReady={true}
/>,
);
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<UseFormReturn<AgentForm> | null> = { current: null };
render(
<TestForm
defaultProvider={EModelEndpoint.openAI}
defaultModel="gpt-4o"
defaultModelParameters={{ temperature: 0.5, top_p: 0.9 }}
formRef={formRef}
models={{ [EModelEndpoint.openAI]: ['gpt-4o'] }}
modelsReady={true}
/>,
);
await waitFor(() => {
expect(formRef.current?.getValues('model_parameters')).toEqual({});
});
});
});

View file

@ -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<AgentForm>();
const { control, setValue, getValues } = useFormContext<AgentForm>();
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);

View file

@ -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', () => {

View file

@ -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<TStartupConfig, 'endpointsDropParamsMap'> | null;
};
export type ResolvedAgentParameterSettings = {
parameters: SettingDefinition[];
schemaResolved: boolean;
};
const NON_CONTROL_PARAMETER_KEYS = new Set<keyof AgentModelParameters>(['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<keyof AgentModelParameters>;
let prunedParameters: Partial<AgentModelParameters> | 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;
}

View file

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

View file

@ -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> = {}): AppConfig => {
@ -32,6 +43,31 @@ const createTestAppConfig = (overrides: Partial<AppConfig> = {}): AppConfig => {
};
};
/** Builds azureOpenAI `groupMap` entries with the required `apiKey`/`models` fields. */
const createAzureGroupMap = (groups: Record<string, string[] | undefined>): 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({});
});
});

View file

@ -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<string, string[]> = {};
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;
}

View file

@ -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<string, string[] | Record<string, string[]>>;
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<

View file

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

View file

@ -1190,6 +1190,48 @@ export const paramSettings: Record<string, SettingsConfiguration | undefined> =
[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<string, string> = {
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<string> = 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<string> {
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,