🧮 fix: Render Google Settings From the Shared Schema and Bound Them Per Model (#14989)

* fix: render Google settings from the shared schema and bound them correctly

Google was the last endpoint hand-rolling its own sliders. The schema was
already there and already wired, only the frontend never used it, so
rendering from it replaces 315 lines with the body OpenAI, Anthropic and
Bedrock share.

That closed a functional gap rather than only moving code: the old form
exposed six fields where the schema declares fifteen, so Resend Files,
Thinking, Thinking Budget, Thinking Level, Grounding with Google Search,
URL Context and File Token Limit were unreachable from a Google preset.
resendFiles is added to the Google schema so its switch round-trips, and
the endpoint type is resolved from the endpoints config, since a preset
for a Google-compatible endpoint need not carry endpointType and would
otherwise blank the panel.

Sharing the controls also meant inheriting their gaps, which this fixes:

- Number settings declared a range that nothing enforced, so a value past
  the provider's ceiling was persisted and rejected later. clampSettingRange
  applies it, and generateDynamicSchema validates the same rule, so the
  definition is the single source of truth for both.
- Thinking budget bounds are per model. The generic range capped 2.5 Pro
  below its documented 32,768 and admitted Flash values above 24,576.
  positiveMin carries the documented floors while -1 stays typeable as the
  decide-automatically sentinel.
- Ranges the model narrowed are marked modelSpecific, so a switch to a
  model that ignores the parameter cannot rewrite a value set for another.
- useDebouncedInput rebuilt its debouncer every render, because neither
  setOption nor the inline setter is memoized, so pending edits were never
  really superseded and a flush reached an instance holding nothing. The
  callbacks move to refs, and the text and slider controls flush on blur or
  value commit so Save and Export cannot read a stale preset.
- Controls reset to their definition default on a conversation or preset
  change and only recovered ~560ms later, which showed saved values as
  defaults and could write the default back.

The debounce regression test fails against the previous memo dependencies
and passes with the refs, so the flush is verified rather than assumed.

* fix: keep the context token bounds on the Google setting

The bounds came from the hand-rolled Google editor, but they were added to
the shared definition every endpoint renders, so blurring the field clamped
OpenAI, Anthropic, Bedrock and custom endpoints to a window that is only
Gemini's. Custom endpoints may declare context windows outside it.

* fix: agree with the generated schema across the sentinel gap

A stored value between range.min and zero passed through clampSettingRange
unchanged, though the schema admits only the sentinel or the positive floor,
so normalization could preserve a value the provider then rejects. Validate
a configured default against the same rule.

* fix: keep positiveMin on configured parameter definitions

The runtime schema for customParams.paramDefinitions retained only min, max
and step, so a configured positive floor was stripped before the UI saw it
while the shared SettingRange type advertised it.

* fix: commit a double-click slider reset immediately

The browser dispatches dblclick after the second pointer release, so the
value commit has already flushed and the reset sat in the debouncer. Saving
or exporting inside that window read the value the slider no longer showed.

* fix: normalize an out-of-range stored value on mount

The applied-range ref started at the first range, so the effect returned
immediately and a budget saved under the shared range stayed displayed and
savable when the selected model no longer allowed it.

* fix: normalize on navigation and keep sliders out of the sentinel gap

The parameters panel stays mounted across conversations, so a legacy budget
could arrive under a range that never changed; keying the normalization on
the conversation or preset identity as well catches it. After a navigation
the local value still belongs to the conversation being left, so the
incoming stored value is what gets normalized.

A slider steps straight through the gap between a sentinel minimum and its
positive floor, which the generated schema rejects, so the committed value
is clamped. It is also set before the flush: the keyboard path commits
before it reports the change, so the flush alone had nothing to write.

* fix: close the remaining paths into the sentinel gap

Applying a preset over the open conversation replaces the stored value
without changing the conversation id or the model, so normalization now also
triggers on a stored value that arrives differing from the local one. A
value the user typed reaches the conversation through this same field and
matches by the time it lands, so it stays on the blur-clamped path.

The slider's adjacent number input only flushed on blur, so a typed value
could sit in the gap the track is now kept out of.

A configured positiveMin above the maximum admits nothing but the sentinel
while the clamp maps every non-negative input onto a maximum the generated
schema rejects, so both the config schema and the definition validator
refuse it.

* fix: keep a non-negative sentinel and a loadable slider default

The minimum is the sentinel whatever its sign, and the generated schema
admits it outright, so a range like { min: 0, positiveMin: 10 } no longer
has its 0 lifted to the floor by the clamp.

The synthesized slider default took the midpoint of the whole range, which
for a sentinel range lands in the gap the validation added alongside it, so
an otherwise coherent custom definition failed to load. It now takes the
midpoint of the admissible interval.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2026-08-26 03:21:28 +02:00 committed by GitHub
parent 0383030817
commit 227a99ede8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1154 additions and 336 deletions

View file

@ -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<number | null | undefined>(
{
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<string | undefined>(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 <Component {...props} options={models} />;
}
return <Component {...props} />;
};
return (
<div className="grid grid-cols-5 gap-6">
<div className="col-span-5 flex flex-col items-center justify-start gap-6 sm:col-span-3">
<div className="grid w-full items-center gap-2">
<SelectDropDown
title={localize('com_ui_model')}
value={model ?? ''}
setValue={setModel}
availableValues={models}
disabled={readonly}
className={cn(defaultTextProps, 'flex w-full resize-none', removeFocusRings)}
containerClassName="flex w-full resize-none"
/>
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="grid grid-cols-1 gap-6 md:grid-cols-5">
<div className="flex flex-col gap-6 md:col-span-3">
{parameters.col1.map(renderComponent)}
</div>
<div className="grid w-full items-center gap-2">
<Label htmlFor="modelLabel" className="text-left text-sm font-medium">
{localize('com_endpoint_custom_name')}{' '}
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
</Label>
<Input
id="modelLabel"
disabled={readonly}
value={modelLabel || ''}
onChange={(e) => 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,
)}
/>
<div className="flex flex-col gap-6 md:col-span-2">
{parameters.col2.map(renderComponent)}
</div>
<div className="grid w-full items-center gap-2">
<Label htmlFor="promptPrefix" className="text-left text-sm font-medium">
{localize('com_endpoint_prompt_prefix')}{' '}
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
</Label>
<TextareaAutosize
id="promptPrefix"
disabled={readonly}
value={promptPrefix || ''}
onChange={(e) => 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',
)}
/>
</div>
</div>
<div className="col-span-5 flex flex-col items-center justify-start gap-6 px-3 sm:col-span-2">
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="mt-1 flex w-full justify-between">
<Label htmlFor="max-context-tokens" className="text-left text-sm font-medium">
{localize('com_endpoint_context_tokens')}{' '}
</Label>
<InputNumber
id="max-context-tokens"
stringMode={false}
disabled={readonly}
value={maxContextTokensValue as number}
onChange={setMaxContextTokens as OnInputNumberChange}
placeholder={localize('com_nav_theme_system')}
min={10}
max={2000000}
step={1000}
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',
'w-1/3',
),
)}
/>
</div>
</HoverCardTrigger>
<OptionHoverAlt
description="com_endpoint_context_info"
langCode={true}
side={ESide.Left}
/>
</HoverCard>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex justify-between">
<Label htmlFor="temp-int" className="text-left text-sm font-medium">
{localize('com_endpoint_temperature')}{' '}
<small className="opacity-40">
({localize('com_endpoint_default')}: {google.temperature.default})
</small>
</Label>
<InputNumber
id="temp-int"
disabled={readonly}
value={temperature}
onChange={(value) => 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',
),
)}
/>
</div>
<Slider
disabled={readonly}
value={[temperature ?? google.temperature.default]}
onValueChange={(value) => 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"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
</HoverCard>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex justify-between">
<Label htmlFor="top-p-int" className="text-left text-sm font-medium">
{localize('com_endpoint_top_p')}{' '}
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: google.topP.default + '' })})
</small>
</Label>
<InputNumber
id="top-p-int"
disabled={readonly}
value={topP}
onChange={(value) => 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',
),
)}
/>
</div>
<Slider
disabled={readonly}
value={[topP ?? google.topP.default]}
onValueChange={(value) => 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"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
</HoverCard>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex justify-between">
<Label htmlFor="top-k-int" className="text-left text-sm font-medium">
{localize('com_endpoint_top_k')}{' '}
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: google.topK.default + '' })})
</small>
</Label>
<InputNumber
id="top-k-int"
disabled={readonly}
value={topK}
onChange={(value) => 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',
),
)}
/>
</div>
<Slider
disabled={readonly}
value={[topK ?? google.topK.default]}
onValueChange={(value) => 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"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topk" side={ESide.Left} />
</HoverCard>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex justify-between">
<Label htmlFor="max-tokens-int" className="text-left text-sm font-medium">
{localize('com_endpoint_max_output_tokens')}{' '}
<small className="opacity-40">
(
{localize('com_endpoint_default_with_num', {
0: maxOutputTokensDefault + '',
})}
)
</small>
</Label>
<InputNumber
id="max-tokens-int"
disabled={readonly}
value={maxOutputTokens}
onChange={(value) => 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',
),
)}
/>
</div>
<Slider
disabled={readonly}
value={[maxOutputTokens ?? maxOutputTokensDefault]}
onValueChange={(value) => 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"
/>
</HoverCardTrigger>
<OptionHover
endpoint={conversation.endpoint ?? ''}
type="maxoutputtokens"
side={ESide.Left}
/>
</HoverCard>
</div>
</div>
);

View file

@ -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<string | number>({
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',

View file

@ -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<string | number>({
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}

View file

@ -26,7 +26,9 @@ function DynamicTextarea({
const localize = useLocalize();
const { preset } = useChatContext();
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | null>({
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

View file

@ -13,10 +13,12 @@ function setup({
type,
range,
settingKey,
conversation = {},
}: {
type: 'number' | 'string';
range?: SettingRange;
settingKey: string;
conversation?: Record<string, unknown>;
}) {
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}
/>
</ChatContext.Provider>,
);
return { input: screen.getByRole('textbox'), commit };
}
function setupNavigable(range: SettingRange, conversation: Record<string, unknown>) {
const commit = jest.fn();
const setOption = jest.fn(() => commit) as unknown as TSetOption;
const tree = (next: Record<string, unknown>) => (
<ChatContext.Provider value={chatContextValue}>
<DynamicInput
settingKey="thinkingBudget"
type="number"
range={range}
setOption={setOption}
conversation={next}
/>
</ChatContext.Provider>
);
const { rerender } = render(tree(conversation));
return {
commit,
input: () => screen.getByRole('textbox'),
navigate: (next: Record<string, unknown>) => 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' });

View file

@ -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<typeof ChatContext>;
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(
<ChatContext.Provider value={chatContextValue}>
<DynamicSlider
settingKey="temperature"
label="Temperature"
type="number"
range={range}
defaultValue={1}
setOption={setOption}
conversation={{ temperature: conversationValue }}
/>
</ChatContext.Provider>,
);
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(
<ChatContext.Provider value={chatContextValue}>
<DynamicSlider
settingKey="thinkingBudget"
label="Thinking budget"
type="number"
range={sentinelRange}
defaultValue={-1}
setOption={setOption}
conversation={{ thinkingBudget: conversationValue }}
/>
</ChatContext.Provider>,
);
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);
});
});

View file

@ -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<string>({
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<string>({
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']);
});
});

View file

@ -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<T = unknown>({
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | T, numeric?: boolean) => void,
T,
SetterOrUpdater<T>,
// (newValue: string) => void,
() => void,
] {
const [value, setValue] = useState<T>(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<T = unknown>({
},
[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;

View file

@ -47,8 +47,12 @@ function useParameterEffects<T = unknown>({
}
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<T = unknown>({
}
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;

View file

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

View file

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

View file

@ -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 = [

View file

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

View file

@ -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<typeof google>;
export type OpenAISettings = Partial<typeof google>;
@ -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) {

View file

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

View file

@ -6,6 +6,7 @@ import {
EModelEndpoint,
openAISettings,
googleSettings,
getGoogleThinkingBudgetBounds,
Providers,
ReasoningEffort,
AnthropicEffort,
@ -690,6 +691,16 @@ const meta: Record<string, SettingDefinition> = {
};
const google: Record<string, SettingDefinition> = {
/** 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<string, SettingDefinition> = {
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)) {

View file

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

View file

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