mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🪃 fix: Restore Raw Spec Fallback for Enforced Presets (#13804)
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
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
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
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* fix: rebuild enforced specs from preset * test: Add enforced model spec e2e coverage * test: Align enforced spec regression scope
This commit is contained in:
parent
fdc7e64bb7
commit
6055ad0af2
5 changed files with 225 additions and 3 deletions
|
|
@ -60,7 +60,14 @@ async function buildEndpointOption(req, res, next) {
|
|||
if (appConfig.modelSpecs?.list?.length && appConfig.modelSpecs?.enforce) {
|
||||
/** @type {{ list: TModelSpec[] }}*/
|
||||
const { list } = appConfig.modelSpecs;
|
||||
const { spec } = parsedBody;
|
||||
const rawSpec = req.body.spec;
|
||||
const spec = parsedBody.spec ?? (typeof rawSpec === 'string' ? rawSpec : undefined);
|
||||
const rawChatProjectId = req.body.chatProjectId;
|
||||
const parsedBodyForModelSpec =
|
||||
parsedBody.chatProjectId === undefined &&
|
||||
(typeof rawChatProjectId === 'string' || rawChatProjectId === null)
|
||||
? { ...parsedBody, chatProjectId: rawChatProjectId }
|
||||
: parsedBody;
|
||||
|
||||
if (!spec) {
|
||||
return handleError(res, { text: 'No model spec selected' });
|
||||
|
|
@ -78,7 +85,7 @@ async function buildEndpointOption(req, res, next) {
|
|||
try {
|
||||
const result = applyModelSpecPreset({
|
||||
modelSpec: currentModelSpec,
|
||||
parsedBody,
|
||||
parsedBody: parsedBodyForModelSpec,
|
||||
endpoint,
|
||||
endpointType,
|
||||
defaultParamsEndpoint,
|
||||
|
|
|
|||
|
|
@ -220,6 +220,46 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => {
|
|||
expect(req.body.endpointOption.chatProjectId).toBe('project-1');
|
||||
});
|
||||
|
||||
it('should rebuild enforced custom specs from the backend preset when compact parsing drops raw fields', async () => {
|
||||
mockGetEndpointsConfig.mockResolvedValue({});
|
||||
|
||||
const modelSpec = {
|
||||
name: 'approved-custom',
|
||||
preset: {
|
||||
endpoint: 'Mock Provider A',
|
||||
endpointType: EModelEndpoint.custom,
|
||||
model: 'mock-model-a',
|
||||
promptPrefix: 'Use the approved custom model spec.',
|
||||
},
|
||||
};
|
||||
|
||||
const req = createReq(
|
||||
{
|
||||
endpoint: 'Mock Provider A',
|
||||
endpointType: EModelEndpoint.custom,
|
||||
spec: 'approved-custom',
|
||||
model: { stale: 'cached-client-value' },
|
||||
agent_id: 'agent_from_cached_client_state',
|
||||
chatProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
modelSpecs: {
|
||||
enforce: true,
|
||||
list: [modelSpec],
|
||||
},
|
||||
},
|
||||
);
|
||||
req.baseUrl = '/api/agents/chat';
|
||||
|
||||
await buildEndpointOption(req, createRes(), jest.fn());
|
||||
|
||||
expect(parseCompactConvo.mock.results[0].value).toEqual({});
|
||||
expect(req.body.endpointOption.spec).toBe('approved-custom');
|
||||
expect(req.body.endpointOption.model).toBe('mock-model-a');
|
||||
expect(req.body.endpointOption.promptPrefix).toBe('Use the approved custom model spec.');
|
||||
expect(req.body.endpointOption.chatProjectId).toBe('project-1');
|
||||
});
|
||||
|
||||
it('should restore private model spec preset fields in non-enforced mode', async () => {
|
||||
mockGetEndpointsConfig.mockResolvedValue({});
|
||||
|
||||
|
|
|
|||
|
|
@ -48,8 +48,12 @@ const preservedCredentialEnvKeys = new Set([
|
|||
*/
|
||||
function writeRuntimeMockConfig() {
|
||||
const template = fs.readFileSync(configTemplatePath, 'utf8');
|
||||
const config =
|
||||
process.env.E2E_MODEL_SPECS_ENFORCE === 'true'
|
||||
? template.replace('\n enforce: false\n', '\n enforce: true\n')
|
||||
: template;
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, template);
|
||||
fs.writeFileSync(configPath, config);
|
||||
}
|
||||
|
||||
function neutralizeCredentialEnv(env: NodeJS.ProcessEnv, keep: Set<string>) {
|
||||
|
|
|
|||
170
e2e/specs/mock/enforced-model-specs.spec.ts
Normal file
170
e2e/specs/mock/enforced-model-specs.spec.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
MOCK_ENDPOINTS,
|
||||
NEW_CHAT_PATH,
|
||||
getAccessToken,
|
||||
messagesView,
|
||||
replyPrompt,
|
||||
replyText,
|
||||
requestJson,
|
||||
selectModelSpec,
|
||||
sendMessage,
|
||||
} from './helpers';
|
||||
|
||||
const NO_PARENT = '00000000-0000-0000-0000-000000000000';
|
||||
const ENFORCED_SPEC_NAME = 'e2e-mock-provider-a';
|
||||
const ENFORCED_SPEC_LABEL = 'Mock Provider A';
|
||||
|
||||
type AgentStartResponse = {
|
||||
conversationId: string;
|
||||
streamId: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
async function createProject(page: Page, name: string): Promise<string> {
|
||||
await page.goto('/projects', { timeout: 10000 });
|
||||
await page.getByRole('button', { name: 'New project' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('textbox', { name: 'Project name' }).fill(name);
|
||||
await dialog.getByRole('button', { name: 'Create project' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name })).toBeVisible();
|
||||
const projectId = new URL(page.url()).pathname.split('/projects/')[1];
|
||||
expect(projectId).toBeTruthy();
|
||||
return projectId;
|
||||
}
|
||||
|
||||
const uniqueName = (prefix: string) => `${prefix} ${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||
|
||||
async function waitForStreamReply(page: Page, token: string, streamId: string, expected: string) {
|
||||
await page.evaluate(
|
||||
async ({ accessToken, expectedText, currentStreamId }) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 60000);
|
||||
let buffered = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/agents/chat/stream/${encodeURIComponent(currentStreamId)}?resume=true`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Expected stream to return 2xx, got ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffered += decoder.decode(value, { stream: true });
|
||||
if (buffered.includes(expectedText)) {
|
||||
await reader.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (buffered.includes('event: error')) {
|
||||
await reader.cancel();
|
||||
throw new Error(`Stream emitted an error before ${expectedText}:\n${buffered}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${expectedText}. Latest stream:\n${buffered}`);
|
||||
},
|
||||
{ accessToken: token, currentStreamId: streamId, expectedText: expected },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('enforced model specs', () => {
|
||||
test.skip(
|
||||
process.env.E2E_MODEL_SPECS_ENFORCE !== 'true',
|
||||
'requires E2E_MODEL_SPECS_ENFORCE=true',
|
||||
);
|
||||
|
||||
test('rebuilds a valid enforced spec from the backend preset when the request is stale', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const label = uniqueName('reported-spec').replace(/\s+/g, '-');
|
||||
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
const token = await getAccessToken(page);
|
||||
const userMessageId = randomUUID();
|
||||
const start = await requestJson<AgentStartResponse>(page, {
|
||||
path: `/api/agents/chat/${encodeURIComponent(MOCK_ENDPOINTS[0].label)}`,
|
||||
token,
|
||||
method: 'POST',
|
||||
body: {
|
||||
text: replyPrompt(label),
|
||||
sender: 'User',
|
||||
clientTimestamp: new Date().toLocaleString('sv').replace(' ', 'T'),
|
||||
isCreatedByUser: true,
|
||||
parentMessageId: NO_PARENT,
|
||||
conversationId: 'new',
|
||||
messageId: userMessageId,
|
||||
responseMessageId: `${userMessageId}_`,
|
||||
endpoint: MOCK_ENDPOINTS[0].label,
|
||||
endpointType: 'custom',
|
||||
model: { stale: 'cached-client-value' },
|
||||
agent_id: 'agent_from_cached_client_state',
|
||||
spec: ENFORCED_SPEC_NAME,
|
||||
isTemporary: false,
|
||||
isRegenerate: false,
|
||||
error: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(start.status).toBe('started');
|
||||
expect(start.conversationId).toBeTruthy();
|
||||
|
||||
await waitForStreamReply(page, token, start.streamId, replyText(label));
|
||||
});
|
||||
|
||||
test('keeps a project-scoped chat attached when sending with an enforced spec', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const name = uniqueName('E2E Enforced Project');
|
||||
const projectId = await createProject(page, name);
|
||||
const label = uniqueName('project-spec').replace(/\s+/g, '-');
|
||||
|
||||
await page.goto(`/c/new?projectId=${projectId}`, { timeout: 10000 });
|
||||
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Message input' })).toHaveAttribute(
|
||||
'placeholder',
|
||||
new RegExp(name),
|
||||
);
|
||||
|
||||
await selectModelSpec(page, ENFORCED_SPEC_LABEL);
|
||||
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
|
||||
|
||||
const response = await sendMessage(page, replyPrompt(label));
|
||||
expect(response.ok()).toBeTruthy();
|
||||
await expect(messagesView(page).getByText(replyText(label))).toBeVisible({ timeout: 30000 });
|
||||
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
|
||||
|
||||
const projectRow = page.getByRole('button', { name }).first();
|
||||
if ((await projectRow.getAttribute('aria-expanded')) !== 'true') {
|
||||
await projectRow.click();
|
||||
}
|
||||
await expect(
|
||||
page.getByTestId(`project-chats-${projectId}`).getByTestId('convo-item').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -59,6 +59,7 @@
|
|||
"e2e:a11y": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.a11y.ts --headed",
|
||||
"e2e:ci": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.ts",
|
||||
"e2e:mock": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.mock.ts",
|
||||
"e2e:mock:enforce": "npm run e2e:prepare && cross-env E2E_MODEL_SPECS_ENFORCE=true playwright test --config=e2e/playwright.config.mock.ts enforced-model-specs.spec.ts",
|
||||
"e2e:mock:ci": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.mock.ts",
|
||||
"e2e:debug": "npm run e2e:prepare && cross-env PWDEBUG=1 playwright test --config=e2e/playwright.config.local.ts",
|
||||
"e2e:record": "npm run e2e:prepare && cross-env E2E_BASE_URL=http://localhost:3333 node e2e/setup/record.js",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue