From 7ce782fec699497e1e995b31cee6232aca23f17f Mon Sep 17 00:00:00 2001 From: "Theo N. Truong" <644650+nhtruong@users.noreply.github.com> Date: Mon, 19 May 2025 17:33:25 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20feat:=20Custom=20Parame?= =?UTF-8?q?ters=20(#7342)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * # * - 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) --- .github/workflows/i18n-unused-keys.yml | 2 +- api/server/services/Config/getCustomConfig.js | 12 +- .../services/Config/loadConfigEndpoints.js | 10 +- .../services/Config/loadCustomConfig.js | 62 +++++++- .../services/Config/loadCustomConfig.spec.js | 150 ++++++++++++++++++ .../services/Endpoints/custom/initialize.js | 1 + .../Endpoints/Settings/Anthropic.tsx | 2 +- .../components/Endpoints/Settings/Bedrock.tsx | 2 +- .../components/Endpoints/Settings/OpenAI.tsx | 4 +- .../SidePanel/Agents/ModelPanel.tsx | 25 ++- .../SidePanel/Parameters/DynamicCheckbox.tsx | 28 ++-- .../SidePanel/Parameters/DynamicCombobox.tsx | 16 +- .../SidePanel/Parameters/DynamicInput.tsx | 15 +- .../SidePanel/Parameters/DynamicSlider.tsx | 2 +- .../SidePanel/Parameters/DynamicSwitch.tsx | 27 ++-- .../SidePanel/Parameters/DynamicTags.tsx | 44 +++-- .../SidePanel/Parameters/DynamicTextarea.tsx | 4 +- .../components/SidePanel/Parameters/Panel.tsx | 30 ++-- packages/data-provider/src/config.ts | 6 + packages/data-provider/src/generate.ts | 6 +- packages/data-provider/src/index.ts | 1 + .../data-provider/src/parameterSettings.ts | 18 +-- packages/data-provider/src/types.ts | 5 + 23 files changed, 340 insertions(+), 132 deletions(-) rename client/src/components/SidePanel/Parameters/settings.ts => packages/data-provider/src/parameterSettings.ts (97%) diff --git a/.github/workflows/i18n-unused-keys.yml b/.github/workflows/i18n-unused-keys.yml index f720a6178..6bcf82494 100644 --- a/.github/workflows/i18n-unused-keys.yml +++ b/.github/workflows/i18n-unused-keys.yml @@ -22,7 +22,7 @@ jobs: # Define paths 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 if [[ ! -f "$I18N_FILE" ]]; then diff --git a/api/server/services/Config/getCustomConfig.js b/api/server/services/Config/getCustomConfig.js index fdd84878e..74828789f 100644 --- a/api/server/services/Config/getCustomConfig.js +++ b/api/server/services/Config/getCustomConfig.js @@ -10,17 +10,7 @@ const getLogStores = require('~/cache/getLogStores'); * */ async function getCustomConfig() { const cache = getLogStores(CacheKeys.CONFIG_STORE); - let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG); - - if (!customConfig) { - customConfig = await loadCustomConfig(); - } - - if (!customConfig) { - return null; - } - - return customConfig; + return (await cache.get(CacheKeys.CUSTOM_CONFIG)) || (await loadCustomConfig()); } /** diff --git a/api/server/services/Config/loadConfigEndpoints.js b/api/server/services/Config/loadConfigEndpoints.js index 03d8c2236..2e80fb42b 100644 --- a/api/server/services/Config/loadConfigEndpoints.js +++ b/api/server/services/Config/loadConfigEndpoints.js @@ -29,7 +29,14 @@ async function loadConfigEndpoints(req) { for (let i = 0; i < customEndpoints.length; 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 resolvedApiKey = extractEnvVariable(apiKey); @@ -41,6 +48,7 @@ async function loadConfigEndpoints(req) { userProvideURL: isUserProvided(resolvedBaseURL), modelDisplayLabel, iconURL, + customParams, }; } } diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 2127ec239..18f3a4474 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -1,10 +1,18 @@ 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 loadYaml = require('~/utils/loadYaml'); const { logger } = require('~/config'); const axios = require('axios'); const yaml = require('js-yaml'); +const keyBy = require('lodash/keyBy'); const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml'); @@ -105,6 +113,10 @@ https://www.librechat.ai/docs/configuration/stt_tts`); logger.debug('Custom config:', customConfig); } + (customConfig.endpoints?.custom ?? []) + .filter((endpoint) => endpoint.customParams) + .forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams)); + if (customConfig.cache) { const cache = getLogStores(CacheKeys.CONFIG_STORE); await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig); @@ -117,4 +129,52 @@ https://www.librechat.ai/docs/configuration/stt_tts`); 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; diff --git a/api/server/services/Config/loadCustomConfig.spec.js b/api/server/services/Config/loadCustomConfig.spec.js index 24553b9f3..ed698e57f 100644 --- a/api/server/services/Config/loadCustomConfig.spec.js +++ b/api/server/services/Config/loadCustomConfig.spec.js @@ -1,6 +1,34 @@ jest.mock('axios'); jest.mock('~/cache/getLogStores'); 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 loadCustomConfig = require('./loadCustomConfig'); @@ -150,4 +178,126 @@ describe('loadCustomConfig', () => { expect(logger.info).toHaveBeenCalledWith(JSON.stringify(mockConfig, null, 2)); 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', + }, + ]); + }); + }); }); diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index 592440db5..39def8d0d 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -105,6 +105,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid headers: resolvedHeaders, addParams: endpointConfig.addParams, dropParams: endpointConfig.dropParams, + customParams: endpointConfig.customParams, titleConvo: endpointConfig.titleConvo, titleModel: endpointConfig.titleModel, forcePrompt: endpointConfig.forcePrompt, diff --git a/client/src/components/Endpoints/Settings/Anthropic.tsx b/client/src/components/Endpoints/Settings/Anthropic.tsx index 5d44f1a1a..ffed8aa53 100644 --- a/client/src/components/Endpoints/Settings/Anthropic.tsx +++ b/client/src/components/Endpoints/Settings/Anthropic.tsx @@ -3,7 +3,7 @@ import { getSettingsKeys } from 'librechat-data-provider'; import type { SettingDefinition } from 'librechat-data-provider'; import type { TModelSelectProps } from '~/common'; import { componentMapping } from '~/components/SidePanel/Parameters/components'; -import { presetSettings } from '~/components/SidePanel/Parameters/settings'; +import { presetSettings } from 'librechat-data-provider'; export default function AnthropicSettings({ conversation, diff --git a/client/src/components/Endpoints/Settings/Bedrock.tsx b/client/src/components/Endpoints/Settings/Bedrock.tsx index 1f1634ea6..95bf325b2 100644 --- a/client/src/components/Endpoints/Settings/Bedrock.tsx +++ b/client/src/components/Endpoints/Settings/Bedrock.tsx @@ -3,7 +3,7 @@ import { getSettingsKeys } from 'librechat-data-provider'; import type { SettingDefinition } from 'librechat-data-provider'; import type { TModelSelectProps } from '~/common'; import { componentMapping } from '~/components/SidePanel/Parameters/components'; -import { presetSettings } from '~/components/SidePanel/Parameters/settings'; +import { presetSettings } from 'librechat-data-provider'; export default function BedrockSettings({ conversation, diff --git a/client/src/components/Endpoints/Settings/OpenAI.tsx b/client/src/components/Endpoints/Settings/OpenAI.tsx index eaa269e70..db0afbe47 100644 --- a/client/src/components/Endpoints/Settings/OpenAI.tsx +++ b/client/src/components/Endpoints/Settings/OpenAI.tsx @@ -1,9 +1,9 @@ import { useMemo } from 'react'; 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 { componentMapping } from '~/components/SidePanel/Parameters/components'; -import { presetSettings } from '~/components/SidePanel/Parameters/settings'; +import { presetSettings } from 'librechat-data-provider'; export default function OpenAISettings({ conversation, diff --git a/client/src/components/SidePanel/Agents/ModelPanel.tsx b/client/src/components/SidePanel/Agents/ModelPanel.tsx index d7e5dd1b4..234cccbaa 100644 --- a/client/src/components/SidePanel/Agents/ModelPanel.tsx +++ b/client/src/components/SidePanel/Agents/ModelPanel.tsx @@ -1,16 +1,21 @@ import React, { useMemo, useEffect } from 'react'; import { ChevronLeft, RotateCcw } from 'lucide-react'; 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 { AgentForm, AgentModelPanelProps, StringOption } from '~/common'; import { componentMapping } from '~/components/SidePanel/Parameters/components'; -import { agentSettings } from '~/components/SidePanel/Parameters/settings'; import ControlCombobox from '~/components/ui/ControlCombobox'; import { useGetEndpointsQuery } from '~/data-provider'; import { getEndpointField, cn } from '~/utils'; import { useLocalize } from '~/hooks'; import { Panel } from '~/common'; +import keyBy from 'lodash/keyBy'; export default function ModelPanel({ setActivePanel, @@ -52,7 +57,7 @@ export default function ModelPanel({ } }, [provider, models, modelsData, setValue, model]); - const { data: endpointsConfig } = useGetEndpointsQuery(); + const { data: endpointsConfig = {} } = useGetEndpointsQuery(); const bedrockRegions = useMemo(() => { return endpointsConfig?.[provider]?.availableRegions ?? []; @@ -63,10 +68,18 @@ export default function ModelPanel({ [provider, endpointsConfig], ); - const parameters = useMemo(() => { + const parameters = useMemo((): SettingDefinition[] => { + const customParams = endpointsConfig[provider]?.customParams ?? {}; const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, model ?? ''); - return agentSettings[combinedKey] ?? agentSettings[endpointKey]; - }, [endpointType, model, provider]); + const overriddenEndpointKey = customParams.defaultParamsEndpoint ?? endpointKey; + 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) => { setValue(`model_parameters.${optionKey}`, value); diff --git a/client/src/components/SidePanel/Parameters/DynamicCheckbox.tsx b/client/src/components/SidePanel/Parameters/DynamicCheckbox.tsx index 7162c73ea..1c4b23aa5 100644 --- a/client/src/components/SidePanel/Parameters/DynamicCheckbox.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicCheckbox.tsx @@ -1,8 +1,8 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { OptionTypes } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider'; 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 OptionHover from './OptionHover'; import { ESide } from '~/common'; @@ -23,23 +23,20 @@ function DynamicCheckbox({ }: DynamicSettingProps) { const localize = useLocalize(); const { preset } = useChatContext(); - const [inputValue, setInputValue] = useState(!!(defaultValue as boolean | undefined)); + + const [setInputValue, inputValue, setLocalValue] = useDebouncedInput({ + optionKey: settingKey, + initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue, + setter: () => ({}), + setOption, + }); const selectedValue = useMemo(() => { - 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]); + }, [conversation, defaultValue, settingKey]); const handleCheckedChange = (checked: boolean) => { - if (optionType === OptionTypes.Custom) { - // TODO: custom logic, add to payload but not to conversation - setInputValue(checked); - return; - } + setInputValue(checked); setOption(settingKey)(checked); }; @@ -49,8 +46,7 @@ function DynamicCheckbox({ defaultValue, conversation, inputValue, - setInputValue, - preventDelayedUpdate: true, + setInputValue: setLocalValue, }); return ( diff --git a/client/src/components/SidePanel/Parameters/DynamicCombobox.tsx b/client/src/components/SidePanel/Parameters/DynamicCombobox.tsx index c0aca0614..0d7479d64 100644 --- a/client/src/components/SidePanel/Parameters/DynamicCombobox.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicCombobox.tsx @@ -1,5 +1,4 @@ import { useMemo, useState, useCallback } from 'react'; -import { OptionTypes } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider'; import { Label, HoverCard, HoverCardTrigger } from '~/components/ui'; import ControlCombobox from '~/components/ui/ControlCombobox'; @@ -16,7 +15,6 @@ function DynamicCombobox({ description = '', columnSpan, setOption, - optionType, options: _options, items: _items, showLabel = true, @@ -36,11 +34,8 @@ function DynamicCombobox({ const [inputValue, setInputValue] = useState(null); const selectedValue = useMemo(() => { - if (optionType === OptionTypes.Custom) { - return inputValue; - } return conversation?.[settingKey] ?? defaultValue; - }, [conversation, defaultValue, optionType, settingKey, inputValue]); + }, [conversation, defaultValue, settingKey]); const items = useMemo(() => { if (_items != null) { @@ -54,13 +49,10 @@ function DynamicCombobox({ const handleChange = useCallback( (value: string) => { - if (optionType === OptionTypes.Custom) { - setInputValue(value); - } else { - setOption(settingKey)(value); - } + setInputValue(value); + setOption(settingKey)(value); }, - [optionType, setOption, settingKey], + [setOption, settingKey], ); useParameterEffects({ diff --git a/client/src/components/SidePanel/Parameters/DynamicInput.tsx b/client/src/components/SidePanel/Parameters/DynamicInput.tsx index e2c49a5d4..71714d050 100644 --- a/client/src/components/SidePanel/Parameters/DynamicInput.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicInput.tsx @@ -12,7 +12,6 @@ function DynamicInput({ settingKey, defaultValue, description = '', - type = 'string', columnSpan, setOption, optionType, @@ -28,7 +27,7 @@ function DynamicInput({ const { preset } = useChatContext(); const [setInputValue, inputValue, setLocalValue] = useDebouncedInput({ - optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined, + optionKey: settingKey, initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue, setter: () => ({}), setOption, @@ -44,17 +43,7 @@ function DynamicInput({ }); const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - if (type !== 'number') { - setInputValue(e); - return; - } - - if (value === '') { - setInputValue(e); - } else if (!isNaN(Number(value))) { - setInputValue(e, true); - } + setInputValue(e, !isNaN(Number(e.target.value))); }; return ( diff --git a/client/src/components/SidePanel/Parameters/DynamicSlider.tsx b/client/src/components/SidePanel/Parameters/DynamicSlider.tsx index 92ab0a91f..a9142468e 100644 --- a/client/src/components/SidePanel/Parameters/DynamicSlider.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicSlider.tsx @@ -33,7 +33,7 @@ function DynamicSlider({ ); const [setInputValue, inputValue, setLocalValue] = useDebouncedInput({ - optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined, + optionKey: settingKey, initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue, setter: () => ({}), setOption, diff --git a/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx b/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx index f7a67fe71..a603ffe89 100644 --- a/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx @@ -1,5 +1,4 @@ -import { useState, useMemo } from 'react'; -import { OptionTypes } from 'librechat-data-provider'; +import { useState } from 'react'; import type { DynamicSettingProps } from 'librechat-data-provider'; import { Label, Switch, HoverCard, HoverCardTrigger } from '~/components/ui'; import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks'; @@ -14,7 +13,6 @@ function DynamicSwitch({ description = '', columnSpan, setOption, - optionType, readonly = false, showDefault = false, labelCode = false, @@ -34,21 +32,10 @@ function DynamicSwitch({ preventDelayedUpdate: true, }); - const selectedValue = useMemo(() => { - 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 selectedValue = conversation?.[settingKey] ?? defaultValue; const handleCheckedChange = (checked: boolean) => { - if (optionType === OptionTypes.Custom) { - // TODO: custom logic, add to payload but not to conversation - setInputValue(checked); - return; - } + setInputValue(checked); setOption(settingKey)(checked); }; @@ -65,7 +52,7 @@ function DynamicSwitch({ htmlFor={`${settingKey}-dynamic-switch`} className="text-left text-sm font-medium" > - {labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '} + {labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '} {showDefault && ( ({localize('com_endpoint_default')}:{' '} @@ -84,7 +71,11 @@ function DynamicSwitch({ {description && ( )} diff --git a/client/src/components/SidePanel/Parameters/DynamicTags.tsx b/client/src/components/SidePanel/Parameters/DynamicTags.tsx index 5fb6f43a4..413a18e06 100644 --- a/client/src/components/SidePanel/Parameters/DynamicTags.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicTags.tsx @@ -1,10 +1,9 @@ import { useState, useMemo, useCallback, useRef } from 'react'; -import { OptionTypes } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider'; import { Label, Input, HoverCard, HoverCardTrigger, Tag } from '~/components/ui'; import { useChatContext, useToastContext } from '~/Providers'; import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks'; -import { cn, defaultTextProps } from '~/utils'; +import { cn } from '~/utils'; import OptionHover from './OptionHover'; import { ESide } from '~/common'; @@ -15,7 +14,6 @@ function DynamicTags({ description = '', columnSpan, setOption, - optionType, placeholder = '', readonly = false, showDefault = false, @@ -38,14 +36,10 @@ function DynamicTags({ const updateState = useCallback( (update: string[]) => { - if (optionType === OptionTypes.Custom) { - // TODO: custom logic, add to payload but not to conversation - setTags(update); - return; - } + setTags(update); setOption(settingKey)(update); }, - [optionType, setOption, settingKey], + [setOption, settingKey], ); const onTagClick = useCallback(() => { @@ -54,18 +48,10 @@ function DynamicTags({ } }, [inputRef]); - const currentTags: string[] | undefined = useMemo(() => { - if (optionType === OptionTypes.Custom) { - // TODO: custom logic, add to payload but not to conversation - return tags; - } - - if (!conversation?.[settingKey]) { - return defaultValue ?? []; - } - - return conversation[settingKey]; - }, [conversation, defaultValue, optionType, settingKey, tags]); + const currentValue = conversation?.[settingKey]; + const currentTags = useMemo(() => { + return currentValue ?? defaultValue ?? []; + }, [currentValue, defaultValue]); const onTagRemove = useCallback( (indexToRemove: number) => { @@ -75,7 +61,7 @@ function DynamicTags({ if (minTags != null && currentTags.length <= minTags) { showToast({ - message: localize('com_ui_min_tags',{ 0: minTags + '' }), + message: localize('com_ui_min_tags', { 0: minTags + '' }), status: 'warning', }); return; @@ -126,7 +112,7 @@ function DynamicTags({ htmlFor={`${settingKey}-dynamic-input`} className="text-left text-sm font-medium" > - {labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '} + {labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '} {showDefault && ( ( @@ -174,7 +160,11 @@ function DynamicTags({ } }} 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')} /> @@ -182,7 +172,11 @@ function DynamicTags({ {description && ( )} diff --git a/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx b/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx index c8c37c7d2..d4f022139 100644 --- a/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx @@ -2,7 +2,7 @@ import { OptionTypes } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider'; import { Label, TextareaAutosize, HoverCard, HoverCardTrigger } from '~/components/ui'; import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks'; -import { cn, defaultTextProps } from '~/utils'; +import { cn } from '~/utils'; import { useChatContext } from '~/Providers'; import OptionHover from './OptionHover'; import { ESide } from '~/common'; @@ -27,7 +27,7 @@ function DynamicTextarea({ const { preset } = useChatContext(); const [setInputValue, inputValue, setLocalValue] = useDebouncedInput({ - optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined, + optionKey: settingKey, initialValue: optionType !== OptionTypes.Custom ? (conversation?.[settingKey] as string) diff --git a/client/src/components/SidePanel/Parameters/Panel.tsx b/client/src/components/SidePanel/Parameters/Panel.tsx index f7e1f579e..6217228b5 100644 --- a/client/src/components/SidePanel/Parameters/Panel.tsx +++ b/client/src/components/SidePanel/Parameters/Panel.tsx @@ -1,6 +1,12 @@ import { RotateCcw } from 'lucide-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 { SaveAsPresetDialog } from '~/components/Endpoints'; import { useSetIndexOptions, useLocalize } from '~/hooks'; @@ -8,7 +14,7 @@ import { useGetEndpointsQuery } from '~/data-provider'; import { getEndpointField, logger } from '~/utils'; import { componentMapping } from './components'; import { useChatContext } from '~/Providers'; -import { settings } from './settings'; +import keyBy from 'lodash/keyBy'; export default function Parameters() { const localize = useLocalize(); @@ -18,7 +24,9 @@ export default function Parameters() { const [isDialogOpen, setIsDialogOpen] = useState(false); const [preset, setPreset] = useState(null); - const { data: endpointsConfig } = useGetEndpointsQuery(); + const { data: endpointsConfig = {} } = useGetEndpointsQuery(); + const provider = conversation?.endpoint ?? ''; + const model = conversation?.model ?? ''; const bedrockRegions = useMemo(() => { return endpointsConfig?.[conversation?.endpoint ?? '']?.availableRegions ?? []; @@ -29,13 +37,17 @@ export default function Parameters() { [conversation?.endpoint, endpointsConfig], ); - const parameters = useMemo(() => { - const [combinedKey, endpointKey] = getSettingsKeys( - endpointType ?? conversation?.endpoint ?? '', - conversation?.model ?? '', + const parameters = useMemo((): SettingDefinition[] => { + const customParams = endpointsConfig[provider]?.customParams ?? {}; + const [combinedKey, endpointKey] = getSettingsKeys(endpointType ?? provider, 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]; - }, [conversation, endpointType]); + }, [endpointType, endpointsConfig, model, provider]); useEffect(() => { if (!parameters) { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 817620494..8aeaf5577 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -279,6 +279,12 @@ export const endpointSchema = baseEndpointSchema.merge( headers: z.record(z.any()).optional(), addParams: z.record(z.any()).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(), directEndpoint: z.boolean().optional(), titleMessageRole: z.string().optional(), diff --git a/packages/data-provider/src/generate.ts b/packages/data-provider/src/generate.ts index c6a2eafbb..bf0b2c1ac 100644 --- a/packages/data-provider/src/generate.ts +++ b/packages/data-provider/src/generate.ts @@ -467,7 +467,7 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi } /* 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({ code: ZodIssueCode.custom, 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({ code: ZodIssueCode.custom, message: `Invalid default value for setting ${setting.key}. Must be a boolean.`, @@ -485,7 +485,7 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi if ( (setting.type === SettingTypes.String || setting.type === SettingTypes.Enum) && - typeof setting.default !== 'string' + typeof setting.default !== 'string' && setting.default != null ) { errors.push({ code: ZodIssueCode.custom, diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index 849062a9c..b7289454f 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -36,3 +36,4 @@ import * as dataService from './data-service'; export * from './utils'; export * from './actions'; export { default as createPayload } from './createPayload'; +export * from './parameterSettings'; diff --git a/client/src/components/SidePanel/Parameters/settings.ts b/packages/data-provider/src/parameterSettings.ts similarity index 97% rename from client/src/components/SidePanel/Parameters/settings.ts rename to packages/data-provider/src/parameterSettings.ts index 4e905ee98..88fc61e20 100644 --- a/client/src/components/SidePanel/Parameters/settings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -6,8 +6,8 @@ import { ReasoningEffort, BedrockProviders, anthropicSettings, -} from 'librechat-data-provider'; -import type { SettingsConfiguration, SettingDefinition } from 'librechat-data-provider'; +} from './types'; +import { SettingDefinition, SettingsConfiguration } from './generate'; // Base definitions const baseDefinitions: Record = { @@ -654,7 +654,7 @@ const bedrockGeneralCol2: SettingsConfiguration = [ bedrock.region, ]; -export const settings: Record = { +export const paramSettings: Record = { [EModelEndpoint.openAI]: openAI, [EModelEndpoint.azureOpenAI]: openAI, [EModelEndpoint.custom]: openAI, @@ -682,9 +682,9 @@ const bedrockGeneralColumns = { export const presetSettings: Record< string, | { - col1: SettingsConfiguration; - col2: SettingsConfiguration; - } + col1: SettingsConfiguration; + col2: SettingsConfiguration; +} | undefined > = { [EModelEndpoint.openAI]: openAIColumns, @@ -716,11 +716,11 @@ export const presetSettings: Record< }, }; -export const agentSettings: Record = Object.entries( +export const agentParamSettings: Record = Object.entries( presetSettings, -).reduce((acc, [key, value]) => { +).reduce>((acc, [key, value]) => { if (value) { acc[key] = value.col2; } return acc; -}, {}); +}, {}); \ No newline at end of file diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index b365c4e58..8e70bdd0a 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -10,6 +10,7 @@ import type { TConversationTag, TBanner, } from './schemas'; +import { SettingDefinition } from './generate'; export type TOpenAIMessage = OpenAI.Chat.ChatCompletionMessageParam; export * from './schemas'; @@ -268,6 +269,10 @@ export type TConfig = { disableBuilder?: boolean; retrievalModels?: string[]; capabilities?: string[]; + customParams?: { + defaultParamsEndpoint?: string; + paramDefinitions?: SettingDefinition[]; + }; }; export type TEndpointsConfig =