From ca8c212c0dbbc52d3edca47ecdfe15ff4e0e11d6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 14 May 2026 10:07:23 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=9D=EF=B8=8F=20fix:=20Protect=20Model?= =?UTF-8?q?=20Spec=20Instructions=20(#13125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent instruction exposure * fix: tighten model spec preset restoration * refactor: type model spec preset handling --- api/server/middleware/buildEndpointOption.js | 61 ++++-- .../middleware/buildEndpointOption.spec.js | 184 ++++++++++++++++- api/server/routes/__tests__/config.spec.js | 37 ++++ api/server/routes/config.js | 9 +- .../api/src/agents/__tests__/load.spec.ts | 20 ++ packages/api/src/agents/load.ts | 9 +- packages/api/src/index.ts | 2 + .../src/mcp/registry/MCPServersInitializer.ts | 18 +- .../__tests__/MCPServersInitializer.test.ts | 11 + packages/api/src/modelSpecs/index.ts | 189 ++++++++++++++++++ .../api/src/modelSpecs/modelSpecs.test.ts | 149 ++++++++++++++ 11 files changed, 668 insertions(+), 21 deletions(-) create mode 100644 packages/api/src/modelSpecs/index.ts create mode 100644 packages/api/src/modelSpecs/modelSpecs.test.ts diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index cf4f773160..1eaa1ef8d9 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -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]; diff --git a/api/server/middleware/buildEndpointOption.spec.js b/api/server/middleware/buildEndpointOption.spec.js index 5d93acd6bb..9c353b498a 100644 --- a/api/server/middleware/buildEndpointOption.spec.js +++ b/api/server/middleware/buildEndpointOption.spec.js @@ -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')); diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index d92c56b8bb..52a843116c 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -322,6 +322,43 @@ describe('GET /api/config', () => { expect(response.body.webSearch).toEqual({ searchProvider: 'tavily' }); }); + it('should strip private prompt fields from model spec presets', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + modelSpecs: { + enforce: false, + prioritize: true, + list: [ + { + name: 'guarded-spec', + label: 'Guarded Spec', + preset: { + endpoint: 'openAI', + model: 'gpt-4o', + promptPrefix: 'private prompt prefix', + instructions: 'private assistant instructions', + additional_instructions: 'private additional instructions', + system: 'private bedrock system', + context: 'private context', + examples: [{ input: { content: 'a' }, output: { content: 'b' } }], + greeting: 'Hello', + }, + }, + ], + }, + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.statusCode).toBe(200); + expect(response.body.modelSpecs.list[0].preset).toEqual({ + endpoint: 'openAI', + model: 'gpt-4o', + greeting: 'Hello', + }); + }); + it('should include full interface config', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); const app = createApp(mockUser); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 46f4cc09da..2b9fd6e7ad 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -1,5 +1,10 @@ const express = require('express'); -const { isEnabled, getBalanceConfig, getCloudFrontConfig } = require('@librechat/api'); +const { + isEnabled, + getBalanceConfig, + getCloudFrontConfig, + sanitizeModelSpecs, +} = require('@librechat/api'); const { defaultSocialLogins } = require('librechat-data-provider'); const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas'); const { hasCapability } = require('~/server/middleware/roles/capabilities'); @@ -181,7 +186,7 @@ router.get('/', async function (req, res) { socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, interface: appConfig?.interfaceConfig, turnstile: appConfig?.turnstileConfig, - modelSpecs: appConfig?.modelSpecs, + modelSpecs: sanitizeModelSpecs(appConfig?.modelSpecs), balance: balanceConfig, bundlerURL: process.env.SANDPACK_BUNDLER_URL, staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL, diff --git a/packages/api/src/agents/__tests__/load.spec.ts b/packages/api/src/agents/__tests__/load.spec.ts index b7c6142d69..2419310b83 100644 --- a/packages/api/src/agents/__tests__/load.spec.ts +++ b/packages/api/src/agents/__tests__/load.spec.ts @@ -233,6 +233,26 @@ describe('loadAgent', () => { } }); + test('should use parsed promptPrefix for ephemeral agent instructions', async () => { + const { EPHEMERAL_AGENT_ID } = Constants; + + const result = await loadAgent( + { + req: { user: { id: 'user123' }, body: {} }, + agent_id: EPHEMERAL_AGENT_ID as string, + endpoint: 'openai', + model_parameters: { + model: 'gpt-4', + promptPrefix: 'Server-side model spec instructions', + } as unknown as AgentModelParameters, + }, + deps, + ); + + expect(result?.instructions).toBe('Server-side model spec instructions'); + expect(result?.model_parameters).not.toHaveProperty('promptPrefix'); + }); + test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => { const { EPHEMERAL_AGENT_ID } = Constants; diff --git a/packages/api/src/agents/load.ts b/packages/api/src/agents/load.ts index 05746d1195..66f01d9440 100644 --- a/packages/api/src/agents/load.ts +++ b/packages/api/src/agents/load.ts @@ -16,6 +16,7 @@ import type { import { getCustomEndpointConfig } from '~/app/config'; const { mcp_all, mcp_delimiter } = Constants; +type ModelParametersWithPromptPrefix = AgentModelParameters & { promptPrefix?: string | null }; export interface LoadAgentDeps { getAgent: (searchParameter: { id: string }) => Promise; @@ -89,7 +90,11 @@ export async function loadEphemeralAgent( } } - const instructions = req.body?.promptPrefix; + const requestPromptPrefix = req.body?.promptPrefix; + const { promptPrefix: modelPromptPrefix, ...safeModelParameters } = + model_parameters as ModelParametersWithPromptPrefix; + const instructions = + typeof modelPromptPrefix === 'string' ? modelPromptPrefix : requestPromptPrefix; // Get endpoint config for modelDisplayLabel fallback const appConfig = req.config; @@ -122,7 +127,7 @@ export async function loadEphemeralAgent( id: ephemeralId, instructions, provider: endpoint, - model_parameters, + model_parameters: safeModelParameters as AgentModelParameters, model, tools, }; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 6369ce6ed4..338e86d7cc 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -33,6 +33,8 @@ export * from './flow/manager'; export * from './middleware'; /* Memory */ export * from './memory'; +/* Model Specs */ +export * from './modelSpecs'; /* Agents */ export * from './agents'; /* Prompts */ diff --git a/packages/api/src/mcp/registry/MCPServersInitializer.ts b/packages/api/src/mcp/registry/MCPServersInitializer.ts index 56c6ef486b..7c19f6338f 100644 --- a/packages/api/src/mcp/registry/MCPServersInitializer.ts +++ b/packages/api/src/mcp/registry/MCPServersInitializer.ts @@ -159,11 +159,27 @@ export class MCPServersInitializer { logger.info(`${prefix} OAuth Required: ${config.requiresOAuth}`); logger.info(`${prefix} Capabilities: ${config.capabilities}`); logger.info(`${prefix} Tools: ${config.tools}`); - logger.info(`${prefix} Server Instructions: ${config.serverInstructions}`); + logger.info( + `${prefix} Server Instructions: ${MCPServersInitializer.formatInstructionsForLogging( + config.serverInstructions, + )}`, + ); logger.info(`${prefix} Initialized in: ${config.initDuration ?? 'N/A'}ms`); logger.info(`${prefix} -------------------------------------------------┘`); } + private static formatInstructionsForLogging(instructions?: string | boolean): string { + if (!instructions) { + return 'N/A'; + } + + if (typeof instructions !== 'string') { + return 'configured'; + } + + return `configured (${instructions.length} chars)`; + } + // Returns formatted log prefix for server messages private static prefix(serverName: string): string { return `[MCP][${serverName}]`; diff --git a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts index 03cfa948fc..1e705cb79b 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts @@ -458,6 +458,17 @@ describe('MCPServersInitializer', () => { ); }); + it('should not log raw server instructions', async () => { + await MCPServersInitializer.initialize(testConfigs); + + expect(mockLogger.info).not.toHaveBeenCalledWith( + expect.stringContaining('Instructions for file_tools_server'), + ); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('[MCP][file_tools_server] Server Instructions: configured'), + ); + }); + it('should use Promise.allSettled for parallel server initialization', async () => { const allSettledSpy = jest.spyOn(Promise, 'allSettled'); diff --git a/packages/api/src/modelSpecs/index.ts b/packages/api/src/modelSpecs/index.ts new file mode 100644 index 0000000000..4e34dd0bac --- /dev/null +++ b/packages/api/src/modelSpecs/index.ts @@ -0,0 +1,189 @@ +import { + parseCompactConvo, + replaceSpecialVars, + type EModelEndpoint, + type TConversation, + type TModelSpec, + type TModelSpecPreset, + type TPreset, + type TSpecsConfig, + type TUser, +} from 'librechat-data-provider'; + +export const PRIVATE_MODEL_SPEC_PRESET_FIELDS = [ + 'promptPrefix', + 'instructions', + 'additional_instructions', + 'system', + 'context', + 'examples', +] as const satisfies readonly (keyof TModelSpecPreset)[]; + +export type PrivateModelSpecPresetField = (typeof PRIVATE_MODEL_SPEC_PRESET_FIELDS)[number]; +export type ModelSpecParsedBody = Partial & + Record; + +export type ApplyModelSpecPresetParams = { + modelSpec: TModelSpec; + parsedBody: ModelSpecParsedBody; + endpoint?: string | null; + endpointType?: string | null; + defaultParamsEndpoint?: string | null; + includePresetDefaults?: boolean; +}; + +export type ApplyModelSpecPresetResult = { + parsedBody: ModelSpecParsedBody; + appliedPrivateFields: Set; +}; + +function hasModelSpecValue(field: PrivateModelSpecPresetField, value: unknown): boolean { + if (value == null || value === '') { + return false; + } + + if (!Array.isArray(value)) { + return true; + } + + if (field === 'examples') { + return value.some((example) => { + const input = example?.input?.content; + const output = example?.output?.content; + return Boolean(input || output); + }); + } + + return value.length > 0; +} + +function mergeModelSpecPreset( + modelSpec: TModelSpec, + parsedBody: ModelSpecParsedBody, + { includePresetDefaults = false }: Pick = {}, +): ApplyModelSpecPresetResult { + const preset = modelSpec.preset; + const merged = { + ...(includePresetDefaults ? preset : {}), + ...parsedBody, + spec: modelSpec.name, + } as ModelSpecParsedBody; + const appliedPrivateFields = new Set(); + + for (const field of PRIVATE_MODEL_SPEC_PRESET_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(preset, field)) { + continue; + } + + if (includePresetDefaults) { + appliedPrivateFields.add(field); + continue; + } + + if (!hasModelSpecValue(field, parsedBody[field])) { + (merged as Record)[field] = preset[field]; + appliedPrivateFields.add(field); + } + } + + return { parsedBody: merged, appliedPrivateFields }; +} + +export function findModelSpecByName( + modelSpecs: Pick | undefined, + spec: string | null | undefined, +): TModelSpec | undefined { + if (!spec) { + return undefined; + } + + return modelSpecs?.list?.find((modelSpec) => modelSpec.name === spec); +} + +export function isModelSpecEndpointMatch( + modelSpec: Pick | undefined, + endpoint: string | null | undefined, +): boolean { + return Boolean(modelSpec && endpoint === modelSpec.preset?.endpoint); +} + +export function applyModelSpecPreset({ + modelSpec, + parsedBody, + endpoint, + endpointType, + defaultParamsEndpoint, + includePresetDefaults, +}: ApplyModelSpecPresetParams): ApplyModelSpecPresetResult { + const { parsedBody: conversation, appliedPrivateFields } = mergeModelSpecPreset( + modelSpec, + parsedBody, + { + includePresetDefaults, + }, + ); + const reparsedBody = parseCompactConvo({ + endpoint: endpoint as EModelEndpoint | undefined, + endpointType: endpointType as EModelEndpoint | null | undefined, + conversation, + defaultParamsEndpoint, + }); + + if (!reparsedBody) { + throw new Error('Model spec preset produced an empty parsed body'); + } + + const modelSpecParsedBody = reparsedBody as ModelSpecParsedBody; + if (modelSpec.iconURL != null && modelSpec.iconURL !== '') { + modelSpecParsedBody.iconURL = modelSpec.iconURL; + } + + return { parsedBody: modelSpecParsedBody, appliedPrivateFields }; +} + +export function resolveModelSpecPromptPrefixVariables( + parsedBody: T, + user?: TUser | null, + now?: string | number | Date, +): T { + if (typeof parsedBody.promptPrefix !== 'string') { + return parsedBody; + } + + return { + ...parsedBody, + promptPrefix: replaceSpecialVars({ + text: parsedBody.promptPrefix, + user, + now, + }), + }; +} + +export function sanitizeModelSpecs | null | undefined>( + modelSpecs: T, +): T { + if (!modelSpecs?.list || !Array.isArray(modelSpecs.list)) { + return modelSpecs; + } + + return { + ...modelSpecs, + list: modelSpecs.list.map((modelSpec) => { + const preset = modelSpec?.preset; + if (!preset || typeof preset !== 'object') { + return modelSpec; + } + + const sanitizedPreset = { ...preset }; + for (const field of PRIVATE_MODEL_SPEC_PRESET_FIELDS) { + delete sanitizedPreset[field]; + } + + return { + ...modelSpec, + preset: sanitizedPreset, + }; + }), + } as T; +} diff --git a/packages/api/src/modelSpecs/modelSpecs.test.ts b/packages/api/src/modelSpecs/modelSpecs.test.ts new file mode 100644 index 0000000000..a617cd9442 --- /dev/null +++ b/packages/api/src/modelSpecs/modelSpecs.test.ts @@ -0,0 +1,149 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TModelSpec } from 'librechat-data-provider'; +import { + applyModelSpecPreset, + findModelSpecByName, + isModelSpecEndpointMatch, + resolveModelSpecPromptPrefixVariables, + sanitizeModelSpecs, +} from './index'; + +describe('modelSpecs helpers', () => { + it('should strip private prompt fields from model spec presets', () => { + const modelSpecs = { + enforce: false, + prioritize: true, + list: [ + { + name: 'guarded-spec', + label: 'Guarded Spec', + preset: { + endpoint: EModelEndpoint.openAI, + model: 'gpt-4o', + promptPrefix: 'private prompt prefix', + instructions: 'private assistant instructions', + additional_instructions: 'private additional instructions', + system: 'private bedrock system', + context: 'private context', + examples: [{ input: { content: 'a' }, output: { content: 'b' } }], + greeting: 'Hello', + }, + }, + ], + }; + + expect(sanitizeModelSpecs(modelSpecs).list[0].preset).toEqual({ + endpoint: EModelEndpoint.openAI, + model: 'gpt-4o', + greeting: 'Hello', + }); + }); + + it('should restore only private fields for non-enforced model specs', () => { + const modelSpec: TModelSpec = { + name: 'guarded-openai', + label: 'Guarded OpenAI', + iconURL: EModelEndpoint.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 { parsedBody, appliedPrivateFields } = applyModelSpecPreset({ + modelSpec, + parsedBody: { + endpoint: EModelEndpoint.openAI, + spec: 'guarded-openai', + model: 'gpt-4o', + temperature: 0.8, + }, + endpoint: EModelEndpoint.openAI, + }); + + expect(parsedBody.promptPrefix).toBe('private prompt prefix'); + expect(parsedBody.instructions).toBeUndefined(); + expect(parsedBody.additional_instructions).toBeUndefined(); + expect(parsedBody.temperature).toBe(0.8); + expect(parsedBody.maxContextTokens).toBeUndefined(); + expect(parsedBody.iconURL).toBe(EModelEndpoint.openAI); + expect(appliedPrivateFields.has('promptPrefix')).toBe(true); + }); + + it('should restore preset defaults when model specs are enforced', () => { + const modelSpec: TModelSpec = { + name: 'enforced-openai', + label: 'Enforced OpenAI', + preset: { + endpoint: EModelEndpoint.openAI, + model: 'gpt-4o', + promptPrefix: 'private prompt prefix', + temperature: 0.2, + }, + }; + + const { parsedBody } = applyModelSpecPreset({ + modelSpec, + parsedBody: modelSpec.preset, + endpoint: EModelEndpoint.openAI, + includePresetDefaults: true, + }); + + expect(parsedBody.spec).toBe('enforced-openai'); + expect(parsedBody.promptPrefix).toBe('private prompt prefix'); + expect(parsedBody.temperature).toBe(0.2); + }); + + it('should restore private examples when parser supplies an empty default', () => { + const examples = [{ input: { content: 'hello' }, output: { content: 'world' } }]; + const modelSpec: TModelSpec = { + name: 'guarded-google', + label: 'Guarded Google', + preset: { + endpoint: EModelEndpoint.google, + model: 'gemini-pro', + examples, + }, + }; + + const { parsedBody } = applyModelSpecPreset({ + modelSpec, + parsedBody: { + endpoint: EModelEndpoint.google, + spec: 'guarded-google', + model: 'gemini-pro', + }, + endpoint: EModelEndpoint.google, + }); + + expect(parsedBody.examples).toEqual(examples); + }); + + it('should find specs and validate endpoint matches', () => { + const modelSpec: TModelSpec = { + name: 'guarded-openai', + label: 'Guarded OpenAI', + preset: { + endpoint: EModelEndpoint.openAI, + model: 'gpt-4o', + }, + }; + + expect(findModelSpecByName({ list: [modelSpec] }, 'guarded-openai')).toBe(modelSpec); + expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.openAI)).toBe(true); + expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.google)).toBe(false); + }); + + it('should resolve special variables in model spec prompt prefixes', () => { + expect( + resolveModelSpecPromptPrefixVariables({ promptPrefix: 'Help {{current_user}}.' }, { + name: 'Ada', + } as never).promptPrefix, + ).toBe('Help Ada.'); + }); +});