mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🗝️ fix: Protect Model Spec Instructions (#13125)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* fix: prevent instruction exposure * fix: tighten model spec preset restoration * refactor: type model spec preset handling
This commit is contained in:
parent
b993d9fb28
commit
ca8c212c0d
11 changed files with 668 additions and 21 deletions
|
|
@ -1,4 +1,10 @@
|
|||
const { handleError } = require('@librechat/api');
|
||||
const {
|
||||
handleError,
|
||||
applyModelSpecPreset,
|
||||
findModelSpecByName,
|
||||
isModelSpecEndpointMatch,
|
||||
resolveModelSpecPromptPrefixVariables,
|
||||
} = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
EndpointURLs,
|
||||
|
|
@ -21,6 +27,8 @@ const buildFunction = {
|
|||
|
||||
async function buildEndpointOption(req, res, next) {
|
||||
const { endpoint, endpointType } = req.body;
|
||||
const isAgents =
|
||||
isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]);
|
||||
|
||||
let endpointsConfig;
|
||||
try {
|
||||
|
|
@ -48,6 +56,7 @@ async function buildEndpointOption(req, res, next) {
|
|||
}
|
||||
|
||||
const appConfig = req.config;
|
||||
let appliedModelSpecPrivateFields = new Set();
|
||||
if (appConfig.modelSpecs?.list?.length && appConfig.modelSpecs?.enforce) {
|
||||
/** @type {{ list: TModelSpec[] }}*/
|
||||
const { list } = appConfig.modelSpecs;
|
||||
|
|
@ -57,41 +66,63 @@ async function buildEndpointOption(req, res, next) {
|
|||
return handleError(res, { text: 'No model spec selected' });
|
||||
}
|
||||
|
||||
const currentModelSpec = list.find((s) => s.name === spec);
|
||||
const currentModelSpec = findModelSpecByName({ list }, spec);
|
||||
if (!currentModelSpec) {
|
||||
return handleError(res, { text: 'Invalid model spec' });
|
||||
}
|
||||
|
||||
if (endpoint !== currentModelSpec.preset.endpoint) {
|
||||
if (!isModelSpecEndpointMatch(currentModelSpec, endpoint)) {
|
||||
return handleError(res, { text: 'Model spec mismatch' });
|
||||
}
|
||||
|
||||
try {
|
||||
currentModelSpec.preset.spec = spec;
|
||||
parsedBody = parseCompactConvo({
|
||||
const result = applyModelSpecPreset({
|
||||
modelSpec: currentModelSpec,
|
||||
parsedBody: currentModelSpec.preset,
|
||||
endpoint,
|
||||
endpointType,
|
||||
conversation: currentModelSpec.preset,
|
||||
defaultParamsEndpoint,
|
||||
includePresetDefaults: true,
|
||||
});
|
||||
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
|
||||
parsedBody.iconURL = currentModelSpec.iconURL;
|
||||
}
|
||||
parsedBody = result.parsedBody;
|
||||
appliedModelSpecPrivateFields = result.appliedPrivateFields;
|
||||
} catch (error) {
|
||||
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
|
||||
return handleError(res, { text: 'Error parsing model spec' });
|
||||
}
|
||||
} else if (parsedBody.spec && appConfig.modelSpecs?.list) {
|
||||
// Non-enforced mode: if spec is selected, derive iconURL from model spec
|
||||
const modelSpec = appConfig.modelSpecs.list.find((s) => s.name === parsedBody.spec);
|
||||
if (modelSpec?.iconURL) {
|
||||
parsedBody.iconURL = modelSpec.iconURL;
|
||||
const modelSpec = findModelSpecByName(appConfig.modelSpecs, parsedBody.spec);
|
||||
if (modelSpec) {
|
||||
if (!isModelSpecEndpointMatch(modelSpec, endpoint)) {
|
||||
return handleError(res, { text: 'Model spec mismatch' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = applyModelSpecPreset({
|
||||
modelSpec,
|
||||
parsedBody,
|
||||
endpoint,
|
||||
endpointType,
|
||||
defaultParamsEndpoint,
|
||||
});
|
||||
parsedBody = result.parsedBody;
|
||||
appliedModelSpecPrivateFields = result.appliedPrivateFields;
|
||||
} catch (error) {
|
||||
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
|
||||
return handleError(res, { text: 'Error parsing model spec' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAgents && appliedModelSpecPrivateFields.has('promptPrefix')) {
|
||||
parsedBody = resolveModelSpecPromptPrefixVariables(
|
||||
parsedBody,
|
||||
req.user,
|
||||
req.body.clientTimestamp,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const isAgents =
|
||||
isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]);
|
||||
const builder = isAgents
|
||||
? (...args) => buildFunction[EModelEndpoint.agents](req, ...args)
|
||||
: buildFunction[endpointType ?? endpoint];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ const mockBuildOptions = jest.fn((_endpoint, parsedBody) => ({
|
|||
...parsedBody,
|
||||
endpoint: _endpoint,
|
||||
}));
|
||||
const mockAgentBuildOptions = jest.fn((_req, endpoint, parsedBody) => ({
|
||||
...parsedBody,
|
||||
endpoint,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Endpoints/azureAssistants', () => ({
|
||||
buildOptions: mockBuildOptions,
|
||||
|
|
@ -25,7 +29,7 @@ jest.mock('~/server/services/Endpoints/assistants', () => ({
|
|||
buildOptions: mockBuildOptions,
|
||||
}));
|
||||
jest.mock('~/server/services/Endpoints/agents', () => ({
|
||||
buildOptions: mockBuildOptions,
|
||||
buildOptions: mockAgentBuildOptions,
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
|
|
@ -38,6 +42,7 @@ jest.mock('~/server/services/Config', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
handleError: jest.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -207,6 +212,183 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => {
|
|||
expect(enforcedResult.maxContextTokens).toBe(50000);
|
||||
});
|
||||
|
||||
it('should restore private model spec preset fields in non-enforced mode', async () => {
|
||||
mockGetEndpointsConfig.mockResolvedValue({});
|
||||
|
||||
const modelSpec = {
|
||||
name: 'guarded-openai',
|
||||
iconURL: 'openAI',
|
||||
preset: {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: 'gpt-4o',
|
||||
promptPrefix: 'private prompt prefix',
|
||||
instructions: 'private instructions',
|
||||
additional_instructions: 'private additional instructions',
|
||||
temperature: 0.2,
|
||||
maxContextTokens: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
const req = createReq(
|
||||
{
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
spec: 'guarded-openai',
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.8,
|
||||
},
|
||||
{
|
||||
modelSpecs: {
|
||||
enforce: false,
|
||||
list: [modelSpec],
|
||||
},
|
||||
},
|
||||
);
|
||||
req.baseUrl = '/api/agents/chat';
|
||||
|
||||
await buildEndpointOption(req, createRes(), jest.fn());
|
||||
|
||||
expect(req.body.endpointOption.promptPrefix).toBe('private prompt prefix');
|
||||
expect(req.body.endpointOption.instructions).toBeUndefined();
|
||||
expect(req.body.endpointOption.additional_instructions).toBeUndefined();
|
||||
expect(req.body.endpointOption.temperature).toBe(0.8);
|
||||
expect(req.body.endpointOption.maxContextTokens).toBeUndefined();
|
||||
expect(req.body.endpointOption.iconURL).toBe('openAI');
|
||||
});
|
||||
|
||||
it('should reject non-enforced model specs for a different endpoint', async () => {
|
||||
mockGetEndpointsConfig.mockResolvedValue({});
|
||||
|
||||
const req = createReq(
|
||||
{
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
spec: 'guarded-google',
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
{
|
||||
modelSpecs: {
|
||||
enforce: false,
|
||||
list: [
|
||||
{
|
||||
name: 'guarded-google',
|
||||
preset: {
|
||||
endpoint: EModelEndpoint.google,
|
||||
model: 'gemini-pro',
|
||||
promptPrefix: 'private google prompt',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
const res = createRes();
|
||||
const next = jest.fn();
|
||||
const { handleError } = require('@librechat/api');
|
||||
|
||||
await buildEndpointOption(req, res, next);
|
||||
|
||||
expect(handleError).toHaveBeenCalledWith(res, { text: 'Model spec mismatch' });
|
||||
expect(mockAgentBuildOptions).not.toHaveBeenCalled();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restore private model spec examples when the parser supplies an empty default', async () => {
|
||||
mockGetEndpointsConfig.mockResolvedValue({});
|
||||
|
||||
const examples = [{ input: { content: 'hello' }, output: { content: 'world' } }];
|
||||
const req = createReq(
|
||||
{
|
||||
endpoint: EModelEndpoint.google,
|
||||
spec: 'guarded-google',
|
||||
model: 'gemini-pro',
|
||||
},
|
||||
{
|
||||
modelSpecs: {
|
||||
enforce: false,
|
||||
list: [
|
||||
{
|
||||
name: 'guarded-google',
|
||||
preset: {
|
||||
endpoint: EModelEndpoint.google,
|
||||
model: 'gemini-pro',
|
||||
examples,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
req.baseUrl = '/api/agents/chat';
|
||||
|
||||
await buildEndpointOption(req, createRes(), jest.fn());
|
||||
|
||||
expect(req.body.endpointOption.examples).toEqual(examples);
|
||||
});
|
||||
|
||||
it('should resolve special variables for restored non-agent promptPrefix', async () => {
|
||||
mockGetEndpointsConfig.mockResolvedValue({});
|
||||
|
||||
const req = createReq(
|
||||
{
|
||||
endpoint: EModelEndpoint.assistants,
|
||||
spec: 'guarded-assistant',
|
||||
assistant_id: 'asst_123',
|
||||
},
|
||||
{
|
||||
modelSpecs: {
|
||||
enforce: false,
|
||||
list: [
|
||||
{
|
||||
name: 'guarded-assistant',
|
||||
preset: {
|
||||
endpoint: EModelEndpoint.assistants,
|
||||
assistant_id: 'asst_123',
|
||||
promptPrefix: 'Help {{current_user}}.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
req.user = { name: 'Ada' };
|
||||
|
||||
await buildEndpointOption(req, createRes(), jest.fn());
|
||||
|
||||
expect(req.body.endpointOption.promptPrefix).toBe('Help Ada.');
|
||||
});
|
||||
|
||||
it('should leave restored agent promptPrefix variables for agent initialization', async () => {
|
||||
mockGetEndpointsConfig.mockResolvedValue({});
|
||||
|
||||
const req = createReq(
|
||||
{
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
spec: 'guarded-openai',
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
{
|
||||
modelSpecs: {
|
||||
enforce: false,
|
||||
list: [
|
||||
{
|
||||
name: 'guarded-openai',
|
||||
preset: {
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: 'gpt-4o',
|
||||
promptPrefix: 'Help {{current_user}}.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
req.baseUrl = '/api/agents/chat';
|
||||
req.user = { name: 'Ada' };
|
||||
|
||||
await buildEndpointOption(req, createRes(), jest.fn());
|
||||
|
||||
expect(req.body.endpointOption.promptPrefix).toBe('Help {{current_user}}.');
|
||||
});
|
||||
|
||||
it('should fall back to OpenAI schema when getEndpointsConfig fails', async () => {
|
||||
mockGetEndpointsConfig.mockRejectedValue(new Error('Config unavailable'));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue