mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🪶 fix: Yield Soft Default Model Spec to Agent Picks in Picker-Only Deployments (#14515)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* 🪶 fix: Yield Soft Default Model Spec to Agent Picks in Picker-Only Deployments The `hasEphemeralModelOptions` gate makes the soft default canonical whenever the selector offers no ephemeral endpoint → model options, so lingering endpoint/model residue never strands a new chat on an unselectable endpoint. That gate swept in agent and assistant selections too: under an agents-only allow-list (`addedEndpoints: [agents]`), every New Chat re-armed the soft spec and discarded the agent the user had just selected, with no way to make the choice stick. An agent pick is the one real selection a picker-only deployment offers, so it now yields like any other selection, while endpoint/model residue keeps falling to the soft default. - Add `hasSelectableEntitySelection`: the stored setup yields when it names a non-ephemeral agent_id (or an assistant_id) on an endpoint the allow-list and endpoints config still expose. Ephemeral ids, and picks whose endpoint has since left the allow-list, stay residue so a stale entity cannot strand a new chat. - Invert the three unit cases that asserted the soft default outranking a stored agent under an agents-only allow-list; add coverage for assistants, prioritized configs, ephemeral agent ids, endpoint/model residue, an endpoints config without agents, and the pre-load allow-list path (35 cases, was 29). - Add an e2e regression test: under an intercepted agents-only allow-list, a selected agent survives New Chat and a cold load, while a cleared instance still lands on the soft default. * 🧹 chore: Type the Intercepted Startup Config in the Soft Default E2E The agents-only allow-list interception cast the `/api/config` response to `{ modelSpecs?: Record<string, unknown> }`, discarding the startup-config schema at the exact point the test rewrites an API response — so a future config shape change would go unchecked here. Reuse `TStartupConfig` instead, and only rewrite `modelSpecs` when the response actually carries it rather than fabricating it.
This commit is contained in:
parent
cc813f430e
commit
91adcf3f2c
3 changed files with 190 additions and 11 deletions
|
|
@ -386,7 +386,7 @@ describe('getDefaultModelSpec', () => {
|
|||
expect(result).toEqual({ softDefault: softSpec });
|
||||
});
|
||||
|
||||
it('applies the soft default despite a stored agent when addedEndpoints only includes agents', () => {
|
||||
it('yields to a stored agent when addedEndpoints only includes agents', () => {
|
||||
persistAgentSelection('agent_abc');
|
||||
|
||||
const result = getDefaultModelSpec(
|
||||
|
|
@ -397,15 +397,91 @@ describe('getDefaultModelSpec', () => {
|
|||
fullEndpointsConfig,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('yields to a stored agent when agents is the only endpoint', () => {
|
||||
persistAgentSelection('agent_abc');
|
||||
|
||||
const result = getDefaultModelSpec(
|
||||
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
|
||||
agentsOnlyEndpointsConfig,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('yields to a stored agent under a prioritized agents-only allow-list', () => {
|
||||
persistAgentSelection('agent_abc');
|
||||
|
||||
const result = getDefaultModelSpec(
|
||||
createStartupConfig([otherSpec, softSpec], {
|
||||
addedEndpoints: [EModelEndpoint.agents],
|
||||
}),
|
||||
agentsOnlyEndpointsConfig,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('yields to a stored assistant when assistants is the only added endpoint', () => {
|
||||
localStorage.setItem(
|
||||
`${LocalStorageKeys.LAST_CONVO_SETUP}_0`,
|
||||
JSON.stringify({
|
||||
endpoint: EModelEndpoint.assistants,
|
||||
assistant_id: 'asst_abc',
|
||||
model: null,
|
||||
spec: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = getDefaultModelSpec(
|
||||
createStartupConfig([otherSpec, softSpec], {
|
||||
prioritize: false,
|
||||
addedEndpoints: [EModelEndpoint.assistants],
|
||||
}),
|
||||
{ [EModelEndpoint.assistants]: { order: 0 } } as TEndpointsConfig,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies the soft default over a stored ephemeral agent id in an agents-only allow-list', () => {
|
||||
persistAgentSelection(Constants.EPHEMERAL_AGENT_ID);
|
||||
|
||||
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', () => {
|
||||
it('applies the soft default over endpoint → model residue in an agents-only allow-list', () => {
|
||||
persistEphemeralSelection('bedrock', 'claude-sonnet-4-6');
|
||||
|
||||
const result = getDefaultModelSpec(
|
||||
createStartupConfig([otherSpec, softSpec], {
|
||||
addedEndpoints: [EModelEndpoint.agents],
|
||||
}),
|
||||
agentsOnlyEndpointsConfig,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ softDefault: softSpec });
|
||||
});
|
||||
|
||||
it('applies the soft default over a stored agent when the endpoints config lacks agents', () => {
|
||||
persistAgentSelection('agent_abc');
|
||||
|
||||
const result = getDefaultModelSpec(
|
||||
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
|
||||
agentsOnlyEndpointsConfig,
|
||||
createStartupConfig([otherSpec, softSpec], {
|
||||
prioritize: false,
|
||||
addedEndpoints: [EModelEndpoint.agents],
|
||||
}),
|
||||
{ [EModelEndpoint.openAI]: { order: 0 } } as TEndpointsConfig,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ softDefault: softSpec });
|
||||
|
|
@ -448,7 +524,7 @@ describe('getDefaultModelSpec', () => {
|
|||
});
|
||||
|
||||
it('detects an agents-only allow-list before the endpoints config loads', () => {
|
||||
persistAgentSelection('agent_abc');
|
||||
persistEphemeralSelection('bedrock', 'claude-sonnet-4-6');
|
||||
|
||||
const result = getDefaultModelSpec(
|
||||
createStartupConfig([otherSpec, softSpec], {
|
||||
|
|
@ -460,5 +536,19 @@ describe('getDefaultModelSpec', () => {
|
|||
|
||||
expect(result).toEqual({ softDefault: softSpec });
|
||||
});
|
||||
|
||||
it('yields to a stored agent in 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).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -212,6 +212,47 @@ function hasEphemeralModelOptions({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the stored setup names a concrete agent/assistant pick the selector
|
||||
* still offers. Picker-only deployments (e.g. `addedEndpoints: [agents]`) have
|
||||
* no ephemeral endpoint → model options, yet an agent selected there is a real
|
||||
* choice the soft default must carry forward; ephemeral agent ids and picks
|
||||
* whose endpoint left the allow-list or endpoints config remain residue.
|
||||
*/
|
||||
function hasSelectableEntitySelection({
|
||||
selection,
|
||||
endpointsConfig,
|
||||
addedEndpoints,
|
||||
modelSelect,
|
||||
}: {
|
||||
selection?: Partial<StoredModelSelection>;
|
||||
endpointsConfig?: t.TEndpointsConfig;
|
||||
addedEndpoints?: Array<EModelEndpoint | string>;
|
||||
modelSelect?: boolean;
|
||||
}): boolean {
|
||||
const endpoint = selection?.endpoint;
|
||||
if (!modelSelect || !endpoint) {
|
||||
return false;
|
||||
}
|
||||
const isAgentPick =
|
||||
isAgentsEndpoint(endpoint) &&
|
||||
hasSelectionValue(selection.agent_id) &&
|
||||
!isEphemeralAgentId(selection.agent_id);
|
||||
const isAssistantPick =
|
||||
isAssistantsEndpoint(endpoint) && hasSelectionValue(selection.assistant_id);
|
||||
if (!isAgentPick && !isAssistantPick) {
|
||||
return false;
|
||||
}
|
||||
const included = new Set(addedEndpoints ?? []);
|
||||
if (included.size > 0 && !included.has(endpoint)) {
|
||||
return false;
|
||||
}
|
||||
if (endpointsConfig == null || Object.keys(endpointsConfig).length === 0) {
|
||||
return true;
|
||||
}
|
||||
return endpointsConfig[endpoint] != null;
|
||||
}
|
||||
|
||||
/** Get the conditional logic for switching conversations */
|
||||
export function getConvoSwitchLogic(params: ConversationInitParams): InitiatedTemplateResult {
|
||||
const { conversation, newEndpoint, endpointsConfig, modularChat = false } = params;
|
||||
|
|
@ -352,9 +393,12 @@ export function applyModelSpecEphemeralAgent({
|
|||
* 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.
|
||||
* also wins whenever the selector offers no ephemeral endpoint → model options — unless
|
||||
* the setup names a concrete agent/assistant the selector still offers, the one real
|
||||
* selection picker-only deployments provide — so lingering endpoint/model residue never
|
||||
* strands a new chat on an unselectable endpoint. 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,
|
||||
|
|
@ -393,12 +437,15 @@ export function getDefaultModelSpec(
|
|||
if (lastSpec?.name === softDefaultSpec.name) {
|
||||
return { softDefault: softDefaultSpec };
|
||||
}
|
||||
const modelSelect = interfaceConfig?.modelSelect;
|
||||
const yieldsToSelection =
|
||||
hasModelSelection(lastSetup) &&
|
||||
hasEphemeralModelOptions({
|
||||
(hasModelSelection(lastSetup) &&
|
||||
hasEphemeralModelOptions({ endpointsConfig, addedEndpoints, modelSelect })) ||
|
||||
hasSelectableEntitySelection({
|
||||
selection: lastSetup,
|
||||
endpointsConfig,
|
||||
addedEndpoints,
|
||||
modelSelect: interfaceConfig?.modelSelect,
|
||||
modelSelect,
|
||||
});
|
||||
return yieldsToSelection ? undefined : { softDefault: softDefaultSpec };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
NEW_CHAT_PATH,
|
||||
|
|
@ -155,6 +156,47 @@ test.describe('soft default model spec', () => {
|
|||
await expect(modelTrigger(page)).not.toHaveText('Select a model');
|
||||
});
|
||||
|
||||
// Regression: agents-only deployment (`addedEndpoints: [agents]`) — the selector
|
||||
// offers specs and agent picks only, so `hasEphemeralModelOptions` is false and the
|
||||
// soft default used to re-arm on every New Chat, discarding the user's agent. A
|
||||
// concrete agent pick is the one real selection such deployments provide and must
|
||||
// survive New Chat and a cold load; the soft default must still land fresh
|
||||
// instances. The allow-list is narrowed via `/api/config` interception because the
|
||||
// gate resolves entirely client-side from the startup config.
|
||||
test('a selected agent survives New Chat in an agents-only allow-list', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await page.route('**/api/config', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const config = (await response.json()) as TStartupConfig;
|
||||
if (config.modelSpecs) {
|
||||
config.modelSpecs = { ...config.modelSpecs, addedEndpoints: ['agents'] };
|
||||
}
|
||||
await route.fulfill({ response, json: config });
|
||||
});
|
||||
|
||||
await startFresh(page);
|
||||
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
|
||||
|
||||
const agentName = uniqueName('E2E Agents Only');
|
||||
await createAgent(page, agentName);
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
|
||||
await selectAgent(page, agentName);
|
||||
await sendAndAwaitReply(page, 'agents-only agent conversation');
|
||||
|
||||
await newChat(page);
|
||||
await expect(modelTrigger(page)).toContainText(agentName, { timeout: 15000 });
|
||||
|
||||
// Cold load (not the SPA transition): ChatRoute resolves purely from getDefaultModelSpec.
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
await expect(modelTrigger(page)).toContainText(agentName, { timeout: 15000 });
|
||||
|
||||
// The soft default still owns the fresh-instance landing under this allow-list.
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
|
||||
});
|
||||
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue