🌱 feat: Support Soft Default Model Spec (#13554)

* feat: add soft default model spec

* chore: sort ChatRoute imports
This commit is contained in:
Danny Avila 2026-06-06 14:20:59 -04:00 committed by GitHub
parent 5c00bbe28c
commit 83bdd3d65d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 226 additions and 26 deletions

View file

@ -30,6 +30,7 @@ import {
getDefaultModelSpec,
getDefaultEndpoint,
getModelSpecPreset,
hasModelSelection,
buildDefaultConvo,
logger,
} from '~/utils';
@ -321,16 +322,15 @@ const useNewConvo = (index = 0) => {
let preset = _preset;
const result = getDefaultModelSpec(startupConfig);
const defaultModelSpec = result?.default ?? result?.last;
if (
!preset &&
startupConfig &&
(startupConfig.modelSpecs?.prioritize === true ||
(startupConfig.interface?.modelSelect ?? true) !== true ||
(result?.last != null &&
Object.keys(_template).filter((key) => key !== 'chatProjectId').length === 0)) &&
defaultModelSpec
) {
const defaultModelSpec = result?.default ?? result?.last ?? result?.softDefault;
const shouldApplyModelSpec =
result?.softDefault != null
? !hasModelSelection(_template)
: startupConfig?.modelSpecs?.prioritize === true ||
(startupConfig?.interface?.modelSelect ?? true) !== true ||
(result?.last != null &&
Object.keys(_template).filter((key) => key !== 'chatProjectId').length === 0);
if (!preset && startupConfig && shouldApplyModelSpec && defaultModelSpec) {
preset = getModelSpecPreset(defaultModelSpec);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo } from 'react';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { Spinner, useToastContext } from '@librechat/client';
import { useParams, useSearchParams } from 'react-router-dom';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
@ -16,6 +16,12 @@ import {
logger,
clearMessagesCache,
} from '~/utils';
import {
useGetConvoIdQuery,
useGetStartupConfig,
useGetEndpointsQuery,
useProjectQuery,
} from '~/data-provider';
import {
useAssistantListMap,
useIdChangeEffect,
@ -23,12 +29,6 @@ import {
useNewConvo,
useLocalize,
} from '~/hooks';
import {
useGetConvoIdQuery,
useGetStartupConfig,
useGetEndpointsQuery,
useProjectQuery,
} from '~/data-provider';
import { ToolCallsMapProvider } from '~/Providers';
import ChatView from '~/components/Chat/ChatView';
import { NotificationSeverity } from '~/common';
@ -167,7 +167,7 @@ export default function ChatRoute() {
const getNewConvoPreset = () => {
const result = getDefaultModelSpec(startupConfig);
const spec = result?.default ?? result?.last;
const spec = result?.default ?? result?.last ?? result?.softDefault;
const specPreset = spec ? getModelSpecPreset(spec) : undefined;
const queryParams: Record<string, string> = {};
@ -213,7 +213,7 @@ export default function ChatRoute() {
isNotFoundError(initialConvoQuery.error)
) {
const result = getDefaultModelSpec(startupConfig);
const spec = result?.default ?? result?.last;
const spec = result?.default ?? result?.last ?? result?.softDefault;
showToast({
message: localize('com_ui_conversation_not_found'),
severity: NotificationSeverity.WARNING,

View file

@ -0,0 +1,90 @@
import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import type { TModelSpec, TStartupConfig } from 'librechat-data-provider';
import { getDefaultModelSpec } from '../endpoints';
const createModelSpec = (name: string, overrides: Partial<TModelSpec> = {}): TModelSpec =>
({
name,
label: name,
preset: {
endpoint: EModelEndpoint.openAI,
model: name,
},
...overrides,
}) as TModelSpec;
const createStartupConfig = (list: TModelSpec[]): TStartupConfig =>
({
interface: {
modelSelect: true,
},
modelSpecs: {
prioritize: true,
list,
},
}) as TStartupConfig;
describe('getDefaultModelSpec', () => {
beforeEach(() => {
localStorage.clear();
});
it('uses the soft default for a fresh user with no prior selection', () => {
const regularSpec = createModelSpec('regular-spec');
const softSpec = createModelSpec('soft-spec', { softDefault: true });
const result = getDefaultModelSpec(createStartupConfig([regularSpec, softSpec]));
expect(result).toEqual({ softDefault: softSpec });
});
it('keeps the last selected spec before applying the soft default', () => {
const lastSpec = createModelSpec('last-spec');
const softSpec = createModelSpec('soft-spec', { softDefault: true });
localStorage.setItem(LocalStorageKeys.LAST_SPEC, lastSpec.name);
const result = getDefaultModelSpec(createStartupConfig([softSpec, lastSpec]));
expect(result).toEqual({ last: lastSpec });
});
it('does not apply the soft default when a prior model selection exists', () => {
const softSpec = createModelSpec('soft-spec', { softDefault: true });
localStorage.setItem(
LocalStorageKeys.LAST_MODEL,
JSON.stringify({ [EModelEndpoint.openAI]: 'gpt-4o' }),
);
const result = getDefaultModelSpec(createStartupConfig([softSpec]));
expect(result).toBeUndefined();
});
it('does not apply the soft default when a prior agent selection exists', () => {
const softSpec = createModelSpec('soft-spec', { softDefault: true });
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_123');
const result = getDefaultModelSpec(createStartupConfig([softSpec]));
expect(result).toBeUndefined();
});
it('keeps hard admin defaults ahead of user history and soft defaults', () => {
const hardSpec = createModelSpec('hard-spec', { default: true });
const softSpec = createModelSpec('soft-spec', { softDefault: true });
localStorage.setItem(LocalStorageKeys.LAST_SPEC, softSpec.name);
const result = getDefaultModelSpec(createStartupConfig([softSpec, hardSpec]));
expect(result).toEqual({ default: hardSpec });
});
it('preserves the legacy first-spec fallback when no soft default is configured', () => {
const firstSpec = createModelSpec('first-spec');
const secondSpec = createModelSpec('second-spec');
const result = getDefaultModelSpec(createStartupConfig([firstSpec, secondSpec]));
expect(result).toEqual({ default: firstSpec });
});
});

View file

@ -137,6 +137,95 @@ interface InitiatedTemplateResult {
newEndpointType: EModelEndpoint | undefined;
}
type StoredModelSelection = Pick<
t.TConversation,
'endpoint' | 'model' | 'spec' | 'agent_id' | 'assistant_id'
>;
function hasSelectionValue(value?: string | null): boolean {
return typeof value === 'string' && value.trim() !== '';
}
function parseStoredModelSelection(
value: string | null,
): Partial<StoredModelSelection> | undefined {
if (!value) {
return;
}
try {
return JSON.parse(value) as Partial<StoredModelSelection>;
} catch {
return;
}
}
function hasStoredPrefixValue(prefix: string): boolean {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key?.startsWith(prefix)) {
continue;
}
if (hasSelectionValue(localStorage.getItem(key))) {
return true;
}
}
return false;
}
function hasStoredModelValue(): boolean {
const storedModelValue = localStorage.getItem(LocalStorageKeys.LAST_MODEL);
if (!storedModelValue) {
return false;
}
try {
const storedModels = JSON.parse(storedModelValue) as Record<string, string | null | undefined>;
return Object.values(storedModels).some(hasSelectionValue);
} catch {
return false;
}
}
export function hasModelSelection(selection?: Partial<StoredModelSelection> | null): boolean {
if (!selection) {
return false;
}
return (
hasSelectionValue(selection.spec) ||
hasSelectionValue(selection.agent_id) ||
hasSelectionValue(selection.assistant_id) ||
hasSelectionValue(selection.model) ||
hasSelectionValue(selection.endpoint)
);
}
function hasStoredModelSelection(): boolean {
if (hasSelectionValue(localStorage.getItem(LocalStorageKeys.LAST_SPEC))) {
return true;
}
if (hasStoredModelValue()) {
return true;
}
if (
hasStoredPrefixValue(LocalStorageKeys.AGENT_ID_PREFIX) ||
hasStoredPrefixValue(LocalStorageKeys.ASST_ID_PREFIX)
) {
return true;
}
const lastConversationSetup = parseStoredModelSelection(
localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'),
);
return hasModelSelection(lastConversationSetup);
}
/** Get the conditional logic for switching conversations */
export function getConvoSwitchLogic(params: ConversationInitParams): InitiatedTemplateResult {
const { conversation, newEndpoint, endpointsConfig, modularChat = false } = params;
@ -269,13 +358,14 @@ export function applyModelSpecEphemeralAgent({
/**
* Gets default model spec from config and user preferences.
* Priority: admin default last selected first spec (when prioritize=true or modelSelect disabled).
* Otherwise: admin default or last conversation spec.
* Priority: hard admin default prior user selection soft first-time default.
* Legacy first-spec prioritization remains only when no soft default is configured.
*/
export function getDefaultModelSpec(startupConfig?: t.TStartupConfig):
| {
default?: t.TModelSpec;
last?: t.TModelSpec;
softDefault?: t.TModelSpec;
}
| undefined {
const { modelSpecs, interface: interfaceConfig } = startupConfig ?? {};
@ -284,20 +374,34 @@ export function getDefaultModelSpec(startupConfig?: t.TStartupConfig):
return;
}
const defaultSpec = list?.find((spec) => spec.default);
const softDefaultSpec = list?.find((spec) => spec.softDefault);
if (prioritize === true || !interfaceConfig?.modelSelect) {
const lastSelectedSpecName = localStorage.getItem(LocalStorageKeys.LAST_SPEC);
const lastSelectedSpec = list?.find((spec) => spec.name === lastSelectedSpecName);
return { default: defaultSpec || lastSelectedSpec || list?.[0] };
if (defaultSpec) {
return { default: defaultSpec };
}
if (lastSelectedSpec) {
return { last: lastSelectedSpec };
}
if (softDefaultSpec) {
return hasStoredModelSelection() ? undefined : { softDefault: softDefaultSpec };
}
return { default: list?.[0] };
} else if (defaultSpec) {
return { default: defaultSpec };
}
const lastConversationSetup = JSON.parse(
localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0') ?? '{}',
const lastConversationSetup = parseStoredModelSelection(
localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'),
);
if (!lastConversationSetup.spec) {
const lastConversationSpecName = lastConversationSetup?.spec;
if (!hasSelectionValue(lastConversationSpecName)) {
if (softDefaultSpec && !hasStoredModelSelection()) {
return { softDefault: softDefaultSpec };
}
return;
}
return { last: list?.find((spec) => spec.name === lastConversationSetup.spec) };
return { last: list?.find((spec) => spec.name === lastConversationSpecName) };
}
export function getModelSpecPreset(modelSpec?: t.TModelSpec) {

View file

@ -606,6 +606,8 @@ endpoints:
# - name: "gpt-4o"
# label: "GPT-4 Optimized"
# description: "Most capable GPT-4 model with multimodal support"
# # default: true # Hard admin default; takes precedence over prior user choices
# # softDefault: true # First-time default only; skipped after a user selects a model/spec/agent
# group: "openAI" # String value matching the endpoint name
# preset:
# endpoint: "openAI"

View file

@ -836,6 +836,7 @@ describe('specsConfigSchema', () => {
name: 'spec-1',
label: 'Spec 1',
hideBadgeRow: true,
softDefault: true,
preset: { endpoint: EModelEndpoint.openAI },
},
],
@ -843,6 +844,7 @@ describe('specsConfigSchema', () => {
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.list[0].hideBadgeRow).toBe(true);
expect(result.data.list[0].softDefault).toBe(true);
}
});

View file

@ -14,6 +14,7 @@ export type TModelSpec = {
preset: TModelSpecPreset;
order?: number;
default?: boolean;
softDefault?: boolean;
description?: string;
/**
* Optional group name for organizing specs in the UI selector.
@ -48,6 +49,7 @@ export const tModelSpecSchema = z.object({
preset: tModelSpecPresetSchema,
order: z.number().optional(),
default: z.boolean().optional(),
softDefault: z.boolean().optional(),
description: z.string().optional(),
group: z.string().optional(),
groupIcon: z.union([z.string(), eModelEndpointSchema]).optional(),