🪶 fix: Prevent Soft Default Model Spec from Overriding User Selections (#13642)

* 🎯 fix: Soft Default Model Spec Overriding User Selections

* 🎯 fix: Detect Agents-Only Allow-List Before Endpoints Config Loads

* 🎯 fix: Preserve Explicit Soft Default Selections over Older History

* 🎯 fix: Limit Soft Default Residue to Spec-Named State, Disable E2E Enforcement
This commit is contained in:
Danny Avila 2026-06-10 08:52:28 -04:00 committed by GitHub
parent 346ebea2d9
commit da6b74e8eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 532 additions and 24 deletions

View file

@ -321,7 +321,7 @@ const useNewConvo = (index = 0) => {
};
let preset = _preset;
const result = getDefaultModelSpec(startupConfig);
const result = getDefaultModelSpec(startupConfig, endpointsConfig);
const defaultModelSpec = result?.default ?? result?.last ?? result?.softDefault;
const shouldApplyModelSpec =
result?.softDefault != null
@ -383,6 +383,7 @@ const useNewConvo = (index = 0) => {
resetBadges,
startupConfig,
saveBadgesState,
endpointsConfig,
pauseGlobalAudio,
switchToConversation,
applyModelSpecEffects,

View file

@ -166,7 +166,7 @@ export default function ChatRoute() {
}
const getNewConvoPreset = () => {
const result = getDefaultModelSpec(startupConfig);
const result = getDefaultModelSpec(startupConfig, endpointsQuery.data);
const spec = result?.default ?? result?.last ?? result?.softDefault;
const specPreset = spec ? getModelSpecPreset(spec) : undefined;
@ -212,7 +212,7 @@ export default function ChatRoute() {
initialConvoQuery.isError &&
isNotFoundError(initialConvoQuery.error)
) {
const result = getDefaultModelSpec(startupConfig);
const result = getDefaultModelSpec(startupConfig, endpointsQuery.data);
const spec = result?.default ?? result?.last ?? result?.softDefault;
showToast({
message: localize('com_ui_conversation_not_found'),

View file

@ -1,5 +1,5 @@
import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import type { TModelSpec, TStartupConfig } from 'librechat-data-provider';
import type { TModelSpec, TStartupConfig, TEndpointsConfig } from 'librechat-data-provider';
import { getDefaultModelSpec } from '../endpoints';
const createModelSpec = (name: string, overrides: Partial<TModelSpec> = {}): TModelSpec =>
@ -13,17 +13,80 @@ const createModelSpec = (name: string, overrides: Partial<TModelSpec> = {}): TMo
...overrides,
}) as TModelSpec;
const createStartupConfig = (list: TModelSpec[]): TStartupConfig =>
const createStartupConfig = (
list: TModelSpec[],
{
prioritize = true,
modelSelect = true,
addedEndpoints,
}: { prioritize?: boolean; modelSelect?: boolean; addedEndpoints?: string[] } = {},
): TStartupConfig =>
({
interface: {
modelSelect: true,
modelSelect,
},
modelSpecs: {
prioritize: true,
prioritize,
list,
...(addedEndpoints ? { addedEndpoints } : {}),
},
}) as TStartupConfig;
const fullEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.openAI]: { order: 0 },
[EModelEndpoint.agents]: { order: 1 },
};
const agentsOnlyEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.agents]: { order: 0 },
};
const writeLastModel = (endpoint: string, model: string) => {
const stored = JSON.parse(localStorage.getItem(LocalStorageKeys.LAST_MODEL) ?? '{}') as Record<
string,
string
>;
stored[endpoint] = model;
localStorage.setItem(LocalStorageKeys.LAST_MODEL, JSON.stringify(stored));
};
/** Mirrors what the conversation effect persists after a spec preset is applied */
const persistAppliedSpec = (spec: TModelSpec) => {
localStorage.setItem(LocalStorageKeys.LAST_SPEC, spec.name);
writeLastModel(spec.preset.endpoint as string, spec.preset.model as string);
localStorage.setItem(
`${LocalStorageKeys.LAST_CONVO_SETUP}_0`,
JSON.stringify({
endpoint: spec.preset.endpoint,
model: spec.preset.model,
spec: spec.name,
}),
);
};
/** Mirrors what the conversation effect persists after the user selects an agent */
const persistAgentSelection = (agentId: string) => {
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agentId);
localStorage.setItem(
`${LocalStorageKeys.LAST_CONVO_SETUP}_0`,
JSON.stringify({
endpoint: EModelEndpoint.agents,
agent_id: agentId,
model: null,
spec: null,
}),
);
};
/** Mirrors what the conversation effect persists after an ephemeral endpoint → model pick */
const persistEphemeralSelection = (endpoint: string, model: string) => {
writeLastModel(endpoint, model);
localStorage.setItem(
`${LocalStorageKeys.LAST_CONVO_SETUP}_0`,
JSON.stringify({ endpoint, model, spec: null }),
);
};
describe('getDefaultModelSpec', () => {
beforeEach(() => {
localStorage.clear();
@ -55,7 +118,7 @@ describe('getDefaultModelSpec', () => {
JSON.stringify({ [EModelEndpoint.openAI]: 'gpt-4o' }),
);
const result = getDefaultModelSpec(createStartupConfig([softSpec]));
const result = getDefaultModelSpec(createStartupConfig([softSpec]), fullEndpointsConfig);
expect(result).toBeUndefined();
});
@ -64,7 +127,7 @@ describe('getDefaultModelSpec', () => {
const softSpec = createModelSpec('soft-spec', { softDefault: true });
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_123');
const result = getDefaultModelSpec(createStartupConfig([softSpec]));
const result = getDefaultModelSpec(createStartupConfig([softSpec]), fullEndpointsConfig);
expect(result).toBeUndefined();
});
@ -87,4 +150,243 @@ describe('getDefaultModelSpec', () => {
expect(result).toEqual({ default: firstSpec });
});
describe('soft default auto-application residue', () => {
const softSpec = createModelSpec('soft-spec', { softDefault: true });
const otherSpec = createModelSpec('other-spec');
it('stays soft after its own application is persisted (prioritized config)', () => {
persistAppliedSpec(softSpec);
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec]),
fullEndpointsConfig,
);
expect(result).toEqual({ softDefault: softSpec });
});
it('stays soft after its own application is persisted (modelSelect config)', () => {
persistAppliedSpec(softSpec);
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
fullEndpointsConfig,
);
expect(result).toEqual({ softDefault: softSpec });
});
it('yields to an agent selected after the soft default was applied', () => {
persistAppliedSpec(softSpec);
persistAgentSelection('agent_abc');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
fullEndpointsConfig,
);
expect(result).toBeUndefined();
});
it('yields to an agent selection even with the prioritized config', () => {
persistAppliedSpec(softSpec);
persistAgentSelection('agent_abc');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec]),
fullEndpointsConfig,
);
expect(result).toBeUndefined();
});
it('yields to an ephemeral endpoint → model pick after the soft default was applied', () => {
persistAppliedSpec(softSpec);
persistEphemeralSelection(EModelEndpoint.openAI, 'gpt-4o');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
fullEndpointsConfig,
);
expect(result).toBeUndefined();
});
it('yields to a different spec selected after the soft default was applied', () => {
persistAppliedSpec(softSpec);
persistAppliedSpec(otherSpec);
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
fullEndpointsConfig,
);
expect(result).toEqual({ last: otherSpec });
});
it('yields to a stored agent even when it matches the soft default agent spec', () => {
const softAgentSpec = createModelSpec('soft-agent-spec', {
softDefault: true,
preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_soft' },
} as Partial<TModelSpec>);
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_soft');
const result = getDefaultModelSpec(
createStartupConfig([softAgentSpec], { prioritize: false }),
fullEndpointsConfig,
);
expect(result).toBeUndefined();
});
it('yields to a stored model selection matching the soft default preset', () => {
localStorage.setItem(
LocalStorageKeys.LAST_MODEL,
JSON.stringify({ [softSpec.preset.endpoint as string]: softSpec.preset.model }),
);
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
fullEndpointsConfig,
);
expect(result).toBeUndefined();
});
it('yields when a different agent is stored than the soft default agent spec', () => {
const softAgentSpec = createModelSpec('soft-agent-spec', {
softDefault: true,
preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_soft' },
} as Partial<TModelSpec>);
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_other');
const result = getDefaultModelSpec(
createStartupConfig([softAgentSpec], { prioritize: false }),
fullEndpointsConfig,
);
expect(result).toBeUndefined();
});
});
describe('explicit soft default selection', () => {
const softSpec = createModelSpec('soft-spec', { softDefault: true });
const otherSpec = createModelSpec('other-spec');
it('keeps the soft default selected over older model history (prioritized config)', () => {
persistEphemeralSelection(EModelEndpoint.openAI, 'gpt-4o');
persistAppliedSpec(softSpec);
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec]),
fullEndpointsConfig,
);
expect(result).toEqual({ softDefault: softSpec });
});
it('keeps the soft default selected over older model history (modelSelect config)', () => {
persistEphemeralSelection(EModelEndpoint.openAI, 'gpt-4o');
persistAppliedSpec(softSpec);
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
fullEndpointsConfig,
);
expect(result).toEqual({ softDefault: softSpec });
});
});
describe('no ephemeral endpoint → model options (edge case)', () => {
const softSpec = createModelSpec('soft-spec', { softDefault: true });
const otherSpec = createModelSpec('other-spec');
it('applies the soft default despite a stored agent when modelSelect is disabled', () => {
persistAgentSelection('agent_abc');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { modelSelect: false }),
fullEndpointsConfig,
);
expect(result).toEqual({ softDefault: softSpec });
});
it('applies the soft default despite a stored agent when addedEndpoints only includes agents', () => {
persistAgentSelection('agent_abc');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], {
prioritize: false,
addedEndpoints: [EModelEndpoint.agents],
}),
fullEndpointsConfig,
);
expect(result).toEqual({ softDefault: softSpec });
});
it('applies the soft default despite a stored agent when agents is the only endpoint', () => {
persistAgentSelection('agent_abc');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
agentsOnlyEndpointsConfig,
);
expect(result).toEqual({ softDefault: softSpec });
});
it('still defers to the last selected spec', () => {
persistAppliedSpec(otherSpec);
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
agentsOnlyEndpointsConfig,
);
expect(result).toEqual({ last: otherSpec });
});
it('keeps the selection gate when addedEndpoints includes ephemeral endpoints', () => {
persistAgentSelection('agent_abc');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], {
prioritize: false,
addedEndpoints: [EModelEndpoint.agents, EModelEndpoint.openAI],
}),
fullEndpointsConfig,
);
expect(result).toBeUndefined();
});
it('keeps the selection gate while the endpoints config has not loaded', () => {
persistAgentSelection('agent_abc');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
undefined,
);
expect(result).toBeUndefined();
});
it('detects an agents-only allow-list before the endpoints config loads', () => {
persistAgentSelection('agent_abc');
const result = getDefaultModelSpec(
createStartupConfig([otherSpec, softSpec], {
prioritize: false,
addedEndpoints: [EModelEndpoint.agents],
}),
undefined,
);
expect(result).toEqual({ softDefault: softSpec });
});
});
});

View file

@ -203,8 +203,14 @@ export function hasModelSelection(selection?: Partial<StoredModelSelection> | nu
);
}
function hasStoredModelSelection(): boolean {
if (hasSelectionValue(localStorage.getItem(LocalStorageKeys.LAST_SPEC))) {
/**
* Whether localStorage holds a model selection the user actually made.
* Only spec entries naming `softDefaultSpec` are residue of the soft default
* itself selections that merely match its preset still count as user choices.
*/
function hasStoredModelSelection(softDefaultSpec?: t.TModelSpec): boolean {
const lastSpecName = localStorage.getItem(LocalStorageKeys.LAST_SPEC);
if (hasSelectionValue(lastSpecName) && lastSpecName !== softDefaultSpec?.name) {
return true;
}
@ -222,10 +228,51 @@ function hasStoredModelSelection(): boolean {
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
* agents/assistants picks remain; an empty endpoints config means not loaded.
*/
function hasEphemeralModelOptions({
endpointsConfig,
addedEndpoints,
modelSelect,
}: {
endpointsConfig?: t.TEndpointsConfig;
addedEndpoints?: Array<EModelEndpoint | string>;
modelSelect?: boolean;
}): boolean {
if (!modelSelect) {
return false;
}
const included = new Set(addedEndpoints ?? []);
const includesEphemeral =
included.size === 0 ||
[...included].some(
(endpoint) => !isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint),
);
if (!includesEphemeral) {
return false;
}
if (endpointsConfig == null || Object.keys(endpointsConfig).length === 0) {
return true;
}
return Object.entries(endpointsConfig).some(
([endpoint, config]) =>
config != null &&
!isAgentsEndpoint(endpoint) &&
!isAssistantsEndpoint(endpoint) &&
(included.size === 0 || included.has(endpoint)),
);
}
/** Get the conditional logic for switching conversations */
export function getConvoSwitchLogic(params: ConversationInitParams): InitiatedTemplateResult {
const { conversation, newEndpoint, endpointsConfig, modularChat = false } = params;
@ -358,10 +405,15 @@ export function applyModelSpecEphemeralAgent({
/**
* Gets default model spec from config and user preferences.
* Priority: hard admin default prior user selection soft first-time default.
* Priority: hard admin default prior user selection soft default.
* The soft default yields only to selections the user actually made, and acts
* as the fallback default when no ephemeral endpoint model options exist.
* Legacy first-spec prioritization remains only when no soft default is configured.
*/
export function getDefaultModelSpec(startupConfig?: t.TStartupConfig):
export function getDefaultModelSpec(
startupConfig?: t.TStartupConfig,
endpointsConfig?: t.TEndpointsConfig,
):
| {
default?: t.TModelSpec;
last?: t.TModelSpec;
@ -369,39 +421,60 @@ export function getDefaultModelSpec(startupConfig?: t.TStartupConfig):
}
| undefined {
const { modelSpecs, interface: interfaceConfig } = startupConfig ?? {};
const { list, prioritize } = modelSpecs ?? {};
const { list, prioritize, addedEndpoints } = modelSpecs ?? {};
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) {
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 hasStoredModelSelection() ? undefined : { softDefault: softDefaultSpec };
return resolveSoftDefault();
}
return { default: list?.[0] };
} else if (defaultSpec) {
return { default: defaultSpec };
}
const lastConversationSetup = parseStoredModelSelection(
localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'),
);
const lastConversationSpecName = lastConversationSetup?.spec;
if (!hasSelectionValue(lastConversationSpecName)) {
if (softDefaultSpec && !hasStoredModelSelection()) {
return { softDefault: softDefaultSpec };
}
return;
return resolveSoftDefault();
}
return { last: list?.find((spec) => spec.name === lastConversationSpecName) };
const lastSpec = list?.find((spec) => spec.name === lastConversationSpecName);
if (lastSpec && lastSpec.name === softDefaultSpec?.name) {
return resolveSoftDefault();
}
return { last: lastSpec };
}
export function getModelSpecPreset(modelSpec?: t.TModelSpec) {

View file

@ -26,9 +26,28 @@ endpoints:
titleConvo: false
modelDisplayLabel: 'Mock Provider B'
# No model spec mirrors this endpoint's label, so it stays unambiguous in the
# selector and gives e2e tests a real ephemeral endpoint → model option.
- name: 'Mock Provider C'
apiKey: 'e2e-mock-key-c'
baseURL: 'http://127.0.0.1:8889/v1'
models:
default:
- 'mock-model-c'
fetch: false
titleConvo: false
modelDisplayLabel: 'Mock Provider C'
modelSpecs:
prioritize: true
enforce: true
# Enforcement would reject sends from the non-spec paths addedEndpoints
# exposes below (buildEndpointOption requires a spec when enforce is true).
enforce: false
# Surfaces the endpoints menu (modelSelect defaults on when addedEndpoints is
# set) limited to entries that don't collide with the spec labels above.
addedEndpoints:
- 'Mock Provider C'
- 'agents'
list:
- name: 'e2e-mock-provider-a'
label: 'Mock Provider A'
@ -58,3 +77,10 @@ modelSpecs:
- 'e2e-model-spec-allowed'
- 'e2e-model-spec-missing'
- 'e2e-model-spec-inaccessible'
- name: 'e2e-soft-default'
label: 'E2E Soft Default'
softDefault: true
preset:
endpoint: 'Mock Provider A'
model: 'mock-model-a'

View file

@ -0,0 +1,106 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { NEW_CHAT_PATH, getAccessToken, requestJson } from './helpers';
/** Label of the `softDefault: true` spec in e2e/config/librechat.e2e.yaml. */
const SOFT_DEFAULT_LABEL = 'E2E Soft Default';
/** Ephemeral endpoint from e2e/config/librechat.e2e.yaml with no mirroring spec. */
const EPHEMERAL_ENDPOINT = { label: 'Mock Provider C', model: 'mock-model-c' };
const uniqueName = (prefix: string) => `${prefix} ${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
const modelTrigger = (page: Page) => page.getByRole('button', { name: 'Select a model' }).first();
/** Reset selection state so the test starts as a fresh instance (auth stays in cookies). */
async function startFresh(page: Page) {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await page.evaluate(() => localStorage.clear());
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
}
type AgentResponse = {
id: string;
name?: string | null;
};
async function createAgent(page: Page, name: string): Promise<AgentResponse> {
const token = await getAccessToken(page);
return requestJson<AgentResponse>(page, {
path: '/api/agents',
token,
method: 'POST',
body: {
name,
provider: 'Mock Provider A',
model: 'mock-model-a',
model_parameters: {},
},
});
}
async function selectAgent(page: Page, agentName: string) {
await modelTrigger(page).click();
await page.getByRole('option', { name: 'My Agents' }).click();
await page.getByRole('option', { name: agentName }).click();
await expect(modelTrigger(page)).toContainText(agentName);
}
async function selectEphemeralModel(page: Page) {
await modelTrigger(page).click();
await page.getByRole('option', { name: EPHEMERAL_ENDPOINT.label }).click();
await page.getByRole('option', { name: EPHEMERAL_ENDPOINT.model, exact: true }).click();
await expect(modelTrigger(page)).toContainText(EPHEMERAL_ENDPOINT.model);
}
test.describe('soft default model spec', () => {
test('applies the soft default on a fresh instance and stays applied across reloads', async ({
page,
}) => {
await startFresh(page);
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
// Its own auto-application must not convert it into a sticky "last" selection
// that would behave differently on the next load.
await page.reload({ timeout: 10000 });
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
});
test('a previously selected agent outranks the soft default', async ({ page }) => {
test.setTimeout(120000);
await startFresh(page);
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
const agentName = uniqueName('E2E Soft Agent');
await createAgent(page, agentName);
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectAgent(page, agentName);
await page.reload({ timeout: 10000 });
await expect(modelTrigger(page)).toContainText(agentName, { timeout: 15000 });
await page.getByTestId('new-chat-button').click();
await expect(page).toHaveURL(/\/c\/new/, { timeout: 15000 });
await expect(modelTrigger(page)).toContainText(agentName, { timeout: 15000 });
});
test('a previous ephemeral endpoint and model selection outranks the soft default', async ({
page,
}) => {
test.setTimeout(120000);
await startFresh(page);
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
await selectEphemeralModel(page);
await page.reload({ timeout: 10000 });
await expect(modelTrigger(page)).toContainText(EPHEMERAL_ENDPOINT.model, { timeout: 15000 });
await expect(modelTrigger(page)).not.toContainText(SOFT_DEFAULT_LABEL);
await page.getByTestId('new-chat-button').click();
await expect(page).toHaveURL(/\/c\/new/, { timeout: 15000 });
await expect(modelTrigger(page)).toContainText(EPHEMERAL_ENDPOINT.model, { timeout: 15000 });
});
});