diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 5f8aebb56d70..be6c63bcf0e9 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -144,6 +144,7 @@ class AgentClient extends BaseClient { resendFiles: this.options.resendFiles, imageDetail: this.options.imageDetail, maxContextTokens: this.maxContextTokens, + customVariables: this.options.customVariables, }, // TODO: PARSE OPTIONS BY PROVIDER, MAY CONTAIN SENSITIVE DATA runOptions, diff --git a/api/server/services/Endpoints/agents/build.js b/api/server/services/Endpoints/agents/build.js index 19ae3ab7e83f..a40c4654a649 100644 --- a/api/server/services/Endpoints/agents/build.js +++ b/api/server/services/Endpoints/agents/build.js @@ -7,7 +7,7 @@ const db = require('~/models'); const loadAgent = (params) => loadAgentFn(params, { getAgent: db.getAgent, getMCPServerTools }); const buildOptions = (req, endpoint, parsedBody, endpointType) => { - const { spec, iconURL, agent_id, ...model_parameters } = parsedBody; + const { spec, iconURL, agent_id, customVariables, ...model_parameters } = parsedBody; const agentPromise = loadAgent({ req, spec, @@ -31,6 +31,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => { model_parameters, agent: agentPromise, addedConvo, + customVariables, }); }; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 69767e191cb5..9862d3a3a4e2 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -449,6 +449,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { iconURL: endpointOption.iconURL, attachments: primaryConfig.attachments, endpointType: endpointOption.endpointType, + customVariables: endpointOption.customVariables, resendFiles: primaryConfig.resendFiles ?? true, maxContextTokens: primaryConfig.maxContextTokens, endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents, diff --git a/client/src/hooks/Input/useQueryParams.spec.ts b/client/src/hooks/Input/useQueryParams.spec.ts index 1f39c1c4ba60..2f03fd3f798f 100644 --- a/client/src/hooks/Input/useQueryParams.spec.ts +++ b/client/src/hooks/Input/useQueryParams.spec.ts @@ -560,4 +560,54 @@ describe('useQueryParams', () => { expect(params.toString()).toBe(''); expect(options).toEqual(expect.objectContaining({ replace: true })); }); + + it('should extract custom_* URL params and pass them as customVariables to newConversation', () => { + const mockNewConversation = jest.fn(); + const mockTextAreaRef = { + current: { + focus: jest.fn(), + setSelectionRange: jest.fn(), + } as unknown as HTMLTextAreaElement, + }; + + (useChatContext as jest.Mock).mockReturnValue({ + conversation: { model: null, endpoint: null }, + newConversation: mockNewConversation, + }); + + (useChatFormContext as jest.Mock).mockReturnValue({ + setValue: jest.fn(), + getValues: jest.fn().mockReturnValue(''), + handleSubmit: jest.fn((cb) => () => cb({ text: '' })), + }); + + (useQueryClient as jest.Mock).mockReturnValue({ + getQueryData: jest.fn().mockImplementation((key) => { + const k = Array.isArray(key) ? key[0] : key; + if (k === 'startupConfig') { + return { modelSpecs: { list: [] } }; + } + if (k === 'endpoints') { + return {}; + } + return null; + }), + }); + + setUrlParams({ model: 'gpt-4', custom_name: 'Alice', custom_department: 'Engineering' }); + + renderHook(() => useQueryParams({ textAreaRef: mockTextAreaRef })); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(mockNewConversation).toHaveBeenCalled(); + const callArgs = mockNewConversation.mock.calls[0][0]; + expect(callArgs.preset).toEqual( + expect.objectContaining({ + customVariables: { name: 'Alice', department: 'Engineering' }, + }), + ); + }); }); diff --git a/client/src/hooks/Input/useQueryParams.ts b/client/src/hooks/Input/useQueryParams.ts index cea11dc35142..ce4e02e366dd 100644 --- a/client/src/hooks/Input/useQueryParams.ts +++ b/client/src/hooks/Input/useQueryParams.ts @@ -22,6 +22,7 @@ import { useAuthContext, useAgentsMap, useDefaultConvo, useSubmitMessage } from import { startupConfigKey, useGetAgentByIdQuery } from '~/data-provider'; import { useChatContext, useChatFormContext } from '~/Providers'; import store from '~/store'; +import isEqual from 'lodash/isEqual'; const injectAgentIntoAgentsMap = (queryClient: QueryClient, agent: any) => { const editCacheKey = [QueryKeys.agents, { requiredPermission: PermissionBits.EDIT }]; @@ -189,7 +190,7 @@ export default function useQueryParams({ } for (const [key, value] of Object.entries(validSettingsRef.current)) { - if (['presetOverride', 'iconURL', 'spec', 'modelLabel'].includes(key)) { + if (['presetOverride', 'iconURL', 'spec', 'modelLabel', 'customVariables'].includes(key)) { continue; } @@ -198,6 +199,10 @@ export default function useQueryParams({ } } + if (!isEqual(validSettingsRef.current.customVariables, convo.customVariables)) { + return false; + } + return true; }, []); @@ -239,8 +244,22 @@ export default function useQueryParams({ delete queryParams.prompt; delete queryParams.q; delete queryParams.submit; + + // Strip custom_* params before schema validation so processValidSettings doesn't warn. + const customVariables: Record = {}; + for (const key of Object.keys(queryParams)) { + if (key.startsWith('custom_') && key.length > 7) { + customVariables[key.slice(7)] = queryParams[key]; + delete queryParams[key]; + } + } + const validSettings = processValidSettings(queryParams); + if (Object.keys(customVariables).length > 0) { + validSettings.customVariables = customVariables; + } + return { decodedPrompt, validSettings, shouldAutoSubmit }; }; diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index a17d349037d3..fffa40114e1d 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -97,7 +97,7 @@ export default function ChatRoute() { const queryParams: Record = {}; searchParams.forEach((value, key) => { - if (key !== 'prompt' && key !== 'q' && key !== 'submit') { + if (key !== 'prompt' && key !== 'q' && key !== 'submit' && !key.startsWith('custom_')) { queryParams[key] = value; } }); diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index be7377954ac9..52cff18f9493 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -422,3 +422,52 @@ describe('initializeAgent — maxContextTokens', () => { expect(result.maxContextTokens).toBe(1024); }); }); + +describe('initializeAgent — custom variable replacement', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('replaces custom variables in agent instructions', async () => { + const { agent, req, res, loadTools, db } = createMocks(); + agent.instructions = 'Hello {{name}}, welcome to {{department}}'; + + await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { + endpoint: EModelEndpoint.agents, + customVariables: { name: 'Alice', department: 'Engineering' }, + }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent: true, + }, + db, + ); + + expect(agent.instructions).toBe('Hello Alice, welcome to Engineering'); + }); + + it('does not replace custom variable placeholders when customVariables is absent', async () => { + const { agent, req, res, loadTools, db } = createMocks(); + agent.instructions = 'Value is {{some_var}}'; + + await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent: true, + }, + db, + ); + + expect(agent.instructions).toBe('Value is {{some_var}}'); + }); +}); diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 5105b2130535..6a46a57c9aaa 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -7,6 +7,7 @@ import { paramEndpoints, isAgentsEndpoint, replaceSpecialVars, + replaceCustomVariables, providerEndpointMap, } from 'librechat-data-provider'; import type { @@ -406,6 +407,12 @@ export async function initializeAgent( text: agent.instructions, user: req.user ? (req.user as unknown as TUser) : null, }); + if (endpointOption?.customVariables) { + agent.instructions = replaceCustomVariables({ + text: agent.instructions, + customVariables: endpointOption.customVariables, + }); + } } if (typeof agent.artifacts === 'string' && agent.artifacts !== '') { diff --git a/packages/data-provider/specs/parsers.spec.ts b/packages/data-provider/specs/parsers.spec.ts index 83c3500922eb..b5ca359785aa 100644 --- a/packages/data-provider/specs/parsers.spec.ts +++ b/packages/data-provider/specs/parsers.spec.ts @@ -1,4 +1,10 @@ -import { replaceSpecialVars, parseConvo, parseCompactConvo, parseTextParts } from '../src/parsers'; +import { + replaceSpecialVars, + replaceCustomVariables, + parseConvo, + parseCompactConvo, + parseTextParts, +} from '../src/parsers'; import { specialVariables } from '../src/config'; import { EModelEndpoint } from '../src/schemas'; import { ContentTypes } from '../src/types/runs'; @@ -128,6 +134,65 @@ describe('replaceSpecialVars', () => { }); }); +describe('replaceCustomVariables', () => { + test('should return the original text if text is empty or falsy', () => { + expect(replaceCustomVariables({ text: '', customVariables: { a: '1' } })).toBe(''); + expect( + replaceCustomVariables({ text: null as unknown as string, customVariables: { a: '1' } }), + ).toBe(null); + expect( + replaceCustomVariables({ text: undefined as unknown as string, customVariables: { a: '1' } }), + ).toBe(undefined); + }); + + test('should return the original text if customVariables is null or undefined', () => { + const text = 'Hello {{name}}'; + expect(replaceCustomVariables({ text, customVariables: null })).toBe(text); + expect(replaceCustomVariables({ text, customVariables: undefined })).toBe(text); + expect(replaceCustomVariables({ text })).toBe(text); + }); + + test('should replace a single custom variable', () => { + const result = replaceCustomVariables({ + text: 'Hello {{name}}!', + customVariables: { name: 'Alice' }, + }); + expect(result).toBe('Hello Alice!'); + }); + + test('should replace multiple different custom variables', () => { + const result = replaceCustomVariables({ + text: '{{greeting}} {{name}}, welcome to {{place}}', + customVariables: { greeting: 'Hi', name: 'Bob', place: 'NYC' }, + }); + expect(result).toBe('Hi Bob, welcome to NYC'); + }); + + test('should leave unmatched variables as-is when not in customVariables', () => { + const result = replaceCustomVariables({ + text: '{{known}} and {{unknown}}', + customVariables: { known: 'yes' }, + }); + expect(result).toBe('yes and {{unknown}}'); + }); + + test('should NOT replace special variables even if present in customVariables', () => { + Object.keys(specialVariables).forEach((key) => { + const result = replaceCustomVariables({ + text: `{{${key}}}`, + customVariables: { [key]: 'should_not_replace' }, + }); + expect(result).toBe(`{{${key}}}`); + }); + + const combined = replaceCustomVariables({ + text: '{{current_date}} {{current_user}} {{my_var}}', + customVariables: { current_date: 'WRONG', current_user: 'WRONG', my_var: 'RIGHT' }, + }); + expect(combined).toBe('{{current_date}} {{current_user}} RIGHT'); + }); +}); + describe('parseCompactConvo', () => { describe('iconURL security sanitization', () => { test('should strip iconURL from OpenAI endpoint conversation input', () => { diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index 3ec4221b62e1..c516c84ad9ea 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -16,7 +16,7 @@ import { compactAssistantSchema, } from './schemas'; import { bedrockInputSchema } from './bedrock'; -import { alternateName } from './config'; +import { alternateName, specialVariables } from './config'; type EndpointSchema = | typeof openAISchema @@ -427,6 +427,23 @@ export function replaceSpecialVars({ text, user }: { text: string; user?: t.TUse return result; } +const CUSTOM_VAR_PATTERN = /{{([a-zA-Z0-9_]+)}}/g; + +export function replaceCustomVariables({ + text, + customVariables, +}: { + text: string; + customVariables?: Record | null; +}): string { + if (!text || !customVariables) { + return text; + } + return text.replace(CUSTOM_VAR_PATTERN, (match, name: string) => + name in specialVariables ? match : (customVariables[name] ?? match), + ); +} + /** * Parsed ephemeral agent ID result */ diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 084f74af8646..aa0587561653 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -780,6 +780,7 @@ export const tConversationSchema = z.object({ assistant_id: z.string().optional(), /* agents */ agent_id: z.string().optional(), + customVariables: z.record(z.string()).optional(), /* AWS Bedrock */ region: z.string().optional(), maxTokens: coerceNumber.optional(), @@ -1255,6 +1256,7 @@ export const compactAgentsBaseSchema = tConversationSchema.pick({ agent_id: true, instructions: true, additional_instructions: true, + customVariables: true, }); export const compactAgentsSchema = compactAgentsBaseSchema diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index b8942dd173d0..80529b4389ea 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -56,7 +56,8 @@ export type TEndpointOption = Pick< | 'effort' // Assistant/Agent fields | 'assistant_id' - | 'agent_id' + | 'agent_id' + | 'customVariables' // UI/Display fields | 'iconURL' | 'greeting' @@ -71,6 +72,7 @@ export type TEndpointOption = Pick< | 'examples' // Context | 'context' + > & { // Fields specific to endpoint options that don't exist on TConversation modelDisplayLabel?: string; diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 9e4c2d2f5db0..ade339e11ce1 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -204,6 +204,19 @@ describe('Conversation Operations', () => { }); expect(savedConvo?.someField).toBeUndefined(); }); + + it('should serialize customVariables as a plain object (not a Mongoose Map)', async () => { + const convoData = { + ...mockConversationData, + customVariables: { region: 'us-east', theme: 'dark' }, + }; + + const result = await saveConvo(mockCtx, convoData); + + expect(result).not.toBeNull(); + expect(result?.customVariables).not.toBeInstanceOf(Map); + expect(result?.customVariables).toEqual({ region: 'us-east', theme: 'dark' }); + }); }); describe('isTemporary conversation handling', () => { diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 00b5cfee7aeb..3b05f52c3a39 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -204,7 +204,7 @@ export function createConversationMethods( return null; } - return conversation.toObject(); + return conversation.toObject({ flattenMaps: true }); } catch (error) { logger.error('[saveConvo] Error saving conversation', error); if (metadata?.context) { diff --git a/packages/data-schemas/src/schema/defaults.ts b/packages/data-schemas/src/schema/defaults.ts index 9b50bceb1d9c..b2f5f7266647 100644 --- a/packages/data-schemas/src/schema/defaults.ts +++ b/packages/data-schemas/src/schema/defaults.ts @@ -160,4 +160,8 @@ export const conversationPreset = { verbosity: { type: String, }, + customVariables: { + type: Map, + of: String, + }, }; diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index c7888efba2c4..8c51b0fa3439 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -57,4 +57,5 @@ export interface IConversation extends Document { createdAt?: Date; updatedAt?: Date; tenantId?: string; + customVariables?: Record; }