🪶 refactor: Ground Default Model Spec Selection in Conversation Recency (#13915)

Resolve the new-chat default spec from the most recent conversation setup
(LAST_CONVO_SETUP_0) instead of reconstructing intent from accumulated
cross-endpoint history. Removes hasStoredModelValue, hasStoredPrefixValue,
hasStoredModelSelection, the sticky LAST_SPEC read, the nested
resolveSoftDefault closure, and the duplicated prioritize/modelSelect branches.

Fixes the soft default being dropped on New Chat ("Select a model") when its
preset endpoint sits outside modelSpecs.addedEndpoints alongside a custom
endpoint: a model lingering in LAST_MODEL for that endpoint no longer
suppresses the soft default.

Clear All Chats now also clears LAST_SPEC/LAST_MODEL/LAST_TOOLS so a new chat
afterward cleanly returns to the soft default. Adds the cross-endpoint unit
case, a clearAllConversationStorage test, and a cold-load e2e regression test.
This commit is contained in:
Danny Avila 2026-06-23 15:49:04 -04:00 committed by GitHub
parent 33d7b0070c
commit f14309e087
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 172 additions and 147 deletions

View file

@ -105,7 +105,7 @@ describe('getDefaultModelSpec', () => {
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);
persistAppliedSpec(lastSpec);
const result = getDefaultModelSpec(createStartupConfig([softSpec, lastSpec]));
@ -114,10 +114,7 @@ describe('getDefaultModelSpec', () => {
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' }),
);
persistEphemeralSelection(EModelEndpoint.openAI, 'gpt-4o');
const result = getDefaultModelSpec(createStartupConfig([softSpec]), fullEndpointsConfig);
@ -126,7 +123,7 @@ describe('getDefaultModelSpec', () => {
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');
persistAgentSelection('agent_123');
const result = getDefaultModelSpec(createStartupConfig([softSpec]), fullEndpointsConfig);
@ -136,7 +133,7 @@ describe('getDefaultModelSpec', () => {
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);
persistAppliedSpec(softSpec);
const result = getDefaultModelSpec(createStartupConfig([softSpec, hardSpec]));
@ -231,7 +228,7 @@ describe('getDefaultModelSpec', () => {
softDefault: true,
preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_soft' },
} as Partial<TModelSpec>);
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_soft');
persistAppliedSpec(softAgentSpec);
const result = getDefaultModelSpec(
createStartupConfig([softAgentSpec], { prioritize: false }),
@ -242,10 +239,7 @@ describe('getDefaultModelSpec', () => {
});
it('treats a stored model matching the soft default preset as residue', () => {
localStorage.setItem(
LocalStorageKeys.LAST_MODEL,
JSON.stringify({ [softSpec.preset.endpoint as string]: softSpec.preset.model }),
);
persistAppliedSpec(softSpec);
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
@ -266,7 +260,7 @@ describe('getDefaultModelSpec', () => {
expect(result).toEqual({ softDefault: softSpec });
});
it('does not re-arm from viewing an old soft conversation after an ephemeral pick', () => {
it('re-arms after viewing the soft conversation, even when an ephemeral pick lingers', () => {
persistEphemeralSelection(EModelEndpoint.anthropic, 'claude-sonnet-4-6');
persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d');
@ -275,10 +269,10 @@ describe('getDefaultModelSpec', () => {
fullEndpointsConfig,
);
expect(result).toBeUndefined();
expect(result).toEqual({ softDefault: softSpec });
});
it('does not re-arm from viewing an old soft conversation after an agent pick', () => {
it('re-arms after viewing the soft conversation, even when an agent pick lingers', () => {
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_abc');
persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d');
@ -287,7 +281,7 @@ describe('getDefaultModelSpec', () => {
fullEndpointsConfig,
);
expect(result).toBeUndefined();
expect(result).toEqual({ softDefault: softSpec });
});
it('yields when a different agent is stored than the soft default agent spec', () => {
@ -295,7 +289,7 @@ describe('getDefaultModelSpec', () => {
softDefault: true,
preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_soft' },
} as Partial<TModelSpec>);
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_other');
persistAgentSelection('agent_other');
const result = getDefaultModelSpec(
createStartupConfig([softAgentSpec], { prioritize: false }),
@ -306,6 +300,48 @@ describe('getDefaultModelSpec', () => {
});
});
describe('soft default endpoint outside an added-endpoints allow-list', () => {
// Mirrors a softDefault spec on `bedrock` with `addedEndpoints: [agents, <custom>]`.
// The custom endpoint turns on the ephemeral-options gate, and a model used on
// that endpoint lingers under a different key than the spec preset — which used
// to suppress the soft default and strand New Chat on the unselectable bedrock.
const softSpec = createModelSpec('clickhouse-agent', {
softDefault: true,
preset: { endpoint: 'bedrock', model: 'claude-sonnet-4-6' },
} as Partial<TModelSpec>);
const otherSpec = createModelSpec('other-spec');
const allowListConfig = { addedEndpoints: ['agents', 'ClickHouse'] };
const allowListEndpoints = {
bedrock: { order: 0 },
ClickHouse: { order: 1 },
[EModelEndpoint.agents]: { order: 2 },
} as TEndpointsConfig;
it('re-arms on New Chat after viewing the soft conversation last', () => {
writeLastModel('ClickHouse', 'kimi-k2p7-code');
persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], allowListConfig),
allowListEndpoints,
);
expect(result).toEqual({ softDefault: softSpec });
});
it('still yields when a selectable endpoint was the last conversation', () => {
persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d');
persistEphemeralSelection('ClickHouse', 'kimi-k2p7-code');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], allowListConfig),
allowListEndpoints,
);
expect(result).toBeUndefined();
});
});
describe('explicit soft default selection', () => {
const softSpec = createModelSpec('soft-spec', { softDefault: true });
const otherSpec = createModelSpec('other-spec');

View file

@ -0,0 +1,29 @@
import { LocalStorageKeys } from 'librechat-data-provider';
import { clearAllConversationStorage } from '../localStorage';
describe('clearAllConversationStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('wipes the selection and conversation state but keeps unrelated keys', () => {
localStorage.setItem(LocalStorageKeys.LAST_SPEC, 'some-spec');
localStorage.setItem(LocalStorageKeys.LAST_MODEL, JSON.stringify({ openAI: 'gpt-4o' }));
localStorage.setItem(LocalStorageKeys.LAST_TOOLS, JSON.stringify(['web_search']));
localStorage.setItem(
`${LocalStorageKeys.LAST_CONVO_SETUP}_0`,
JSON.stringify({ spec: 'some-spec' }),
);
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_1');
localStorage.setItem('unrelated-key', 'keep-me');
clearAllConversationStorage();
expect(localStorage.getItem(LocalStorageKeys.LAST_SPEC)).toBeNull();
expect(localStorage.getItem(LocalStorageKeys.LAST_MODEL)).toBeNull();
expect(localStorage.getItem(LocalStorageKeys.LAST_TOOLS)).toBeNull();
expect(localStorage.getItem(`${LocalStorageKeys.LAST_CONVO_SETUP}_0`)).toBeNull();
expect(localStorage.getItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`)).toBeNull();
expect(localStorage.getItem('unrelated-key')).toBe('keep-me');
});
});

View file

@ -139,7 +139,7 @@ interface InitiatedTemplateResult {
type StoredModelSelection = Pick<
t.TConversation,
'endpoint' | 'model' | 'spec' | 'agent_id' | 'assistant_id' | 'conversationId'
'endpoint' | 'model' | 'spec' | 'agent_id' | 'assistant_id'
>;
function hasSelectionValue(value?: string | null): boolean {
@ -160,40 +160,6 @@ function parseStoredModelSelection(
}
}
function hasStoredPrefixValue(prefix: string, ignoreValue?: string | null): boolean {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key?.startsWith(prefix)) {
continue;
}
const value = localStorage.getItem(key);
if (hasSelectionValue(value) && value !== ignoreValue) {
return true;
}
}
return false;
}
function hasStoredModelValue(softPreset?: t.TModelSpecPreset): 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.entries(storedModels).some(
([endpoint, model]) =>
hasSelectionValue(model) &&
!(endpoint === softPreset?.endpoint && model === softPreset?.model),
);
} catch {
return false;
}
}
export function hasModelSelection(selection?: Partial<StoredModelSelection> | null): boolean {
if (!selection) {
return false;
@ -208,39 +174,6 @@ export function hasModelSelection(selection?: Partial<StoredModelSelection> | nu
);
}
/**
* Whether localStorage holds a model selection the user actually made.
* State matching what applying `softDefaultSpec` writes is residue of the
* soft default itself, not evidence of a user choice, and is ignored.
*/
function hasStoredModelSelection(softDefaultSpec?: t.TModelSpec): boolean {
const softPreset = softDefaultSpec?.preset;
const lastSpecName = localStorage.getItem(LocalStorageKeys.LAST_SPEC);
if (hasSelectionValue(lastSpecName) && lastSpecName !== softDefaultSpec?.name) {
return true;
}
if (hasStoredModelValue(softPreset)) {
return true;
}
if (
hasStoredPrefixValue(LocalStorageKeys.AGENT_ID_PREFIX, softPreset?.agent_id) ||
hasStoredPrefixValue(LocalStorageKeys.ASST_ID_PREFIX, softPreset?.assistant_id)
) {
return true;
}
const lastConversationSetup = parseStoredModelSelection(
localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'),
);
if (softDefaultSpec && lastConversationSetup?.spec === softDefaultSpec.name) {
return false;
}
return hasModelSelection(lastConversationSetup);
}
/**
* Whether the selector offers any ephemeral endpoint model options.
* False when the endpoints menu is hidden (`modelSelect` disabled) or only
@ -410,13 +343,16 @@ export function applyModelSpecEphemeralAgent({
}
/**
* Gets default model spec from config and user preferences.
* Priority: hard admin default prior user selection soft default.
* The soft default yields only to selections the user actually made its
* stored state counts as the live selection only when set on a new chat, so
* merely viewing an old soft-spec conversation never re-arms it and it acts
* as the fallback default when no ephemeral endpoint model options exist.
* Legacy first-spec prioritization remains only when no soft default is configured.
* Resolves the default model spec for a new chat. Priority: hard admin default
* the most recent conversation's own selection soft default legacy first spec.
*
* `LAST_CONVO_SETUP_0` is the single source of truth for prior intent: a setup naming
* the soft spec is the soft default re-arming, any other spec/agent/endpoint is a
* selection to carry forward, and an empty setup is a fresh start (clearing chats
* wipes the selection, so a new chat then falls to the soft default). The soft default
* also wins whenever the selector offers no ephemeral endpoint model options, so a
* stale agent never strands it. The legacy first-spec fallback applies only when specs
* are prioritized (or the model menu is hidden) and no soft default is configured.
*/
export function getDefaultModelSpec(
startupConfig?: t.TStartupConfig,
@ -433,59 +369,42 @@ export function getDefaultModelSpec(
if (!list) {
return;
}
const defaultSpec = list?.find((spec) => spec.default);
const softDefaultSpec = list?.find((spec) => spec.softDefault);
const lastConversationSetup = parseStoredModelSelection(
localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'),
);
const resolveSoftDefault = (): { softDefault: t.TModelSpec } | undefined => {
if (!softDefaultSpec) {
return;
}
if (
lastConversationSetup?.spec === softDefaultSpec.name &&
lastConversationSetup?.conversationId === Constants.NEW_CONVO
) {
return { softDefault: softDefaultSpec };
}
const ephemeralOptions = hasEphemeralModelOptions({
endpointsConfig,
addedEndpoints,
modelSelect: interfaceConfig?.modelSelect,
});
if (!ephemeralOptions) {
return { softDefault: softDefaultSpec };
}
return hasStoredModelSelection(softDefaultSpec) ? undefined : { softDefault: softDefaultSpec };
};
if (prioritize === true || !interfaceConfig?.modelSelect) {
const lastSelectedSpecName = localStorage.getItem(LocalStorageKeys.LAST_SPEC);
const lastSelectedSpec = list?.find((spec) => spec.name === lastSelectedSpecName);
if (defaultSpec) {
return { default: defaultSpec };
}
if (lastSelectedSpec && lastSelectedSpec.name === softDefaultSpec?.name) {
return resolveSoftDefault();
}
if (lastSelectedSpec) {
return { last: lastSelectedSpec };
}
if (softDefaultSpec) {
return resolveSoftDefault();
}
return { default: list?.[0] };
} else if (defaultSpec) {
const defaultSpec = list.find((spec) => spec.default);
if (defaultSpec) {
return { default: defaultSpec };
}
const lastConversationSpecName = lastConversationSetup?.spec;
if (!hasSelectionValue(lastConversationSpecName)) {
return resolveSoftDefault();
const softDefaultSpec = list.find((spec) => spec.softDefault);
const lastSetup = parseStoredModelSelection(
localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'),
);
const lastSpec = hasSelectionValue(lastSetup?.spec)
? list.find((spec) => spec.name === lastSetup?.spec)
: undefined;
if (lastSpec && lastSpec.name !== softDefaultSpec?.name) {
return { last: lastSpec };
}
const lastSpec = list?.find((spec) => spec.name === lastConversationSpecName);
if (lastSpec && lastSpec.name === softDefaultSpec?.name) {
return resolveSoftDefault();
if (softDefaultSpec) {
if (lastSpec?.name === softDefaultSpec.name) {
return { softDefault: softDefaultSpec };
}
const yieldsToSelection =
hasModelSelection(lastSetup) &&
hasEphemeralModelOptions({
endpointsConfig,
addedEndpoints,
modelSelect: interfaceConfig?.modelSelect,
});
return yieldsToSelection ? undefined : { softDefault: softDefaultSpec };
}
return { last: lastSpec };
if (prioritize === true || interfaceConfig?.modelSelect !== true) {
return { default: list[0] };
}
return;
}
export function getModelSpecPreset(modelSpec?: t.TModelSpec) {

View file

@ -72,7 +72,10 @@ export function clearAllConversationStorage() {
key.startsWith(LocalStorageKeys.TEXT_DRAFT) ||
key.startsWith(LocalStorageKeys.ASST_ID_PREFIX) ||
key.startsWith(LocalStorageKeys.AGENT_ID_PREFIX) ||
key.startsWith(LocalStorageKeys.LAST_CONVO_SETUP)
key.startsWith(LocalStorageKeys.LAST_CONVO_SETUP) ||
key === LocalStorageKeys.LAST_SPEC ||
key === LocalStorageKeys.LAST_MODEL ||
key === LocalStorageKeys.LAST_TOOLS
) {
localStorage.removeItem(key);
}

View file

@ -1,6 +1,13 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { NEW_CHAT_PATH, getAccessToken, mockReply, requestJson, sendMessage } from './helpers';
import {
NEW_CHAT_PATH,
getAccessToken,
mockReply,
requestJson,
selectModelSpec,
sendMessage,
} from './helpers';
/** Label of the `softDefault: true` spec in e2e/config/librechat.e2e.yaml. */
const SOFT_DEFAULT_LABEL = 'E2E Soft Default';
@ -127,9 +134,7 @@ test.describe('soft default model spec', () => {
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
});
test('viewing an old soft conversation does not re-arm it over a prior selection', async ({
page,
}) => {
test('viewing the soft conversation re-arms it on the next New Chat', async ({ page }) => {
test.setTimeout(120000);
await startFresh(page);
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
@ -143,7 +148,40 @@ test.describe('soft default model spec', () => {
await page.goto(softConvoUrl, { timeout: 10000 });
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
// Fresh load (not the in-memory SPA transition, which masks the regression): the
// cold ChatRoute path resolves the New Chat purely from getDefaultModelSpec.
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
await expect(modelTrigger(page)).not.toHaveText('Select a model');
});
// Regression: softDefault spec on an endpoint kept out of `addedEndpoints` (e.g. a
// bedrock spec with `addedEndpoints: [agents, <custom>]`). Using the custom endpoint
// leaves a model in history under a key the spec preset never matches, which used to
// suppress the soft default and strand a freshly loaded New Chat on the unselectable
// endpoint ("Select a model"). The spec must re-arm when it was the conversation used
// last. A cold load is used because the SPA New Chat transition resolves non-
// deterministically and can mask the dropped spec.
test('re-arms on a fresh New Chat when the spec endpoint is outside the allow-list', async ({
page,
}) => {
test.setTimeout(120000);
await startFresh(page);
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
await selectEphemeralModel(page);
await sendAndAwaitReply(page, 'history on a different endpoint');
await newChat(page);
await expect(modelTrigger(page)).not.toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
await selectModelSpec(page, SOFT_DEFAULT_LABEL);
await sendAndAwaitReply(page, 'soft spec used last');
const specConvoUrl = page.url();
await page.goto(specConvoUrl, { timeout: 10000 });
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
await expect(modelTrigger(page)).not.toHaveText('Select a model');
});
});