mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
🪶 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:
parent
346ebea2d9
commit
da6b74e8eb
6 changed files with 532 additions and 24 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
106
e2e/specs/mock/soft-default.spec.ts
Normal file
106
e2e/specs/mock/soft-default.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue