From 4b699fb60fed0a30a21f330fb778333878c1f46d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 8 Jun 2026 08:41:27 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=8C=20fix:=20Preserve=20Project=20Scop?= =?UTF-8?q?e=20Through=20Enforced=20Model=20Specs=20(#13586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/middleware/buildEndpointOption.js | 2 +- .../middleware/buildEndpointOption.spec.js | 7 +++++++ e2e/config/librechat.e2e.yaml | 2 ++ e2e/specs/mock/projects.spec.ts | 14 ++++++++++++- e2e/specs/mock/skill-file-authoring.spec.ts | 4 ++-- packages/api/src/modelSpecs/index.ts | 21 ++++++++++++++++++- .../api/src/modelSpecs/modelSpecs.test.ts | 12 ++++++++++- 7 files changed, 56 insertions(+), 6 deletions(-) diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index 35bb36830c..7aeffdfb82 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -78,7 +78,7 @@ async function buildEndpointOption(req, res, next) { try { const result = applyModelSpecPreset({ modelSpec: currentModelSpec, - parsedBody: currentModelSpec.preset, + parsedBody, endpoint, endpointType, defaultParamsEndpoint, diff --git a/api/server/middleware/buildEndpointOption.spec.js b/api/server/middleware/buildEndpointOption.spec.js index 893a14471b..6c1fa1041d 100644 --- a/api/server/middleware/buildEndpointOption.spec.js +++ b/api/server/middleware/buildEndpointOption.spec.js @@ -189,6 +189,9 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => { endpointType: EModelEndpoint.custom, spec: 'claude-opus-4.5', model: 'anthropic/claude-opus-4.5', + temperature: 0.1, + topP: 0.2, + chatProjectId: 'project-1', }, { modelSpecs: { @@ -197,6 +200,7 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => { }, }, ); + req.baseUrl = '/api/agents/chat'; await buildEndpointOption(req, createRes(), jest.fn()); @@ -210,7 +214,10 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => { const enforcedResult = parseCompactConvo.mock.results[1].value; expect(enforcedResult.maxOutputTokens).toBe(8192); expect(enforcedResult.temperature).toBe(0.7); + expect(enforcedResult.topP).toBeUndefined(); expect(enforcedResult.maxContextTokens).toBe(50000); + expect(enforcedResult.chatProjectId).toBe('project-1'); + expect(req.body.endpointOption.chatProjectId).toBe('project-1'); }); it('should restore private model spec preset fields in non-enforced mode', async () => { diff --git a/e2e/config/librechat.e2e.yaml b/e2e/config/librechat.e2e.yaml index 7da6aa4b10..3d42915d7f 100644 --- a/e2e/config/librechat.e2e.yaml +++ b/e2e/config/librechat.e2e.yaml @@ -27,6 +27,8 @@ endpoints: modelDisplayLabel: 'Mock Provider B' modelSpecs: + prioritize: true + enforce: true list: - name: 'e2e-mock-provider-a' label: 'Mock Provider A' diff --git a/e2e/specs/mock/projects.spec.ts b/e2e/specs/mock/projects.spec.ts index 27ea2f9d39..a336a94d3e 100644 --- a/e2e/specs/mock/projects.spec.ts +++ b/e2e/specs/mock/projects.spec.ts @@ -33,7 +33,7 @@ test.describe('chat projects', () => { await expect(page.getByRole('button', { name }).first()).toBeVisible(); }); - test('starts a project-scoped chat and files it under the project', async ({ page }) => { + test('starts a project-scoped chat and persists it under the project', async ({ page }) => { test.setTimeout(120000); const name = uniqueName('E2E Project'); const projectId = await createProject(page, name); @@ -68,6 +68,18 @@ test.describe('chat projects', () => { await expect( page.getByTestId(`project-chats-${projectId}`).getByTestId('convo-item').first(), ).toBeVisible(); + + const conversationUrl = page.url(); + await page.reload({ timeout: 10000 }); + await expect(page).toHaveURL(conversationUrl); + + const reloadedProjectRow = page.getByRole('button', { name }).first(); + if ((await reloadedProjectRow.getAttribute('aria-expanded')) !== 'true') { + await reloadedProjectRow.click(); + } + await expect( + page.getByTestId(`project-chats-${projectId}`).getByTestId('convo-item').first(), + ).toBeVisible(); }); test('removes the project scope via the chip ×', async ({ page }) => { diff --git a/e2e/specs/mock/skill-file-authoring.spec.ts b/e2e/specs/mock/skill-file-authoring.spec.ts index c3b9b20c4d..529a2991bf 100644 --- a/e2e/specs/mock/skill-file-authoring.spec.ts +++ b/e2e/specs/mock/skill-file-authoring.spec.ts @@ -92,7 +92,7 @@ test.describe('skill file authoring tools', () => { expect(response.ok()).toBeTruthy(); await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 }); - const conversationUrl = page.url(); + const conversationPath = new URL(page.url()).pathname; await expect( page.getByTestId('messages-view').getByText(`${FINAL_TEXT}: ${skillName}`), ).toBeVisible({ timeout: 30000 }); @@ -113,7 +113,7 @@ test.describe('skill file authoring tools', () => { expect(editedSkill.body).toContain(EDITED_DESCRIPTION); await page.reload({ timeout: 10000 }); - await expect(page).toHaveURL(conversationUrl); + await expect(page).toHaveURL((url) => url.pathname === conversationPath); await expect( page.getByTestId('messages-view').getByText(`${FINAL_TEXT}: ${skillName}`), ).toBeVisible({ timeout: 30000 }); diff --git a/packages/api/src/modelSpecs/index.ts b/packages/api/src/modelSpecs/index.ts index 6762721967..2ed7b29fdf 100644 --- a/packages/api/src/modelSpecs/index.ts +++ b/packages/api/src/modelSpecs/index.ts @@ -23,6 +23,10 @@ export type PrivateModelSpecPresetField = (typeof PRIVATE_MODEL_SPEC_PRESET_FIEL export type ModelSpecParsedBody = Partial & Record; +export const ENFORCED_MODEL_SPEC_REQUEST_FIELDS = [ + 'chatProjectId', +] as const satisfies readonly (keyof ModelSpecParsedBody)[]; + export type ApplyModelSpecPresetParams = { modelSpec: TModelSpec; parsedBody: ModelSpecParsedBody; @@ -57,15 +61,30 @@ function hasModelSpecValue(field: PrivateModelSpecPresetField, value: unknown): return value.length > 0; } +function pickEnforcedModelSpecRequestFields(parsedBody: ModelSpecParsedBody): ModelSpecParsedBody { + const requestFields: ModelSpecParsedBody = {}; + + for (const field of ENFORCED_MODEL_SPEC_REQUEST_FIELDS) { + if (parsedBody[field] !== undefined) { + requestFields[field] = parsedBody[field]; + } + } + + return requestFields; +} + function mergeModelSpecPreset( modelSpec: TModelSpec, parsedBody: ModelSpecParsedBody, { includePresetDefaults = false }: Pick = {}, ): ApplyModelSpecPresetResult { const preset = modelSpec.preset; + const requestFields = includePresetDefaults + ? pickEnforcedModelSpecRequestFields(parsedBody) + : parsedBody; const merged = { + ...requestFields, ...(includePresetDefaults ? preset : {}), - ...parsedBody, spec: modelSpec.name, } as ModelSpecParsedBody; const appliedPrivateFields = new Set(); diff --git a/packages/api/src/modelSpecs/modelSpecs.test.ts b/packages/api/src/modelSpecs/modelSpecs.test.ts index 8b721bb4be..284dca75a2 100644 --- a/packages/api/src/modelSpecs/modelSpecs.test.ts +++ b/packages/api/src/modelSpecs/modelSpecs.test.ts @@ -92,14 +92,24 @@ describe('modelSpecs helpers', () => { const { parsedBody } = applyModelSpecPreset({ modelSpec, - parsedBody: modelSpec.preset, + parsedBody: { + endpoint: EModelEndpoint.openAI, + spec: 'enforced-openai', + model: 'client-model', + temperature: 0.8, + topP: 0.9, + chatProjectId: 'project-1', + }, endpoint: EModelEndpoint.openAI, includePresetDefaults: true, }); expect(parsedBody.spec).toBe('enforced-openai'); + expect(parsedBody.model).toBe('gpt-4o'); expect(parsedBody.promptPrefix).toBe('private prompt prefix'); expect(parsedBody.temperature).toBe(0.2); + expect(parsedBody.topP).toBeUndefined(); + expect(parsedBody.chatProjectId).toBe('project-1'); }); it('should restore private examples when parser supplies an empty default', () => {