🎚️ feat: Custom Parameters (#7342)

* #

* - refactor: simplified getCustomConfig func

* #

* - feature: persist values for parameters with optionType of custom

* #

* - refactor: moved `Parameters/settings.ts` into `data-provider` so that both frontend and backend code can use it.

* - feature: loadCustomConfig can now parse and validate customParams property for `endpoints.custom` in `librechat.yaml`

* # fixed linter

* # removed .strict() in config.ts

* change: added packages/data-provider/src to SOURCE_DIRS for i18n check

* # removed unnecessary lodash imports

* # addressed PR comments
# fixed lint for updated files

* # better import for lodash (w/o relying on tree-shaking)
This commit is contained in:
Theo N. Truong 2025-05-19 17:33:25 -06:00 committed by GitHub
parent c79ee32006
commit 7ce782fec6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 340 additions and 132 deletions

View file

@ -22,7 +22,7 @@ jobs:
# Define paths # Define paths
I18N_FILE="client/src/locales/en/translation.json" I18N_FILE="client/src/locales/en/translation.json"
SOURCE_DIRS=("client/src" "api") SOURCE_DIRS=("client/src" "api" "packages/data-provider/src")
# Check if translation file exists # Check if translation file exists
if [[ ! -f "$I18N_FILE" ]]; then if [[ ! -f "$I18N_FILE" ]]; then

View file

@ -10,17 +10,7 @@ const getLogStores = require('~/cache/getLogStores');
* */ * */
async function getCustomConfig() { async function getCustomConfig() {
const cache = getLogStores(CacheKeys.CONFIG_STORE); const cache = getLogStores(CacheKeys.CONFIG_STORE);
let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG); return (await cache.get(CacheKeys.CUSTOM_CONFIG)) || (await loadCustomConfig());
if (!customConfig) {
customConfig = await loadCustomConfig();
}
if (!customConfig) {
return null;
}
return customConfig;
} }
/** /**

View file

@ -29,7 +29,14 @@ async function loadConfigEndpoints(req) {
for (let i = 0; i < customEndpoints.length; i++) { for (let i = 0; i < customEndpoints.length; i++) {
const endpoint = customEndpoints[i]; const endpoint = customEndpoints[i];
const { baseURL, apiKey, name: configName, iconURL, modelDisplayLabel } = endpoint; const {
baseURL,
apiKey,
name: configName,
iconURL,
modelDisplayLabel,
customParams,
} = endpoint;
const name = normalizeEndpointName(configName); const name = normalizeEndpointName(configName);
const resolvedApiKey = extractEnvVariable(apiKey); const resolvedApiKey = extractEnvVariable(apiKey);
@ -41,6 +48,7 @@ async function loadConfigEndpoints(req) {
userProvideURL: isUserProvided(resolvedBaseURL), userProvideURL: isUserProvided(resolvedBaseURL),
modelDisplayLabel, modelDisplayLabel,
iconURL, iconURL,
customParams,
}; };
} }
} }

View file

@ -1,10 +1,18 @@
const path = require('path'); const path = require('path');
const { CacheKeys, configSchema, EImageOutputType } = require('librechat-data-provider'); const {
CacheKeys,
configSchema,
EImageOutputType,
validateSettingDefinitions,
agentParamSettings,
paramSettings,
} = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const loadYaml = require('~/utils/loadYaml'); const loadYaml = require('~/utils/loadYaml');
const { logger } = require('~/config'); const { logger } = require('~/config');
const axios = require('axios'); const axios = require('axios');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
const keyBy = require('lodash/keyBy');
const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml'); const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml');
@ -105,6 +113,10 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
logger.debug('Custom config:', customConfig); logger.debug('Custom config:', customConfig);
} }
(customConfig.endpoints?.custom ?? [])
.filter((endpoint) => endpoint.customParams)
.forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams));
if (customConfig.cache) { if (customConfig.cache) {
const cache = getLogStores(CacheKeys.CONFIG_STORE); const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig); await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig);
@ -117,4 +129,52 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
return customConfig; return customConfig;
} }
// Validate and fill out missing values for custom parameters
function parseCustomParams(endpointName, customParams) {
const paramEndpoint = customParams.defaultParamsEndpoint;
customParams.paramDefinitions = customParams.paramDefinitions || [];
// Checks if `defaultParamsEndpoint` is a key in `paramSettings`.
const validEndpoints = new Set([
...Object.keys(paramSettings),
...Object.keys(agentParamSettings),
]);
if (!validEndpoints.has(paramEndpoint)) {
throw new Error(
`defaultParamsEndpoint of "${endpointName}" endpoint is invalid. ` +
`Valid options are ${Array.from(validEndpoints).join(', ')}`,
);
}
// creates default param maps
const regularParams = paramSettings[paramEndpoint] ?? [];
const agentParams = agentParamSettings[paramEndpoint] ?? [];
const defaultParams = regularParams.concat(agentParams);
const defaultParamsMap = keyBy(defaultParams, 'key');
// TODO: Remove this check once we support new parameters not part of default parameters.
// Checks if every key in `paramDefinitions` is valid.
const validKeys = new Set(Object.keys(defaultParamsMap));
const paramKeys = customParams.paramDefinitions.map((param) => param.key);
if (paramKeys.some((key) => !validKeys.has(key))) {
throw new Error(
`paramDefinitions of "${endpointName}" endpoint contains invalid key(s). ` +
`Valid parameter keys are ${Array.from(validKeys).join(', ')}`,
);
}
// Fill out missing values for custom param definitions
customParams.paramDefinitions = customParams.paramDefinitions.map((param) => {
return { ...defaultParamsMap[param.key], ...param, optionType: 'custom' };
});
try {
validateSettingDefinitions(customParams.paramDefinitions);
} catch (e) {
throw new Error(
`Custom parameter definitions for "${endpointName}" endpoint is malformed: ${e.message}`,
);
}
}
module.exports = loadCustomConfig; module.exports = loadCustomConfig;

View file

@ -1,6 +1,34 @@
jest.mock('axios'); jest.mock('axios');
jest.mock('~/cache/getLogStores'); jest.mock('~/cache/getLogStores');
jest.mock('~/utils/loadYaml'); jest.mock('~/utils/loadYaml');
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
paramSettings: { foo: {}, bar: {}, custom: {} },
agentParamSettings: {
custom: [],
google: [
{
key: 'pressure',
type: 'string',
component: 'input',
},
{
key: 'temperature',
type: 'number',
component: 'slider',
default: 0.5,
range: {
min: 0,
max: 2,
step: 0.01,
},
},
],
},
};
});
const axios = require('axios'); const axios = require('axios');
const loadCustomConfig = require('./loadCustomConfig'); const loadCustomConfig = require('./loadCustomConfig');
@ -150,4 +178,126 @@ describe('loadCustomConfig', () => {
expect(logger.info).toHaveBeenCalledWith(JSON.stringify(mockConfig, null, 2)); expect(logger.info).toHaveBeenCalledWith(JSON.stringify(mockConfig, null, 2));
expect(logger.debug).toHaveBeenCalledWith('Custom config:', mockConfig); expect(logger.debug).toHaveBeenCalledWith('Custom config:', mockConfig);
}); });
describe('parseCustomParams', () => {
const mockConfig = {
version: '1.0',
cache: false,
endpoints: {
custom: [
{
name: 'Google',
apiKey: 'user_provided',
customParams: {},
},
],
},
};
async function loadCustomParams(customParams) {
mockConfig.endpoints.custom[0].customParams = customParams;
loadYaml.mockReturnValue(mockConfig);
return await loadCustomConfig();
}
beforeEach(() => {
jest.resetAllMocks();
process.env.CONFIG_PATH = 'validConfig.yaml';
});
it('returns no error when customParams is undefined', async () => {
const result = await loadCustomParams(undefined);
expect(result).toEqual(mockConfig);
});
it('returns no error when customParams is valid', async () => {
const result = await loadCustomParams({
defaultParamsEndpoint: 'google',
paramDefinitions: [
{
key: 'temperature',
default: 0.5,
},
],
});
expect(result).toEqual(mockConfig);
});
it('throws an error when paramDefinitions contain unsupported keys', async () => {
const malformedCustomParams = {
defaultParamsEndpoint: 'google',
paramDefinitions: [
{ key: 'temperature', default: 0.5 },
{ key: 'unsupportedKey', range: 0.5 },
],
};
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
'paramDefinitions of "Google" endpoint contains invalid key(s). Valid parameter keys are pressure, temperature',
);
});
it('throws an error when paramDefinitions is malformed', async () => {
const malformedCustomParams = {
defaultParamsEndpoint: 'google',
paramDefinitions: [
{
key: 'temperature',
type: 'noomba',
component: 'inpoot',
optionType: 'custom',
},
],
};
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
/Custom parameter definitions for "Google" endpoint is malformed:/,
);
});
it('throws an error when defaultParamsEndpoint is not provided', async () => {
const malformedCustomParams = { defaultParamsEndpoint: undefined };
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
'defaultParamsEndpoint of "Google" endpoint is invalid. Valid options are foo, bar, custom, google',
);
});
it('fills the paramDefinitions with missing values', async () => {
const customParams = {
defaultParamsEndpoint: 'google',
paramDefinitions: [
{ key: 'temperature', default: 0.7, range: { min: 0.1, max: 0.9, step: 0.1 } },
{ key: 'pressure', component: 'textarea' },
],
};
const parsedConfig = await loadCustomParams(customParams);
const paramDefinitions = parsedConfig.endpoints.custom[0].customParams.paramDefinitions;
expect(paramDefinitions).toEqual([
{
columnSpan: 1,
component: 'slider',
default: 0.7, // overridden
includeInput: true,
key: 'temperature',
label: 'temperature',
optionType: 'custom',
range: {
// overridden
max: 0.9,
min: 0.1,
step: 0.1,
},
type: 'number',
},
{
columnSpan: 1,
component: 'textarea', // overridden
key: 'pressure',
label: 'pressure',
optionType: 'custom',
placeholder: '',
type: 'string',
},
]);
});
});
}); });

View file

@ -105,6 +105,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
headers: resolvedHeaders, headers: resolvedHeaders,
addParams: endpointConfig.addParams, addParams: endpointConfig.addParams,
dropParams: endpointConfig.dropParams, dropParams: endpointConfig.dropParams,
customParams: endpointConfig.customParams,
titleConvo: endpointConfig.titleConvo, titleConvo: endpointConfig.titleConvo,
titleModel: endpointConfig.titleModel, titleModel: endpointConfig.titleModel,
forcePrompt: endpointConfig.forcePrompt, forcePrompt: endpointConfig.forcePrompt,

View file

@ -3,7 +3,7 @@ import { getSettingsKeys } from 'librechat-data-provider';
import type { SettingDefinition } from 'librechat-data-provider'; import type { SettingDefinition } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common'; import type { TModelSelectProps } from '~/common';
import { componentMapping } from '~/components/SidePanel/Parameters/components'; import { componentMapping } from '~/components/SidePanel/Parameters/components';
import { presetSettings } from '~/components/SidePanel/Parameters/settings'; import { presetSettings } from 'librechat-data-provider';
export default function AnthropicSettings({ export default function AnthropicSettings({
conversation, conversation,

View file

@ -3,7 +3,7 @@ import { getSettingsKeys } from 'librechat-data-provider';
import type { SettingDefinition } from 'librechat-data-provider'; import type { SettingDefinition } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common'; import type { TModelSelectProps } from '~/common';
import { componentMapping } from '~/components/SidePanel/Parameters/components'; import { componentMapping } from '~/components/SidePanel/Parameters/components';
import { presetSettings } from '~/components/SidePanel/Parameters/settings'; import { presetSettings } from 'librechat-data-provider';
export default function BedrockSettings({ export default function BedrockSettings({
conversation, conversation,

View file

@ -1,9 +1,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { getSettingsKeys } from 'librechat-data-provider'; import { getSettingsKeys } from 'librechat-data-provider';
import type { SettingDefinition, DynamicSettingProps } from 'librechat-data-provider'; import type { SettingDefinition } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common'; import type { TModelSelectProps } from '~/common';
import { componentMapping } from '~/components/SidePanel/Parameters/components'; import { componentMapping } from '~/components/SidePanel/Parameters/components';
import { presetSettings } from '~/components/SidePanel/Parameters/settings'; import { presetSettings } from 'librechat-data-provider';
export default function OpenAISettings({ export default function OpenAISettings({
conversation, conversation,

View file

@ -1,16 +1,21 @@
import React, { useMemo, useEffect } from 'react'; import React, { useMemo, useEffect } from 'react';
import { ChevronLeft, RotateCcw } from 'lucide-react'; import { ChevronLeft, RotateCcw } from 'lucide-react';
import { useFormContext, useWatch, Controller } from 'react-hook-form'; import { useFormContext, useWatch, Controller } from 'react-hook-form';
import { getSettingsKeys, alternateName } from 'librechat-data-provider'; import {
getSettingsKeys,
alternateName,
agentParamSettings,
SettingDefinition,
} from 'librechat-data-provider';
import type * as t from 'librechat-data-provider'; import type * as t from 'librechat-data-provider';
import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common'; import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common';
import { componentMapping } from '~/components/SidePanel/Parameters/components'; import { componentMapping } from '~/components/SidePanel/Parameters/components';
import { agentSettings } from '~/components/SidePanel/Parameters/settings';
import ControlCombobox from '~/components/ui/ControlCombobox'; import ControlCombobox from '~/components/ui/ControlCombobox';
import { useGetEndpointsQuery } from '~/data-provider'; import { useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField, cn } from '~/utils'; import { getEndpointField, cn } from '~/utils';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { Panel } from '~/common'; import { Panel } from '~/common';
import keyBy from 'lodash/keyBy';
export default function ModelPanel({ export default function ModelPanel({
setActivePanel, setActivePanel,
@ -52,7 +57,7 @@ export default function ModelPanel({
} }
}, [provider, models, modelsData, setValue, model]); }, [provider, models, modelsData, setValue, model]);
const { data: endpointsConfig } = useGetEndpointsQuery(); const { data: endpointsConfig = {} } = useGetEndpointsQuery();
const bedrockRegions = useMemo(() => { const bedrockRegions = useMemo(() => {
return endpointsConfig?.[provider]?.availableRegions ?? []; return endpointsConfig?.[provider]?.availableRegions ?? [];
@ -63,10 +68,18 @@ export default function ModelPanel({
[provider, endpointsConfig], [provider, endpointsConfig],
); );
const parameters = useMemo(() => { const parameters = useMemo((): SettingDefinition[] => {
const customParams = endpointsConfig[provider]?.customParams ?? {};
const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, model ?? ''); const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, model ?? '');
return agentSettings[combinedKey] ?? agentSettings[endpointKey]; const overriddenEndpointKey = customParams.defaultParamsEndpoint ?? endpointKey;
}, [endpointType, model, provider]); const defaultParams =
agentParamSettings[combinedKey] ?? agentParamSettings[overriddenEndpointKey] ?? [];
const overriddenParams = endpointsConfig[provider]?.customParams?.paramDefinitions ?? [];
const overriddenParamsMap = keyBy(overriddenParams, 'key');
return defaultParams.map(
(param) => (overriddenParamsMap[param.key] as SettingDefinition) ?? param,
);
}, [endpointType, endpointsConfig, model, provider]);
const setOption = (optionKey: keyof t.AgentModelParameters) => (value: t.AgentParameterValue) => { const setOption = (optionKey: keyof t.AgentModelParameters) => (value: t.AgentParameterValue) => {
setValue(`model_parameters.${optionKey}`, value); setValue(`model_parameters.${optionKey}`, value);

View file

@ -1,8 +1,8 @@
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import { OptionTypes } from 'librechat-data-provider'; import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Checkbox, HoverCard, HoverCardTrigger } from '~/components/ui'; import { Label, Checkbox, HoverCard, HoverCardTrigger } from '~/components/ui';
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks'; import { TranslationKeys, useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover'; import OptionHover from './OptionHover';
import { ESide } from '~/common'; import { ESide } from '~/common';
@ -23,23 +23,20 @@ function DynamicCheckbox({
}: DynamicSettingProps) { }: DynamicSettingProps) {
const localize = useLocalize(); const localize = useLocalize();
const { preset } = useChatContext(); const { preset } = useChatContext();
const [inputValue, setInputValue] = useState<boolean>(!!(defaultValue as boolean | undefined));
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<boolean>({
optionKey: settingKey,
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
setter: () => ({}),
setOption,
});
const selectedValue = useMemo(() => { const selectedValue = useMemo(() => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
return inputValue;
}
return conversation?.[settingKey] ?? defaultValue; return conversation?.[settingKey] ?? defaultValue;
}, [conversation, defaultValue, optionType, settingKey, inputValue]); }, [conversation, defaultValue, settingKey]);
const handleCheckedChange = (checked: boolean) => { const handleCheckedChange = (checked: boolean) => {
if (optionType === OptionTypes.Custom) { setInputValue(checked);
// TODO: custom logic, add to payload but not to conversation
setInputValue(checked);
return;
}
setOption(settingKey)(checked); setOption(settingKey)(checked);
}; };
@ -49,8 +46,7 @@ function DynamicCheckbox({
defaultValue, defaultValue,
conversation, conversation,
inputValue, inputValue,
setInputValue, setInputValue: setLocalValue,
preventDelayedUpdate: true,
}); });
return ( return (

View file

@ -1,5 +1,4 @@
import { useMemo, useState, useCallback } from 'react'; import { useMemo, useState, useCallback } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, HoverCard, HoverCardTrigger } from '~/components/ui'; import { Label, HoverCard, HoverCardTrigger } from '~/components/ui';
import ControlCombobox from '~/components/ui/ControlCombobox'; import ControlCombobox from '~/components/ui/ControlCombobox';
@ -16,7 +15,6 @@ function DynamicCombobox({
description = '', description = '',
columnSpan, columnSpan,
setOption, setOption,
optionType,
options: _options, options: _options,
items: _items, items: _items,
showLabel = true, showLabel = true,
@ -36,11 +34,8 @@ function DynamicCombobox({
const [inputValue, setInputValue] = useState<string | null>(null); const [inputValue, setInputValue] = useState<string | null>(null);
const selectedValue = useMemo(() => { const selectedValue = useMemo(() => {
if (optionType === OptionTypes.Custom) {
return inputValue;
}
return conversation?.[settingKey] ?? defaultValue; return conversation?.[settingKey] ?? defaultValue;
}, [conversation, defaultValue, optionType, settingKey, inputValue]); }, [conversation, defaultValue, settingKey]);
const items = useMemo(() => { const items = useMemo(() => {
if (_items != null) { if (_items != null) {
@ -54,13 +49,10 @@ function DynamicCombobox({
const handleChange = useCallback( const handleChange = useCallback(
(value: string) => { (value: string) => {
if (optionType === OptionTypes.Custom) { setInputValue(value);
setInputValue(value); setOption(settingKey)(value);
} else {
setOption(settingKey)(value);
}
}, },
[optionType, setOption, settingKey], [setOption, settingKey],
); );
useParameterEffects({ useParameterEffects({

View file

@ -12,7 +12,6 @@ function DynamicInput({
settingKey, settingKey,
defaultValue, defaultValue,
description = '', description = '',
type = 'string',
columnSpan, columnSpan,
setOption, setOption,
optionType, optionType,
@ -28,7 +27,7 @@ function DynamicInput({
const { preset } = useChatContext(); const { preset } = useChatContext();
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({ const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined, optionKey: settingKey,
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue, initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
setter: () => ({}), setter: () => ({}),
setOption, setOption,
@ -44,17 +43,7 @@ function DynamicInput({
}); });
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; setInputValue(e, !isNaN(Number(e.target.value)));
if (type !== 'number') {
setInputValue(e);
return;
}
if (value === '') {
setInputValue(e);
} else if (!isNaN(Number(value))) {
setInputValue(e, true);
}
}; };
return ( return (

View file

@ -33,7 +33,7 @@ function DynamicSlider({
); );
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({ const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined, optionKey: settingKey,
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue, initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
setter: () => ({}), setter: () => ({}),
setOption, setOption,

View file

@ -1,5 +1,4 @@
import { useState, useMemo } from 'react'; import { useState } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Switch, HoverCard, HoverCardTrigger } from '~/components/ui'; import { Label, Switch, HoverCard, HoverCardTrigger } from '~/components/ui';
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks'; import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
@ -14,7 +13,6 @@ function DynamicSwitch({
description = '', description = '',
columnSpan, columnSpan,
setOption, setOption,
optionType,
readonly = false, readonly = false,
showDefault = false, showDefault = false,
labelCode = false, labelCode = false,
@ -34,21 +32,10 @@ function DynamicSwitch({
preventDelayedUpdate: true, preventDelayedUpdate: true,
}); });
const selectedValue = useMemo(() => { const selectedValue = conversation?.[settingKey] ?? defaultValue;
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
return inputValue;
}
return conversation?.[settingKey] ?? defaultValue;
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
const handleCheckedChange = (checked: boolean) => { const handleCheckedChange = (checked: boolean) => {
if (optionType === OptionTypes.Custom) { setInputValue(checked);
// TODO: custom logic, add to payload but not to conversation
setInputValue(checked);
return;
}
setOption(settingKey)(checked); setOption(settingKey)(checked);
}; };
@ -65,7 +52,7 @@ function DynamicSwitch({
htmlFor={`${settingKey}-dynamic-switch`} htmlFor={`${settingKey}-dynamic-switch`}
className="text-left text-sm font-medium" className="text-left text-sm font-medium"
> >
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '} {labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && ( {showDefault && (
<small className="opacity-40"> <small className="opacity-40">
({localize('com_endpoint_default')}:{' '} ({localize('com_endpoint_default')}:{' '}
@ -84,7 +71,11 @@ function DynamicSwitch({
</HoverCardTrigger> </HoverCardTrigger>
{description && ( {description && (
<OptionHover <OptionHover
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description} description={
descriptionCode
? (localize(description as TranslationKeys) ?? description)
: description
}
side={ESide.Left} side={ESide.Left}
/> />
)} )}

View file

@ -1,10 +1,9 @@
import { useState, useMemo, useCallback, useRef } from 'react'; import { useState, useMemo, useCallback, useRef } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Input, HoverCard, HoverCardTrigger, Tag } from '~/components/ui'; import { Label, Input, HoverCard, HoverCardTrigger, Tag } from '~/components/ui';
import { useChatContext, useToastContext } from '~/Providers'; import { useChatContext, useToastContext } from '~/Providers';
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks'; import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
import { cn, defaultTextProps } from '~/utils'; import { cn } from '~/utils';
import OptionHover from './OptionHover'; import OptionHover from './OptionHover';
import { ESide } from '~/common'; import { ESide } from '~/common';
@ -15,7 +14,6 @@ function DynamicTags({
description = '', description = '',
columnSpan, columnSpan,
setOption, setOption,
optionType,
placeholder = '', placeholder = '',
readonly = false, readonly = false,
showDefault = false, showDefault = false,
@ -38,14 +36,10 @@ function DynamicTags({
const updateState = useCallback( const updateState = useCallback(
(update: string[]) => { (update: string[]) => {
if (optionType === OptionTypes.Custom) { setTags(update);
// TODO: custom logic, add to payload but not to conversation
setTags(update);
return;
}
setOption(settingKey)(update); setOption(settingKey)(update);
}, },
[optionType, setOption, settingKey], [setOption, settingKey],
); );
const onTagClick = useCallback(() => { const onTagClick = useCallback(() => {
@ -54,18 +48,10 @@ function DynamicTags({
} }
}, [inputRef]); }, [inputRef]);
const currentTags: string[] | undefined = useMemo(() => { const currentValue = conversation?.[settingKey];
if (optionType === OptionTypes.Custom) { const currentTags = useMemo(() => {
// TODO: custom logic, add to payload but not to conversation return currentValue ?? defaultValue ?? [];
return tags; }, [currentValue, defaultValue]);
}
if (!conversation?.[settingKey]) {
return defaultValue ?? [];
}
return conversation[settingKey];
}, [conversation, defaultValue, optionType, settingKey, tags]);
const onTagRemove = useCallback( const onTagRemove = useCallback(
(indexToRemove: number) => { (indexToRemove: number) => {
@ -75,7 +61,7 @@ function DynamicTags({
if (minTags != null && currentTags.length <= minTags) { if (minTags != null && currentTags.length <= minTags) {
showToast({ showToast({
message: localize('com_ui_min_tags',{ 0: minTags + '' }), message: localize('com_ui_min_tags', { 0: minTags + '' }),
status: 'warning', status: 'warning',
}); });
return; return;
@ -126,7 +112,7 @@ function DynamicTags({
htmlFor={`${settingKey}-dynamic-input`} htmlFor={`${settingKey}-dynamic-input`}
className="text-left text-sm font-medium" className="text-left text-sm font-medium"
> >
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '} {labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && ( {showDefault && (
<small className="opacity-40"> <small className="opacity-40">
( (
@ -174,7 +160,11 @@ function DynamicTags({
} }
}} }}
onChange={(e) => setTagText(e.target.value)} onChange={(e) => setTagText(e.target.value)}
placeholder={placeholderCode ? localize(placeholder as TranslationKeys) ?? placeholder : placeholder} placeholder={
placeholderCode
? (localize(placeholder as TranslationKeys) ?? placeholder)
: placeholder
}
className={cn('flex h-10 max-h-10 border-none bg-surface-secondary px-3 py-2')} className={cn('flex h-10 max-h-10 border-none bg-surface-secondary px-3 py-2')}
/> />
</div> </div>
@ -182,7 +172,11 @@ function DynamicTags({
</HoverCardTrigger> </HoverCardTrigger>
{description && ( {description && (
<OptionHover <OptionHover
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description} description={
descriptionCode
? (localize(description as TranslationKeys) ?? description)
: description
}
side={descriptionSide as ESide} side={descriptionSide as ESide}
/> />
)} )}

View file

@ -2,7 +2,7 @@ import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, TextareaAutosize, HoverCard, HoverCardTrigger } from '~/components/ui'; import { Label, TextareaAutosize, HoverCard, HoverCardTrigger } from '~/components/ui';
import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks'; import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks';
import { cn, defaultTextProps } from '~/utils'; import { cn } from '~/utils';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover'; import OptionHover from './OptionHover';
import { ESide } from '~/common'; import { ESide } from '~/common';
@ -27,7 +27,7 @@ function DynamicTextarea({
const { preset } = useChatContext(); const { preset } = useChatContext();
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | null>({ const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | null>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined, optionKey: settingKey,
initialValue: initialValue:
optionType !== OptionTypes.Custom optionType !== OptionTypes.Custom
? (conversation?.[settingKey] as string) ? (conversation?.[settingKey] as string)

View file

@ -1,6 +1,12 @@
import { RotateCcw } from 'lucide-react'; import { RotateCcw } from 'lucide-react';
import React, { useMemo, useState, useEffect, useCallback } from 'react'; import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { excludedKeys, getSettingsKeys, tConvoUpdateSchema } from 'librechat-data-provider'; import {
excludedKeys,
getSettingsKeys,
tConvoUpdateSchema,
paramSettings,
SettingDefinition,
} from 'librechat-data-provider';
import type { TPreset } from 'librechat-data-provider'; import type { TPreset } from 'librechat-data-provider';
import { SaveAsPresetDialog } from '~/components/Endpoints'; import { SaveAsPresetDialog } from '~/components/Endpoints';
import { useSetIndexOptions, useLocalize } from '~/hooks'; import { useSetIndexOptions, useLocalize } from '~/hooks';
@ -8,7 +14,7 @@ import { useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField, logger } from '~/utils'; import { getEndpointField, logger } from '~/utils';
import { componentMapping } from './components'; import { componentMapping } from './components';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
import { settings } from './settings'; import keyBy from 'lodash/keyBy';
export default function Parameters() { export default function Parameters() {
const localize = useLocalize(); const localize = useLocalize();
@ -18,7 +24,9 @@ export default function Parameters() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [preset, setPreset] = useState<TPreset | null>(null); const [preset, setPreset] = useState<TPreset | null>(null);
const { data: endpointsConfig } = useGetEndpointsQuery(); const { data: endpointsConfig = {} } = useGetEndpointsQuery();
const provider = conversation?.endpoint ?? '';
const model = conversation?.model ?? '';
const bedrockRegions = useMemo(() => { const bedrockRegions = useMemo(() => {
return endpointsConfig?.[conversation?.endpoint ?? '']?.availableRegions ?? []; return endpointsConfig?.[conversation?.endpoint ?? '']?.availableRegions ?? [];
@ -29,13 +37,17 @@ export default function Parameters() {
[conversation?.endpoint, endpointsConfig], [conversation?.endpoint, endpointsConfig],
); );
const parameters = useMemo(() => { const parameters = useMemo((): SettingDefinition[] => {
const [combinedKey, endpointKey] = getSettingsKeys( const customParams = endpointsConfig[provider]?.customParams ?? {};
endpointType ?? conversation?.endpoint ?? '', const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, model);
conversation?.model ?? '', const overriddenEndpointKey = customParams.defaultParamsEndpoint ?? endpointKey;
const defaultParams = paramSettings[combinedKey] ?? paramSettings[overriddenEndpointKey] ?? [];
const overriddenParams = endpointsConfig[provider]?.customParams?.paramDefinitions ?? [];
const overriddenParamsMap = keyBy(overriddenParams, 'key');
return defaultParams.map(
(param) => (overriddenParamsMap[param.key] as SettingDefinition) ?? param,
); );
return settings[combinedKey] ?? settings[endpointKey]; }, [endpointType, endpointsConfig, model, provider]);
}, [conversation, endpointType]);
useEffect(() => { useEffect(() => {
if (!parameters) { if (!parameters) {

View file

@ -279,6 +279,12 @@ export const endpointSchema = baseEndpointSchema.merge(
headers: z.record(z.any()).optional(), headers: z.record(z.any()).optional(),
addParams: z.record(z.any()).optional(), addParams: z.record(z.any()).optional(),
dropParams: z.array(z.string()).optional(), dropParams: z.array(z.string()).optional(),
customParams: z
.object({
defaultParamsEndpoint: z.string().default('custom'),
paramDefinitions: z.array(z.record(z.any())).optional(),
})
.strict(),
customOrder: z.number().optional(), customOrder: z.number().optional(),
directEndpoint: z.boolean().optional(), directEndpoint: z.boolean().optional(),
titleMessageRole: z.string().optional(), titleMessageRole: z.string().optional(),

View file

@ -467,7 +467,7 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
} }
/* Default value checks */ /* Default value checks */
if (setting.type === SettingTypes.Number && isNaN(setting.default as number)) { if (setting.type === SettingTypes.Number && isNaN(setting.default as number) && setting.default != null) {
errors.push({ errors.push({
code: ZodIssueCode.custom, code: ZodIssueCode.custom,
message: `Invalid default value for setting ${setting.key}. Must be a number.`, message: `Invalid default value for setting ${setting.key}. Must be a number.`,
@ -475,7 +475,7 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
}); });
} }
if (setting.type === SettingTypes.Boolean && typeof setting.default !== 'boolean') { if (setting.type === SettingTypes.Boolean && typeof setting.default !== 'boolean' && setting.default != null) {
errors.push({ errors.push({
code: ZodIssueCode.custom, code: ZodIssueCode.custom,
message: `Invalid default value for setting ${setting.key}. Must be a boolean.`, message: `Invalid default value for setting ${setting.key}. Must be a boolean.`,
@ -485,7 +485,7 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
if ( if (
(setting.type === SettingTypes.String || setting.type === SettingTypes.Enum) && (setting.type === SettingTypes.String || setting.type === SettingTypes.Enum) &&
typeof setting.default !== 'string' typeof setting.default !== 'string' && setting.default != null
) { ) {
errors.push({ errors.push({
code: ZodIssueCode.custom, code: ZodIssueCode.custom,

View file

@ -36,3 +36,4 @@ import * as dataService from './data-service';
export * from './utils'; export * from './utils';
export * from './actions'; export * from './actions';
export { default as createPayload } from './createPayload'; export { default as createPayload } from './createPayload';
export * from './parameterSettings';

View file

@ -6,8 +6,8 @@ import {
ReasoningEffort, ReasoningEffort,
BedrockProviders, BedrockProviders,
anthropicSettings, anthropicSettings,
} from 'librechat-data-provider'; } from './types';
import type { SettingsConfiguration, SettingDefinition } from 'librechat-data-provider'; import { SettingDefinition, SettingsConfiguration } from './generate';
// Base definitions // Base definitions
const baseDefinitions: Record<string, SettingDefinition> = { const baseDefinitions: Record<string, SettingDefinition> = {
@ -654,7 +654,7 @@ const bedrockGeneralCol2: SettingsConfiguration = [
bedrock.region, bedrock.region,
]; ];
export const settings: Record<string, SettingsConfiguration | undefined> = { export const paramSettings: Record<string, SettingsConfiguration | undefined> = {
[EModelEndpoint.openAI]: openAI, [EModelEndpoint.openAI]: openAI,
[EModelEndpoint.azureOpenAI]: openAI, [EModelEndpoint.azureOpenAI]: openAI,
[EModelEndpoint.custom]: openAI, [EModelEndpoint.custom]: openAI,
@ -682,9 +682,9 @@ const bedrockGeneralColumns = {
export const presetSettings: Record< export const presetSettings: Record<
string, string,
| { | {
col1: SettingsConfiguration; col1: SettingsConfiguration;
col2: SettingsConfiguration; col2: SettingsConfiguration;
} }
| undefined | undefined
> = { > = {
[EModelEndpoint.openAI]: openAIColumns, [EModelEndpoint.openAI]: openAIColumns,
@ -716,11 +716,11 @@ export const presetSettings: Record<
}, },
}; };
export const agentSettings: Record<string, SettingsConfiguration | undefined> = Object.entries( export const agentParamSettings: Record<string, SettingsConfiguration | undefined> = Object.entries(
presetSettings, presetSettings,
).reduce((acc, [key, value]) => { ).reduce<Record<string, SettingsConfiguration | undefined>>((acc, [key, value]) => {
if (value) { if (value) {
acc[key] = value.col2; acc[key] = value.col2;
} }
return acc; return acc;
}, {}); }, {});

View file

@ -10,6 +10,7 @@ import type {
TConversationTag, TConversationTag,
TBanner, TBanner,
} from './schemas'; } from './schemas';
import { SettingDefinition } from './generate';
export type TOpenAIMessage = OpenAI.Chat.ChatCompletionMessageParam; export type TOpenAIMessage = OpenAI.Chat.ChatCompletionMessageParam;
export * from './schemas'; export * from './schemas';
@ -268,6 +269,10 @@ export type TConfig = {
disableBuilder?: boolean; disableBuilder?: boolean;
retrievalModels?: string[]; retrievalModels?: string[];
capabilities?: string[]; capabilities?: string[];
customParams?: {
defaultParamsEndpoint?: string;
paramDefinitions?: SettingDefinition[];
};
}; };
export type TEndpointsConfig = export type TEndpointsConfig =