diff --git a/client/src/components/Endpoints/Settings/Google.tsx b/client/src/components/Endpoints/Settings/Google.tsx index d01ba06598..cc6b7ddb70 100644 --- a/client/src/components/Endpoints/Settings/Google.tsx +++ b/client/src/components/Endpoints/Settings/Google.tsx @@ -1,314 +1,127 @@ -import TextareaAutosize from 'react-textarea-autosize'; -import { EModelEndpoint, endpointSettings } from 'librechat-data-provider'; +import { useEffect, useMemo, useRef } from 'react'; import { - Input, - Label, - Slider, - HoverCard, - InputNumber, - SelectDropDown, - HoverCardTrigger, -} from '@librechat/client'; -import type { TModelSelectProps, OnInputNumberChange } from '~/common'; -import { cn, defaultTextProps, optionText, removeFocusOutlines, removeFocusRings } from '~/utils'; -import OptionHoverAlt from '~/components/SidePanel/Parameters/OptionHover'; -import { useLocalize, useDebouncedInput } from '~/hooks'; -import OptionHover from './OptionHover'; -import { ESide } from '~/common'; + getEndpointField, + getSettingsKeys, + presetSettings, + applyModelAwareDefaults, + clampSettingRange, +} from 'librechat-data-provider'; +import type { SettingsConfiguration, SettingDefinition } from 'librechat-data-provider'; +import type { TModelSelectProps } from '~/common'; +import { componentMapping } from '~/components/SidePanel/Parameters/components'; +import { useGetEndpointsQuery } from '~/data-provider'; -export default function Settings({ conversation, setOption, models, readonly }: TModelSelectProps) { - const localize = useLocalize(); - const google = endpointSettings[EModelEndpoint.google]; - const { - model, - modelLabel, - promptPrefix, - temperature, - topP, - topK, - maxContextTokens, - maxOutputTokens, - } = conversation ?? {}; +export default function GoogleSettings({ + conversation, + setOption, + models, + readonly, +}: TModelSelectProps) { + const { data: endpointsConfig } = useGetEndpointsQuery(); - const [setMaxContextTokens, maxContextTokensValue] = useDebouncedInput( - { - setOption, - optionKey: 'maxContextTokens', - initialValue: maxContextTokens, - }, - ); + const parameters = useMemo(() => { + /** A preset for a Google-compatible endpoint need not carry `endpointType`, + * so the configured type is resolved the same way EndpointSettings resolves + * it to pick this component. Falling back to the endpoint's custom name + * would miss `presetSettings` entirely and blank the panel. */ + const endpointType = + getEndpointField(endpointsConfig, conversation?.endpoint, 'type') ?? + conversation?.endpointType; + const model = conversation?.model ?? ''; + const [combinedKey, endpointKey] = getSettingsKeys( + endpointType ?? conversation?.endpoint ?? '', + model, + ); + const columns = presetSettings[combinedKey] ?? presetSettings[endpointKey]; + if (!columns) { + return undefined; + } - if (!conversation) { + /** Google's max output token ceiling moved with Gemini 2.5/3, so the raw + * definition's default is stale for current models. */ + const withModelDefaults = (settings: SettingsConfiguration) => + applyModelAwareDefaults(settings, endpointKey, model); + + const col1 = withModelDefaults(columns.col1); + const col2 = withModelDefaults(columns.col2); + + return { col1, col2 }; + }, [conversation?.endpoint, conversation?.endpointType, conversation?.model, endpointsConfig]); + + /** Only the rendered definition follows the model; the value already stored + * in the preset does not. Switching a 2.5 Pro preset to Flash would leave a + * 32,768 thinking budget in place, past the new ceiling, and Save would + * persist it without the untouched field ever being focused. */ + const appliedModelRef = useRef(conversation?.model ?? undefined); + const model = conversation?.model ?? undefined; + useEffect(() => { + if (appliedModelRef.current === model) { + return; + } + appliedModelRef.current = model; + if (!parameters || !setOption) { + return; + } + for (const setting of [...parameters.col1, ...parameters.col2]) { + if (setting == null || setting.type !== 'number' || setting.range == null) { + continue; + } + /** Same marker the field-level clamp uses: a model that does not narrow + * this parameter leaves the shared fallback range in place. */ + if (setting.range.modelSpecific !== true) { + continue; + } + const stored = conversation?.[setting.key]; + if (typeof stored !== 'number') { + continue; + } + const clamped = clampSettingRange(stored, setting.range); + if (clamped !== stored) { + setOption(setting.key)(clamped); + } + } + }, [model, parameters, conversation, setOption]); + + if (!parameters) { return null; } - const setModel = setOption('model'); - const setModelLabel = setOption('modelLabel'); - const setPromptPrefix = setOption('promptPrefix'); - const setTemperature = setOption('temperature'); - const setTopP = setOption('topP'); - const setTopK = setOption('topK'); - const setMaxOutputTokens = setOption('maxOutputTokens'); - const maxOutputTokensDefault = google.maxOutputTokens.reset(model ?? ''); + const renderComponent = (setting: SettingDefinition | undefined) => { + if (!setting) { + return null; + } + const Component = componentMapping[setting.component]; + if (!Component) { + return null; + } + const { key, default: defaultValue, ...rest } = setting; + + const props = { + key, + settingKey: key, + defaultValue, + ...rest, + readonly, + setOption, + conversation, + }; + + if (key === 'model') { + return ; + } + + return ; + }; return ( -
-
-
- +
+
+
+ {parameters.col1.map(renderComponent)}
-
- - setModelLabel(e.target.value ?? null)} - placeholder={localize('com_endpoint_google_custom_name_placeholder')} - className={cn( - defaultTextProps, - 'flex h-10 max-h-10 w-full resize-none px-3 py-2', - removeFocusOutlines, - )} - /> +
+ {parameters.col2.map(renderComponent)}
-
- - setPromptPrefix(e.target.value ?? null)} - placeholder={localize('com_endpoint_prompt_prefix_placeholder')} - className={cn( - defaultTextProps, - 'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2', - )} - /> -
-
-
- - -
- - -
-
- -
- - -
- - setTemperature(value ?? google.temperature.default)} - max={google.temperature.max} - min={google.temperature.min} - step={google.temperature.step} - controls={false} - className={cn( - defaultTextProps, - cn( - optionText, - 'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-border-light', - ), - )} - /> -
- setTemperature(value[0])} - onDoubleClick={() => setTemperature(google.temperature.default)} - max={google.temperature.max} - min={google.temperature.min} - step={google.temperature.step} - className="flex h-4 w-full" - aria-labelledby="temp-int" - /> -
- -
- - -
- - setTopP(value ?? google.topP.default)} - max={google.topP.max} - min={google.topP.min} - step={google.topP.step} - controls={false} - className={cn( - defaultTextProps, - cn( - optionText, - 'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-border-light', - ), - )} - /> -
- setTopP(value[0])} - onDoubleClick={() => setTopP(google.topP.default)} - max={google.topP.max} - min={google.topP.min} - step={google.topP.step} - className="flex h-4 w-full" - aria-labelledby="top-p-int" - /> -
- -
- - - -
- - setTopK(value ?? google.topK.default)} - max={google.topK.max} - min={google.topK.min} - step={google.topK.step} - controls={false} - className={cn( - defaultTextProps, - cn( - optionText, - 'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-border-light', - ), - )} - /> -
- setTopK(value[0])} - onDoubleClick={() => setTopK(google.topK.default)} - max={google.topK.max} - min={google.topK.min} - step={google.topK.step} - className="flex h-4 w-full" - aria-labelledby="top-k-int" - /> -
- -
- - -
- - setMaxOutputTokens(Number(value))} - max={google.maxOutputTokens.max} - min={google.maxOutputTokens.min} - step={google.maxOutputTokens.step} - controls={false} - className={cn( - defaultTextProps, - cn( - optionText, - 'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-border-light', - ), - )} - /> -
- setMaxOutputTokens(value[0])} - onDoubleClick={() => setMaxOutputTokens(maxOutputTokensDefault)} - max={google.maxOutputTokens.max} - min={google.maxOutputTokens.min} - step={google.maxOutputTokens.step} - className="flex h-4 w-full" - aria-labelledby="max-tokens-int" - /> -
- -
); diff --git a/client/src/components/SidePanel/Parameters/DynamicInput.tsx b/client/src/components/SidePanel/Parameters/DynamicInput.tsx index 19d43cfaf3..4bdf7c29b4 100644 --- a/client/src/components/SidePanel/Parameters/DynamicInput.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicInput.tsx @@ -1,5 +1,6 @@ -import { OptionTypes, SettingTypes } from 'librechat-data-provider'; +import { useEffect, useRef } from 'react'; import { Label, Input, HoverCard, HoverCardTrigger } from '@librechat/client'; +import { OptionTypes, SettingTypes, clampSettingRange } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider'; import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks'; import { cn, sanitizeIntegerInput } from '~/utils'; @@ -28,7 +29,9 @@ function DynamicInput({ const localize = useLocalize(); const { preset } = useChatContext(); - const [setInputValue, inputValue, setLocalValue] = useDebouncedInput({ + const [setInputValue, inputValue, setLocalValue, flushInputValue] = useDebouncedInput< + string | number + >({ optionKey: settingKey, initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue, setter: () => ({}), @@ -61,6 +64,101 @@ function DynamicInput({ setInputValue(e, type === SettingTypes.String ? false : !isNaN(Number(e.target.value))); }; + /** The schema declares a range for number fields, but nothing enforced it, so + * a value past the endpoint's ceiling (a Google output limit above 65535, + * say) was persisted and only rejected later by the provider. Clamping on + * blur rather than on change leaves partially typed numbers alone. */ + const handleInputBlur = () => { + /** Clicking Save blurs the field first, so committing the pending edit here + * is what stops submitPreset reading the value from before it. */ + flushInputValue(); + if (type !== SettingTypes.Number || range == null) { + return; + } + if (inputValue === '' || inputValue == null || inputValue === '-') { + return; + } + const numeric = Number(inputValue); + if (Number.isNaN(numeric)) { + return; + } + const clamped = clampSettingRange(numeric, range); + if (clamped === numeric) { + return; + } + /** Two writes, because one alone is not enough. Going through the debounced + * setter supersedes the out-of-range value typing already queued (lodash + * debounce keeps only the latest args) but would not land for another + * delay, so a Save clicked inside that window would still read the bad + * value. Calling setOption directly closes that window; the later trailing + * invocation then rewrites the same clamped value. */ + setInputValue(clamped, true); + setOption?.(settingKey)(clamped); + }; + + /** A model switch can change this field's bounds while a write typed just + * before it is still queued. Clamping only what is already committed would + * let that queued value land afterwards, past the new ceiling. Re-clamping + * through the same debouncer replaces its arguments, so the queued write + * becomes the corrected one. */ + const rangeKey = range != null ? `${range.min}:${range.max}:${range.positiveMin ?? ''}` : ''; + /** The panel stays mounted while navigating, so the stored value can be + * replaced under an unchanged range. */ + const identityKey = preset?.presetId ?? conversation?.conversationId ?? ''; + /** Null rather than the first pair, so a stored value that the shared range + * allowed but the selected model does not is normalized on mount too, not + * only once the user switches models or blurs the field. */ + const normalizedRef = useRef<{ + identity: string; + range: string; + stored: unknown; + } | null>(null); + const storedValue = conversation?.[settingKey]; + useEffect(() => { + const normalized = normalizedRef.current; + const identityChanged = normalized == null || normalized.identity !== identityKey; + const rangeChanged = normalized == null || normalized.range !== rangeKey; + /** Applying a preset over the open conversation replaces the stored value + * without touching either key, and useParameterEffects then pushes it into + * this field. The user's own edits reach the conversation through this + * same field, so by the time they land the two agree and the value stays + * on the blur-clamped path rather than being corrected mid-edit. */ + const replacedExternally = + normalized != null && normalized.stored !== storedValue && storedValue !== inputValue; + normalizedRef.current = { identity: identityKey, range: rangeKey, stored: storedValue }; + if (!identityChanged && !rangeChanged && !replacedExternally) { + return; + } + if (type !== SettingTypes.Number || range == null) { + return; + } + /** Only a range the model actually narrowed should rewrite a stored value. + * A model that ignores this parameter leaves the shared fallback in place, + * and clamping to that would discard a value set for another model. */ + if (range.modelSpecific !== true) { + return; + } + /** A range change is the one trigger where the local value is what matters: + * a write typed just before the model switch is still queued in it. The + * others are a value arriving from outside this field, which the local one + * has not caught up to yet. */ + const candidate = identityChanged || replacedExternally ? storedValue : inputValue; + if (candidate === '' || candidate == null || candidate === '-') { + return; + } + const numeric = Number(candidate); + if (Number.isNaN(numeric)) { + return; + } + const clamped = clampSettingRange(numeric, range); + if (clamped === numeric) { + return; + } + setInputValue(clamped, true); + setOption?.(settingKey)(clamped); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [identityKey, rangeKey, storedValue]); + const placeholderText = placeholderCode ? localize(placeholder as TranslationKeys) || placeholder : placeholder; @@ -96,6 +194,7 @@ function DynamicInput({ inputMode={type === 'number' ? 'numeric' : undefined} value={inputValue ?? defaultValue ?? ''} onChange={handleInputChange} + onBlur={handleInputBlur} placeholder={placeholderText} className={cn( 'flex h-9 max-h-9 w-full resize-none rounded-lg border border-border-light bg-surface-secondary px-3 py-2', diff --git a/client/src/components/SidePanel/Parameters/DynamicSlider.tsx b/client/src/components/SidePanel/Parameters/DynamicSlider.tsx index 91841c3c22..76adba8c3f 100644 --- a/client/src/components/SidePanel/Parameters/DynamicSlider.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicSlider.tsx @@ -1,5 +1,5 @@ import { useMemo, useCallback } from 'react'; -import { OptionTypes } from 'librechat-data-provider'; +import { OptionTypes, clampSettingRange } from 'librechat-data-provider'; import { Label, Slider, HoverCard, Input, InputNumber, HoverCardTrigger } from '@librechat/client'; import type { DynamicSettingProps } from 'librechat-data-provider'; import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks'; @@ -33,7 +33,9 @@ function DynamicSlider({ [options, range], ); - const [setInputValue, inputValue, setLocalValue] = useDebouncedInput({ + const [setInputValue, inputValue, setLocalValue, flushInputValue] = useDebouncedInput< + string | number + >({ optionKey: settingKey, initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue, setter: () => ({}), @@ -123,6 +125,19 @@ function DynamicSlider({ return String(defaultValue ?? ''); }, [defaultValue, enumMappings, localize]); + /** A typed value can land in the gap between a sentinel minimum and its + * positive floor, which the generated schema rejects. Corrected on blur + * rather than while the number is still being typed. */ + const handleNumberBlur = useCallback(() => { + if (range != null && inputValue != null && inputValue !== '') { + const numeric = Number(inputValue); + if (Number.isFinite(numeric)) { + setInputValue(clampSettingRange(numeric, range)); + } + } + flushInputValue(); + }, [range, inputValue, setInputValue, flushInputValue]); + const handleValueChange = useCallback( (value: number) => { if (isEnum) { @@ -175,6 +190,9 @@ function DynamicSlider({ disabled={readonly} value={inputValue ?? defaultValue} onChange={(value) => setInputValue(Number(value))} + /** Clicking Save blurs this first, so the pending edit is + * committed before submitPreset reads the preset. */ + onBlur={handleNumberBlur} max={range ? range.max : (options?.length ?? 0) - 1} min={range ? range.min : 0} step={range ? (range.step ?? 1) : 1} @@ -214,7 +232,28 @@ function DynamicSlider({ : ((inputValue as number) ?? (defaultValue as number)), ]} onValueChange={(value) => handleValueChange(value[0])} - onDoubleClick={() => setInputValue(defaultValue as string | number)} + /** Fires once the drag or keypress settles, which is the point the + * chosen value should be in the preset rather than pending. It is + * set again here before flushing because the keyboard path commits + * before it reports the change, leaving the debouncer empty for a + * flush that only follows the drag path. The track also steps + * straight through the gap between a sentinel minimum and its + * positive floor, which the generated schema rejects, so the + * released value has to land outside it. */ + onValueCommit={(value) => { + if (!isEnum && range != null) { + setInputValue(clampSettingRange(value[0], range)); + } + flushInputValue(); + }} + /** The browser dispatches this after the second release, so the + * commit above has already fired and the reset would otherwise sit + * in the debouncer while an action clicked next reads the old + * value. */ + onDoubleClick={() => { + setInputValue(defaultValue as string | number); + flushInputValue(); + }} max={max} aria-label={localize(label as TranslationKeys)} min={range ? range.min : 0} diff --git a/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx b/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx index df4b342ae0..a6092555e8 100644 --- a/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx @@ -26,7 +26,9 @@ function DynamicTextarea({ const localize = useLocalize(); const { preset } = useChatContext(); - const [setInputValue, inputValue, setLocalValue] = useDebouncedInput({ + const [setInputValue, inputValue, setLocalValue, flushInputValue] = useDebouncedInput< + string | null + >({ optionKey: settingKey, initialValue: optionType !== OptionTypes.Custom @@ -75,6 +77,9 @@ function DynamicTextarea({ disabled={readonly} value={inputValue ?? ''} onChange={setInputValue} + /** Clicking Save blurs this first, so the pending edit is committed + * before submitPreset reads the preset. */ + onBlur={flushInputValue} aria-label={localize(label as TranslationKeys)} placeholder={ placeholderCode diff --git a/client/src/components/SidePanel/Parameters/__tests__/DynamicInput.spec.tsx b/client/src/components/SidePanel/Parameters/__tests__/DynamicInput.spec.tsx index 5cd348ab90..f898689b09 100644 --- a/client/src/components/SidePanel/Parameters/__tests__/DynamicInput.spec.tsx +++ b/client/src/components/SidePanel/Parameters/__tests__/DynamicInput.spec.tsx @@ -13,10 +13,12 @@ function setup({ type, range, settingKey, + conversation = {}, }: { type: 'number' | 'string'; range?: SettingRange; settingKey: string; + conversation?: Record; }) { const commit = jest.fn(); const setOption = jest.fn(() => commit) as unknown as TSetOption; @@ -27,13 +29,35 @@ function setup({ type={type} range={range} setOption={setOption} - conversation={{}} + conversation={conversation} /> , ); return { input: screen.getByRole('textbox'), commit }; } +function setupNavigable(range: SettingRange, conversation: Record) { + const commit = jest.fn(); + const setOption = jest.fn(() => commit) as unknown as TSetOption; + const tree = (next: Record) => ( + + + + ); + const { rerender } = render(tree(conversation)); + return { + commit, + input: () => screen.getByRole('textbox'), + navigate: (next: Record) => rerender(tree(next)), + }; +} + describe('DynamicInput', () => { beforeEach(() => { jest.useFakeTimers(); @@ -110,6 +134,173 @@ describe('DynamicInput', () => { expect(commit).toHaveBeenLastCalledWith(-1); }); + it('keeps thinkingBudget -1 on blur when a model-specific positive floor is set', () => { + const { input, commit } = setup({ + type: 'number', + range: { min: -1, max: 32768, step: 1, positiveMin: 128 }, + settingKey: 'thinkingBudget', + }); + + fireEvent.change(input, { target: { value: '-1' } }); + fireEvent.blur(input); + + expect(input).toHaveValue('-1'); + act(() => { + jest.advanceTimersByTime(500); + }); + expect(commit).toHaveBeenLastCalledWith(-1); + }); + + /** + * A preset saved while the shared range was in force can hold a budget the + * selected model rejects. Waiting for a model switch or a blur would leave it + * displayed, savable and sendable. + */ + it('normalizes a stored value the selected model no longer allows on mount', () => { + const { input, commit } = setup({ + type: 'number', + range: { min: -1, max: 24576, step: 1, positiveMin: 0, modelSpecific: true }, + settingKey: 'thinkingBudget', + conversation: { thinkingBudget: 32000 }, + }); + + expect(input).toHaveValue('24576'); + expect(commit).toHaveBeenLastCalledWith(24576); + }); + + it('leaves a stored value inside the model range untouched on mount', () => { + const { input, commit } = setup({ + type: 'number', + range: { min: -1, max: 24576, step: 1, positiveMin: 0, modelSpecific: true }, + settingKey: 'thinkingBudget', + conversation: { thinkingBudget: 2048 }, + }); + + expect(input).toHaveValue('2048'); + expect(commit).not.toHaveBeenCalled(); + }); + + /** The shared fallback stays in place for models that ignore the parameter, + * so clamping to it would discard a value set for another model. */ + it('does not normalize on mount against a range the model did not narrow', () => { + const { input, commit } = setup({ + type: 'number', + range: { min: -1, max: 24576, step: 1 }, + settingKey: 'thinkingBudget', + conversation: { thinkingBudget: 32000 }, + }); + + expect(input).toHaveValue('32000'); + expect(commit).not.toHaveBeenCalled(); + }); + + /** + * The panel stays mounted across conversations, so a legacy value can arrive + * under a range that never changed. Keying only on the range left it above + * the model ceiling, savable and sendable. + */ + it('normalizes when a navigation brings in a value the same range rejects', () => { + const range: SettingRange = { + min: -1, + max: 24576, + step: 1, + positiveMin: 0, + modelSpecific: true, + }; + const { navigate, input, commit } = setupNavigable(range, { + conversationId: 'convo-a', + thinkingBudget: 2048, + }); + expect(commit).not.toHaveBeenCalled(); + + navigate({ conversationId: 'convo-b', thinkingBudget: 32000 }); + + expect(input()).toHaveValue('24576'); + expect(commit).toHaveBeenLastCalledWith(24576); + }); + + it('leaves a navigation alone when the incoming value fits the range', () => { + const range: SettingRange = { + min: -1, + max: 24576, + step: 1, + positiveMin: 0, + modelSpecific: true, + }; + const { navigate, commit } = setupNavigable(range, { + conversationId: 'convo-a', + thinkingBudget: 2048, + }); + + navigate({ conversationId: 'convo-b', thinkingBudget: 4096 }); + + expect(commit).not.toHaveBeenCalled(); + }); + + /** + * Applying a preset over the open conversation keeps the conversation id and + * the model, so neither key moves; the value simply arrives. + */ + it('normalizes a stored value replaced under an unchanged conversation', () => { + const range: SettingRange = { + min: -1, + max: 24576, + step: 1, + positiveMin: 0, + modelSpecific: true, + }; + const { navigate, input, commit } = setupNavigable(range, { + conversationId: 'convo-a', + thinkingBudget: 2048, + }); + + navigate({ conversationId: 'convo-a', thinkingBudget: 32000 }); + + expect(input()).toHaveValue('24576'); + expect(commit).toHaveBeenLastCalledWith(24576); + }); + + /** + * A typed value reaches the conversation through this same field, so the two + * agree by the time it lands. Correcting it there would fight the user + * mid-edit, which is why clamping belongs on blur. + */ + it('leaves the value the user typed to the blur clamp when it lands', () => { + const range: SettingRange = { + min: -1, + max: 32768, + step: 1, + positiveMin: 128, + modelSpecific: true, + }; + const { navigate, input, commit } = setupNavigable(range, { + conversationId: 'convo-a', + thinkingBudget: 2048, + }); + + fireEvent.change(input(), { target: { value: '50' } }); + /** The debounced write reaching the conversation, which is what the + * normalization would otherwise read as an external replacement. */ + navigate({ conversationId: 'convo-a', thinkingBudget: 50 }); + + expect(input()).toHaveValue('50'); + expect(commit).not.toHaveBeenCalledWith(128); + }); + + it('clamps a positive thinkingBudget below the model floor on blur', () => { + const { input, commit } = setup({ + type: 'number', + range: { min: -1, max: 32768, step: 1, positiveMin: 128 }, + settingKey: 'thinkingBudget', + }); + + fireEvent.change(input, { target: { value: '50' } }); + fireEvent.blur(input); + + expect(input).toHaveValue('128'); + expect(commit).toHaveBeenLastCalledWith(128); + }); + it('drops the minus sign when the range does not permit negatives', async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); const { input, commit } = setup({ type: 'number', settingKey: 'max_tokens' }); diff --git a/client/src/components/SidePanel/Parameters/__tests__/DynamicSlider.spec.tsx b/client/src/components/SidePanel/Parameters/__tests__/DynamicSlider.spec.tsx new file mode 100644 index 0000000000..936e3e4e92 --- /dev/null +++ b/client/src/components/SidePanel/Parameters/__tests__/DynamicSlider.spec.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { render, screen, act, fireEvent } from '@testing-library/react'; +import type { TSetOption, SettingRange } from 'librechat-data-provider'; +import DynamicSlider from '../DynamicSlider'; +import { ChatContext } from '~/Providers'; + +type ChatContextValue = React.ContextType; + +const chatContextValue = { preset: null } as unknown as ChatContextValue; + +const range: SettingRange = { min: 0, max: 2, step: 0.01 }; + +function setup(conversationValue: number) { + const commit = jest.fn(); + const setOption = jest.fn(() => commit) as unknown as TSetOption; + render( + + + , + ); + return { slider: screen.getByRole('slider'), commit }; +} + +const sentinelRange: SettingRange = { min: -1, max: 32768, step: 1, positiveMin: 128 }; + +function setupSentinel(conversationValue: number) { + const commit = jest.fn(); + const setOption = jest.fn(() => commit) as unknown as TSetOption; + render( + + + , + ); + return { slider: screen.getByRole('slider'), commit }; +} + +describe('DynamicSlider', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + /** + * The browser dispatches `dblclick` after the second pointer release, so the + * commit has already flushed by the time the reset is queued. An action + * clicked in the debounce window would otherwise read the pre-reset value. + */ + it('commits a double-click reset without waiting for the debounce', () => { + const { slider, commit } = setup(0.2); + + fireEvent.doubleClick(slider); + + expect(commit).toHaveBeenCalledWith(1); + }); + + it('still lands the reset exactly once after the debounce elapses', () => { + const { slider, commit } = setup(0.2); + + fireEvent.doubleClick(slider); + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(commit).toHaveBeenCalledTimes(1); + expect(commit).toHaveBeenLastCalledWith(1); + }); + + /** + * A configured sentinel range leaves a gap the track steps straight through, + * and `generateDynamicSchema` rejects exactly those values, so the UI must not + * be able to persist one. + */ + it('lifts a committed value out of the sentinel gap', () => { + const { slider, commit } = setupSentinel(-1); + + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + + expect(commit).toHaveBeenLastCalledWith(128); + }); + + it('leaves a committed value outside the gap alone', () => { + const { slider, commit } = setupSentinel(2048); + + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + + expect(commit).toHaveBeenLastCalledWith(2049); + }); + + /** The adjacent number input reaches the same gap the track does. */ + it('lifts a typed value out of the sentinel gap on blur', () => { + const { commit } = setupSentinel(-1); + const input = screen.getByRole('spinbutton'); + + fireEvent.change(input, { target: { value: '50' } }); + fireEvent.blur(input); + + expect(commit).toHaveBeenLastCalledWith(128); + }); + + it('leaves a typed value outside the gap alone on blur', () => { + const { commit } = setupSentinel(-1); + const input = screen.getByRole('spinbutton'); + + fireEvent.change(input, { target: { value: '2048' } }); + fireEvent.blur(input); + + expect(commit).toHaveBeenLastCalledWith(2048); + }); +}); diff --git a/client/src/hooks/Conversations/__tests__/useDebouncedInput.spec.tsx b/client/src/hooks/Conversations/__tests__/useDebouncedInput.spec.tsx new file mode 100644 index 0000000000..45eb196a8a --- /dev/null +++ b/client/src/hooks/Conversations/__tests__/useDebouncedInput.spec.tsx @@ -0,0 +1,65 @@ +import { renderHook, act } from '@testing-library/react'; +import useDebouncedInput from '../useDebouncedInput'; + +describe('useDebouncedInput', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + /** Callers do not memoize either callback: setOption is a plain arrow in + * useSetIndexOptions and the dynamic settings pass `setter` inline. If the + * debouncer depended on them it would be rebuilt every render, so the flush + * would reach an instance holding nothing while the previous instance's + * timer still fired the superseded value. */ + it('flushes the pending edit even though the callbacks change identity', () => { + const committed: unknown[] = []; + const render = () => + renderHook( + ({ tick }: { tick: number }) => + useDebouncedInput({ + optionKey: 'promptPrefix', + initialValue: '', + /** A new function every render, as the real call sites pass. */ + setOption: () => (value: unknown) => committed.push([tick, value]), + setter: () => ({}) as never, + }), + { initialProps: { tick: 0 } }, + ); + + const { result, rerender } = render(); + + act(() => { + result.current[0]('edited', false); + }); + rerender({ tick: 1 }); + expect(committed).toHaveLength(0); + + act(() => { + result.current[3](); + }); + + expect(committed).toHaveLength(1); + expect((committed[0] as unknown[])[1]).toBe('edited'); + }); + + it('does not commit before the delay elapses', () => { + const committed: unknown[] = []; + const { result } = renderHook(() => + useDebouncedInput({ + optionKey: 'promptPrefix', + initialValue: '', + setOption: () => (value: unknown) => committed.push(value), + setter: () => ({}) as never, + }), + ); + + act(() => { + result.current[0]('typed', false); + }); + expect(committed).toHaveLength(0); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(committed).toEqual(['typed']); + }); +}); diff --git a/client/src/hooks/Conversations/useDebouncedInput.ts b/client/src/hooks/Conversations/useDebouncedInput.ts index 73fbb66723..8fbe0b1294 100644 --- a/client/src/hooks/Conversations/useDebouncedInput.ts +++ b/client/src/hooks/Conversations/useDebouncedInput.ts @@ -1,5 +1,5 @@ +import React, { useState, useCallback, useMemo, useRef } from 'react'; import debounce from 'lodash/debounce'; -import React, { useState, useCallback, useMemo } from 'react'; import type { SetterOrUpdater } from 'recoil'; import type { TSetOption } from '~/common'; import { defaultDebouncedDelay } from '~/common'; @@ -23,16 +23,38 @@ function useDebouncedInput({ (e: React.ChangeEvent | T, numeric?: boolean) => void, T, SetterOrUpdater, - // (newValue: string) => void, + () => void, ] { const [value, setValue] = useState(initialValue); - /** A debounced function to call the passed setOption with the optionKey and new value. - * - Note: We use useMemo to ensure our debounced function is stable across renders and properly typed. */ + /** The callbacks are read through refs so the debounced function itself stays + * stable. Neither is memoized by its caller: `setOption` is a plain arrow in + * useSetIndexOptions and the dynamic settings pass `setter: () => ({})` + * inline, so depending on them rebuilt the debouncer on every render. Each + * render then produced a fresh instance while the previous one kept its + * timer, which left `flush()` pointing at an instance with nothing pending + * and let a superseded value land after a correction. */ + const setOptionRef = useRef(setOption); + const setterRef = useRef(setter); + setOptionRef.current = setOption; + setterRef.current = setter; + + /** A debounced function to call the passed setOption with the optionKey and new value. */ const setDebouncedOption = useMemo( - () => debounce(setOption && optionKey ? setOption(optionKey) : setter || (() => {}), delay), - [setOption, optionKey, setter, delay], + () => + debounce((newValue: T) => { + const currentSetOption = setOptionRef.current; + if (currentSetOption && optionKey != null) { + /** T is the caller's field type; TSetOption is typed against the whole + * conversation union, which does not narrow per key. The previous + * form passed the setter to debounce directly and inherited the same + * looseness. */ + (currentSetOption(optionKey) as (value: T) => void)(newValue); + return; + } + setterRef.current?.(newValue); + }, delay), + [optionKey, delay], ); /** An onChange handler that updates the local state and the debounced option */ @@ -52,7 +74,13 @@ function useDebouncedInput({ }, [setDebouncedOption], ); - return [onChange, value, setValue]; + /** Lets a caller commit a pending edit immediately, e.g. on blur, so a Save + * clicked in the same gesture does not read the pre-edit value. */ + const flush = useCallback(() => { + setDebouncedOption.flush(); + }, [setDebouncedOption]); + + return [onChange, value, setValue, flush]; } export default useDebouncedInput; diff --git a/client/src/hooks/Conversations/useParameterEffects.ts b/client/src/hooks/Conversations/useParameterEffects.ts index a39113342e..f61a908354 100644 --- a/client/src/hooks/Conversations/useParameterEffects.ts +++ b/client/src/hooks/Conversations/useParameterEffects.ts @@ -47,8 +47,12 @@ function useParameterEffects({ } idRef.current = conversationId; - setInputValue(defaultValue as T); - }, [setInputValue, conversation?.conversationId, defaultValue]); + /** Seed from what the conversation already holds, falling back to the + * definition default. Resetting to the default unconditionally made saved + * values read as defaults until the delayed sync below corrected them, and + * editing inside that window wrote the default back. */ + setInputValue((conversation?.[settingKey] ?? defaultValue) as T); + }, [setInputValue, conversation, conversation?.conversationId, settingKey, defaultValue]); /** Resets the local state if presetId changed */ useEffect(() => { @@ -62,8 +66,8 @@ function useParameterEffects({ } presetIdRef.current = presetId; - setInputValue(defaultValue as T); - }, [setInputValue, preset?.presetId, defaultValue]); + setInputValue((preset?.[settingKey] ?? conversation?.[settingKey] ?? defaultValue) as T); + }, [setInputValue, preset, preset?.presetId, conversation, settingKey, defaultValue]); } export default useParameterEffects; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index d905e52f27..208bd2a1ee 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -286,7 +286,6 @@ "com_endpoint_export": "Export", "com_endpoint_export_share": "Export/Share", "com_endpoint_frequency_penalty": "Frequency Penalty", - "com_endpoint_google_custom_name_placeholder": "Set a custom name for Google", "com_endpoint_google_maxoutputtokens": "Maximum number of tokens that can be generated in the response. Specify a lower value for shorter responses and a higher value for longer responses. Note: models may stop before reaching this maximum.", "com_endpoint_google_temp": "Higher values = more random, while lower values = more focused and deterministic. We recommend altering this or Top P but not both.", "com_endpoint_google_thinking": "Enables or disables reasoning. Supported by Gemini 2.5 and 3 series. Note: Gemini 3 Pro cannot fully disable thinking.", @@ -352,7 +351,6 @@ "com_endpoint_prompt_prefix": "Custom Instructions", "com_endpoint_prompt_prefix_assistants": "Additional Instructions", "com_endpoint_prompt_prefix_assistants_placeholder": "Set additional instructions or context on top of the Assistant's main instructions. Ignored if empty.", - "com_endpoint_prompt_prefix_placeholder": "Set custom instructions or context. Ignored if empty.", "com_endpoint_reasoning_context": "Reasoning Context", "com_endpoint_reasoning_effort": "Reasoning Effort", "com_endpoint_reasoning_mode": "Reasoning Mode", diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index d3ab111480..2ce5724f19 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -47,6 +47,40 @@ describe('paramDefinitionSchema', () => { expect(result.success).toBe(true); }); + /** + * The shared `SettingRange` exposes it, so a configured sentinel range would + * otherwise reach the UI with its positive floor silently dropped. + */ + it('preserves a configured positiveMin on the range', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'thinkingBudget', + type: 'number', + component: 'slider', + range: { min: -1, max: 32768, step: 1, positiveMin: 128 }, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data.range).toEqual({ + min: -1, + max: 32768, + step: 1, + positiveMin: 128, + }); + }); + + /** The floor would admit nothing but the sentinel while the clamp maps every + * non-negative input onto a maximum the generated schema then rejects. */ + it('rejects a positiveMin above the range maximum', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'thinkingBudget', + type: 'number', + component: 'slider', + range: { min: -1, max: 100, positiveMin: 200 }, + }); + + expect(result.success).toBe(false); + }); + it('rejects columns > 4', () => { const result = paramDefinitionSchema.safeParse({ key: 'test', diff --git a/packages/data-provider/specs/generate.spec.ts b/packages/data-provider/specs/generate.spec.ts index 2c3cda0f17..4b79d76c5f 100644 --- a/packages/data-provider/specs/generate.spec.ts +++ b/packages/data-provider/specs/generate.spec.ts @@ -1,5 +1,10 @@ import { ZodError, z } from 'zod'; -import { generateDynamicSchema, validateSettingDefinitions, OptionTypes } from '../src/generate'; +import { + generateDynamicSchema, + validateSettingDefinitions, + OptionTypes, + clampSettingRange, +} from '../src/generate'; import type { SettingsConfiguration } from '../src/generate'; describe('generateDynamicSchema', () => { @@ -194,6 +199,171 @@ describe('generateDynamicSchema', () => { }); }); +describe('generateDynamicSchema with positiveMin', () => { + const settings = [ + { + key: 'thinkingBudget', + type: 'number', + component: 'input', + range: { min: -1, max: 32768, positiveMin: 128 }, + }, + ] as SettingsConfiguration; + + it('accepts the sentinel and values at or above the floor', () => { + const schema = generateDynamicSchema(settings); + expect(schema.safeParse({ thinkingBudget: -1 }).success).toBe(true); + expect(schema.safeParse({ thinkingBudget: 128 }).success).toBe(true); + expect(schema.safeParse({ thinkingBudget: 32768 }).success).toBe(true); + }); + + it('rejects non-negative values below the floor', () => { + const schema = generateDynamicSchema(settings); + expect(schema.safeParse({ thinkingBudget: 0 }).success).toBe(false); + expect(schema.safeParse({ thinkingBudget: 127 }).success).toBe(false); + }); +}); + +describe('positiveMin default validation', () => { + const definition = (defaultValue: number): SettingsConfiguration => [ + { + key: 'thinkingBudget', + type: 'number', + component: 'slider', + optionType: 'custom', + default: defaultValue, + range: { min: -1, max: 32768, step: 1, positiveMin: 128 }, + }, + ]; + + it('rejects a default between the sentinel and the positive floor', () => { + for (const invalid of [0, 127]) { + expect(() => validateSettingDefinitions(definition(invalid))).toThrow( + /Must be -1 or at least 128/, + ); + } + }); + + it('accepts the sentinel itself and any value at or above the floor', () => { + expect(() => validateSettingDefinitions(definition(-1))).not.toThrow(); + expect(() => validateSettingDefinitions(definition(128))).not.toThrow(); + }); +}); + +describe('positiveMin range validation', () => { + /** + * `default` is optional and this validator populates it, so a midpoint taken + * across the sentinel gap would make an otherwise coherent definition fail + * the validation in the same pass. + */ + it('synthesizes a slider default inside the admissible interval', () => { + const settings: SettingsConfiguration = [ + { + key: 'budget', + type: 'number', + component: 'slider', + optionType: 'custom', + range: { min: -1, max: 100, step: 1, positiveMin: 80 }, + }, + ]; + + expect(() => validateSettingDefinitions(settings)).not.toThrow(); + expect(settings[0].default).toBe(90); + }); + + it('rejects a positive floor above the maximum', () => { + const settings: SettingsConfiguration = [ + { + key: 'thinkingBudget', + type: 'number', + component: 'slider', + optionType: 'custom', + range: { min: -1, max: 100, step: 1, positiveMin: 200 }, + }, + ]; + + expect(() => validateSettingDefinitions(settings)).toThrow(/cannot exceed max/); + }); +}); + +describe('clampSettingRange', () => { + const proThinkingBudget = { min: -1, max: 32768, step: 1, positiveMin: 128 }; + + it('preserves the negative sentinel', () => { + expect(clampSettingRange(-1, proThinkingBudget)).toBe(-1); + }); + + it('clamps positive values below the model floor up to that floor', () => { + expect(clampSettingRange(0, proThinkingBudget)).toBe(128); + expect(clampSettingRange(127, proThinkingBudget)).toBe(128); + }); + + it('leaves values inside the supported positive range alone', () => { + expect(clampSettingRange(128, proThinkingBudget)).toBe(128); + expect(clampSettingRange(2000, proThinkingBudget)).toBe(2000); + }); + + it('still enforces the ceiling', () => { + expect(clampSettingRange(40000, proThinkingBudget)).toBe(32768); + }); + + it('clamps values below the sentinel up to the sentinel', () => { + expect(clampSettingRange(-2, proThinkingBudget)).toBe(-1); + }); + + /** The minimum is the sentinel whatever its sign, and the generated schema + * admits it outright, so the clamp must not lift it to the floor. */ + it('preserves a non-negative sentinel minimum', () => { + const range = { min: 0, max: 100, step: 1, positiveMin: 10 }; + const settings: SettingsConfiguration = [ + { + key: 'budget', + type: 'number', + component: 'slider', + optionType: 'custom', + range, + }, + ]; + const schema = generateDynamicSchema(settings); + + expect(clampSettingRange(0, range)).toBe(0); + expect(schema.safeParse({ budget: 0 }).success).toBe(true); + /** Still inside the gap, so it lifts to the floor. */ + expect(clampSettingRange(5, range)).toBe(10); + expect(clampSettingRange(-3, range)).toBe(0); + }); + + it('treats a zero positiveMin as a valid floor rather than a missing one', () => { + const flash = { min: -1, max: 24576, step: 1, positiveMin: 0 }; + expect(clampSettingRange(0, flash)).toBe(0); + expect(clampSettingRange(-1, flash)).toBe(-1); + }); + + /** + * The generated schema admits the sentinel or the positive floor and nothing + * between them, so a stored fraction has to resolve to one of those rather + * than survive a normalization the schema then rejects. + */ + it('resolves a value between the sentinel and the floor to the sentinel', () => { + const settings: SettingsConfiguration = [ + { + key: 'thinkingBudget', + type: 'number', + component: 'slider', + optionType: 'model', + range: proThinkingBudget, + }, + ]; + const schema = generateDynamicSchema(settings); + + for (const stored of [-0.5, -0.001]) { + const clamped = clampSettingRange(stored, proThinkingBudget); + expect(clamped).toBe(-1); + expect(schema.safeParse({ thinkingBudget: stored }).success).toBe(false); + expect(schema.safeParse({ thinkingBudget: clamped }).success).toBe(true); + } + }); +}); + describe('validateSettingDefinitions', () => { test('should throw error for Conversation optionType', () => { const validSettings: SettingsConfiguration = [ diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 007db9d438..9822ffbc68 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1089,6 +1089,14 @@ export const paramDefinitionSchema = z.object({ min: z.number(), max: z.number(), step: z.number().optional(), + positiveMin: z.number().optional(), + }) + /** A floor above the ceiling admits nothing but the sentinel, while the + * clamp maps every non-negative input onto a maximum the generated schema + * then rejects. */ + .refine((value) => value.positiveMin == null || value.positiveMin <= value.max, { + message: 'range.positiveMin cannot exceed range.max', + path: ['positiveMin'], }) .optional(), enumMappings: z.record(z.union([z.number(), z.boolean(), z.string()])).optional(), diff --git a/packages/data-provider/src/generate.ts b/packages/data-provider/src/generate.ts index c184983505..e12934ee4e 100644 --- a/packages/data-provider/src/generate.ts +++ b/packages/data-provider/src/generate.ts @@ -1,7 +1,7 @@ import { z, ZodArray, ZodError, ZodIssueCode } from 'zod'; -import { tConversationSchema, googleSettings as google, openAISettings as openAI } from './schemas'; import type { ZodIssue } from 'zod'; import type { TConversation, TSetOption, TPreset } from './schemas'; +import { tConversationSchema, googleSettings as google, openAISettings as openAI } from './schemas'; export type GoogleSettings = Partial; export type OpenAISettings = Partial; @@ -97,6 +97,38 @@ export interface SettingRange { min: number; max: number; step?: number; + /** + * Inclusive floor for non-negative values. Use when `min` is a sentinel + * (Google thinkingBudget `-1` for auto) and positive values have a higher + * documented minimum. + */ + positiveMin?: number; + /** + * Set when this range came from the selected model rather than the shared + * fallback. Only then is it safe to normalize a stored value against it: a + * model that does not use the parameter leaves the generic range in place, + * and clamping to that would discard a value set for another model. + */ + modelSpecific?: boolean; +} + +export function clampSettingRange(value: number, range: SettingRange): number { + if (range.positiveMin != null) { + /** The minimum carries its own meaning here (Google's -1 for automatic), + * and the schema admits it outright, so it survives rather than being + * lifted to the floor. It need not be negative to be the sentinel. */ + if (value === range.min) { + return range.min; + } + /** Below the sentinel there is nothing admissible to lift to, so the value + * resolves to it. Between the sentinel and the floor, the floor is the + * nearest value the generated schema accepts. */ + if (value < Math.max(range.min, 0)) { + return range.min; + } + return Math.min(Math.max(value, range.positiveMin), range.max); + } + return Math.min(Math.max(value, range.min), range.max); } export type SettingsConfiguration = SettingDefinition[]; @@ -118,10 +150,23 @@ export function generateDynamicSchema(settings: SettingsConfiguration) { } = setting; if (type === SettingTypes.Number) { - let schema = z.number(); + let numberSchema = z.number(); if (range) { - schema = schema.min(range.min); - schema = schema.max(range.max); + numberSchema = numberSchema.min(range.min); + numberSchema = numberSchema.max(range.max); + } + /** Widened deliberately: refine returns ZodEffects, not ZodNumber, and + * the number-specific chaining is already done above. */ + let schema: z.ZodTypeAny = numberSchema; + if (range?.positiveMin != null) { + /** Mirrors clampSettingRange so the generated schema and the clamp + * agree: `min` only admits the sentinel, and any non-negative value + * must clear the documented floor. */ + const { positiveMin, min } = range; + schema = numberSchema.refine( + (value) => value === min || value >= positiveMin, + `Expected ${min} or a value of at least ${positiveMin}`, + ); } if (typeof defaultValue === 'number') { schemaFields[key] = schema.default(defaultValue); @@ -363,8 +408,12 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi if (setting.component === ComponentTypes.Slider && setting.type === SettingTypes.Number) { if (setting.default === undefined && setting.range) { - // Set default to the middle of the range if unspecified - setting.default = Math.round((setting.range.min + setting.range.max) / 2); + /** The midpoint of the admissible interval, which a positive floor + * narrows: the span between the sentinel and that floor holds no value + * the generated schema accepts, so a midpoint taken across it would + * fail the validation below. */ + const floor = Math.max(setting.range.min, setting.range.positiveMin ?? setting.range.min); + setting.default = Math.round((floor + setting.range.max) / 2); } } @@ -530,6 +579,32 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi }); } + if ( + setting.type === SettingTypes.Number && + setting.range?.positiveMin != null && + setting.range.positiveMin > setting.range.max + ) { + errors.push({ + code: ZodIssueCode.custom, + message: `Invalid range for setting ${setting.key}. positiveMin (${setting.range.positiveMin}) cannot exceed max (${setting.range.max}).`, + path: ['range'], + }); + } + + if ( + setting.type === SettingTypes.Number && + setting.range?.positiveMin != null && + typeof setting.default === 'number' && + setting.default !== setting.range.min && + setting.default < setting.range.positiveMin + ) { + errors.push({ + code: ZodIssueCode.custom, + message: `Invalid default value for setting ${setting.key}. Must be ${setting.range.min} or at least ${setting.range.positiveMin}.`, + path: ['default'], + }); + } + // Validate enumMappings if (setting.enumMappings && setting.type === SettingTypes.Enum && setting.options) { for (const option of setting.options) { diff --git a/packages/data-provider/src/parameterSettings.spec.ts b/packages/data-provider/src/parameterSettings.spec.ts index a072459bc1..f38e838084 100644 --- a/packages/data-provider/src/parameterSettings.spec.ts +++ b/packages/data-provider/src/parameterSettings.spec.ts @@ -5,6 +5,10 @@ import { EModelEndpoint } from './types'; const googleParams = paramSettings[EModelEndpoint.google] as SettingDefinition[]; const anthropicParams = paramSettings[EModelEndpoint.anthropic] as SettingDefinition[]; const maxOut = (params: SettingDefinition[]) => params.find((p) => p.key === 'maxOutputTokens'); +const maxContext = (params: SettingDefinition[]) => + params.find((p) => p.key === 'maxContextTokens'); +const thinkingBudget = (params: SettingDefinition[]) => + params.find((p) => p.key === 'thinkingBudget'); const hasSetting = (params: SettingDefinition[], key: string) => params.some((param) => param.key === key); @@ -76,4 +80,54 @@ describe('applyModelAwareDefaults', () => { const final = modelAware.map((p) => (p.key === 'maxOutputTokens' ? override : p)); expect(maxOut(final)?.default).toBe(2048); }); + + it('keeps thinkingBudget -1 as the range minimum and applies the Pro floor separately', () => { + const result = applyModelAwareDefaults(googleParams, EModelEndpoint.google, 'gemini-2.5-pro'); + expect(thinkingBudget(result)?.range).toMatchObject({ + min: -1, + max: 32768, + positiveMin: 128, + }); + }); + + it('applies the Flash Lite thinking-budget floor without raising the sentinel minimum', () => { + const result = applyModelAwareDefaults( + googleParams, + EModelEndpoint.google, + 'gemini-2.5-flash-lite', + ); + expect(thinkingBudget(result)?.range).toMatchObject({ + min: -1, + max: 24576, + positiveMin: 512, + }); + }); + + it('applies the Flash thinking-budget ceiling and a zero positive floor', () => { + const result = applyModelAwareDefaults(googleParams, EModelEndpoint.google, 'gemini-2.5-flash'); + expect(thinkingBudget(result)?.range).toMatchObject({ + min: -1, + max: 24576, + positiveMin: 0, + }); + }); +}); + +/** + * The field is rendered by every endpoint, so bounds written for Gemini would + * silently clamp a context window another provider accepts. + */ +describe('maxContextTokens bounds', () => { + it('bounds the Google field to the documented context window', () => { + expect(maxContext(googleParams)?.range).toEqual({ min: 10, max: 2000000, step: 1000 }); + }); + + it('leaves every other endpoint unbounded', () => { + const bounded = Object.entries(paramSettings) + .filter(([endpoint]) => endpoint !== EModelEndpoint.google) + .filter(([, params]) => maxContext(params as SettingDefinition[])?.range != null) + .map(([endpoint]) => endpoint); + + expect(bounded).toEqual([]); + }); }); diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index af2b779119..1a6a2cad66 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -6,6 +6,7 @@ import { EModelEndpoint, openAISettings, googleSettings, + getGoogleThinkingBudgetBounds, Providers, ReasoningEffort, AnthropicEffort, @@ -690,6 +691,16 @@ const meta: Record = { }; const google: Record = { + /** Bounds the hand-rolled editor enforced through InputNumber, and they stay + * scoped to this endpoint: the shared definition is rendered by every other + * endpoint, whose own context windows may fall outside them. */ + maxContextTokens: createDefinition(librechat.maxContextTokens, { + range: { + min: googleSettings.maxContextTokens.min, + max: googleSettings.maxContextTokens.max, + step: googleSettings.maxContextTokens.step, + }, + }), temperature: createDefinition(baseDefinitions.temperature, { default: googleSettings.temperature.default, range: { @@ -830,7 +841,7 @@ const google: Record = { const googleConfig: SettingsConfiguration = [ librechat.modelLabel, librechat.promptPrefix, - librechat.maxContextTokens, + google.maxContextTokens, google.maxOutputTokens, google.temperature, google.topP, @@ -851,7 +862,7 @@ const googleCol1: SettingsConfiguration = [ ]; const googleCol2: SettingsConfiguration = [ - librechat.maxContextTokens, + google.maxContextTokens, google.maxOutputTokens, google.temperature, google.topP, @@ -1264,14 +1275,32 @@ export function applyModelAwareDefaults( if (!model) { return settings; } - const modelAwareSettings = endpoint === EModelEndpoint.google - ? settings.map((setting) => - setting.key === 'maxOutputTokens' - ? { ...setting, default: googleSettings.maxOutputTokens.reset(model) } - : setting, - ) + ? settings.map((setting) => { + if (setting.key === 'maxOutputTokens') { + return { ...setting, default: googleSettings.maxOutputTokens.reset(model) }; + } + /** The shared thinking budget range is model-agnostic, so it caps Pro below + * its real ceiling and accepts Flash values the provider rejects. The + * maximum and the positive floor move together. `range.min` stays -1 so + * the "decide automatically" sentinel remains typeable. */ + if (setting.key === 'thinkingBudget' && setting.range != null) { + const bounds = getGoogleThinkingBudgetBounds(model); + if (bounds != null) { + return { + ...setting, + range: { + ...setting.range, + max: bounds.max, + positiveMin: bounds.min, + modelSpecific: true, + }, + }; + } + } + return setting; + }) : settings; if (endpoint !== EModelEndpoint.anthropic || supportsPromptCache(model)) { diff --git a/packages/data-provider/src/schemas.spec.ts b/packages/data-provider/src/schemas.spec.ts index 2439a232df..fca60a549c 100644 --- a/packages/data-provider/src/schemas.spec.ts +++ b/packages/data-provider/src/schemas.spec.ts @@ -12,6 +12,7 @@ import { eReasoningModeSchema, eReasoningContextSchema, subagentThreadLineageSchema, + getGoogleThinkingBudgetBounds, } from './schemas'; describe('anthropicSettings', () => { @@ -519,6 +520,37 @@ describe('googleSettings', () => { }); }); + describe('getGoogleThinkingBudgetBounds()', () => { + it('returns the documented Pro floor and ceiling', () => { + expect(getGoogleThinkingBudgetBounds('gemini-2.5-pro')).toEqual({ min: 128, max: 32768 }); + expect(getGoogleThinkingBudgetBounds('gemini-2.5-pro-preview-05-06')).toEqual({ + min: 128, + max: 32768, + }); + }); + + it('returns the documented Flash floor and ceiling', () => { + expect(getGoogleThinkingBudgetBounds('gemini-2.5-flash')).toEqual({ min: 0, max: 24576 }); + }); + + it('returns the documented Flash Lite floor and ceiling', () => { + expect(getGoogleThinkingBudgetBounds('gemini-2.5-flash-lite')).toEqual({ + min: 512, + max: 24576, + }); + expect(getGoogleThinkingBudgetBounds('gemini-2.5-flash-lite-preview-09-2025')).toEqual({ + min: 512, + max: 24576, + }); + }); + + it('does not apply 2.5 bounds to other Gemini families', () => { + expect(getGoogleThinkingBudgetBounds('gemini-2.0-flash')).toBeUndefined(); + expect(getGoogleThinkingBudgetBounds('gemini-3-pro')).toBeUndefined(); + expect(getGoogleThinkingBudgetBounds('gemini-1.5-pro')).toBeUndefined(); + }); + }); + describe('compactGoogleSchema (model-aware maxOutputTokens)', () => { it('strips the model default for current Gemini models', () => { const result = compactGoogleSchema.parse({ diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 39d62751b4..f86fb37f65 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -461,10 +461,55 @@ const getGoogleMaxOutputTokens = (modelName: string): number => { return GOOGLE_LEGACY_MAX_OUTPUT; }; +/** + * Per-model thinking budget bounds, documented in + * `com_endpoint_google_thinking_budget`: Gemini 2.5 Pro accepts 128-32,768, + * Flash accepts 0-24,576, and Flash Lite accepts 512-24,576. The generic + * 32,000 in the shared definition both under-limits Pro and lets invalid + * Flash values through. + * + * `-1` remains the "decide automatically" sentinel and is not part of these + * floors. Callers must keep `range.min` at -1 and apply `min` only to + * non-negative values. + */ +const GOOGLE_THINKING_BUDGET_PRO_MAX = 32768 as const; +const GOOGLE_THINKING_BUDGET_FLASH_MAX = 24576 as const; +const GOOGLE_THINKING_BUDGET_PRO_MIN = 128 as const; +const GOOGLE_THINKING_BUDGET_FLASH_MIN = 0 as const; +const GOOGLE_THINKING_BUDGET_FLASH_LITE_MIN = 512 as const; + +export type GoogleThinkingBudgetBounds = { min: number; max: number }; + +export const getGoogleThinkingBudgetBounds = ( + modelName: string, +): GoogleThinkingBudgetBounds | undefined => { + if (!/gemini-2\.5/i.test(modelName)) { + return undefined; + } + if (/flash[-_.]?lite/i.test(modelName)) { + return { min: GOOGLE_THINKING_BUDGET_FLASH_LITE_MIN, max: GOOGLE_THINKING_BUDGET_FLASH_MAX }; + } + if (/flash/i.test(modelName)) { + return { min: GOOGLE_THINKING_BUDGET_FLASH_MIN, max: GOOGLE_THINKING_BUDGET_FLASH_MAX }; + } + if (/pro/i.test(modelName)) { + return { min: GOOGLE_THINKING_BUDGET_PRO_MIN, max: GOOGLE_THINKING_BUDGET_PRO_MAX }; + } + return undefined; +}; + +export const getGoogleThinkingBudgetMax = (modelName: string): number | undefined => + getGoogleThinkingBudgetBounds(modelName)?.max; + export const googleSettings = { model: { default: 'gemini-1.5-flash-latest' as const, }, + maxContextTokens: { + min: 10 as const, + max: 2000000 as const, + step: 1000 as const, + }, maxOutputTokens: { min: 1 as const, max: GOOGLE_MAX_OUTPUT, @@ -1308,6 +1353,7 @@ export const googleBaseSchema = tConversationSchema.pick({ examples: true, temperature: true, maxOutputTokens: true, + resendFiles: true, artifacts: true, topP: true, topK: true,