Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion api/server/services/Endpoints/agents/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,6 +31,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
model_parameters,
agent: agentPromise,
addedConvo,
customVariables,
});
};

Expand Down
1 change: 1 addition & 0 deletions api/server/services/Endpoints/agents/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions client/src/hooks/Input/useQueryParams.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
}),
);
});
});
21 changes: 20 additions & 1 deletion client/src/hooks/Input/useQueryParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }];
Expand Down Expand Up @@ -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;
}

Expand All @@ -198,6 +199,10 @@ export default function useQueryParams({
}
}

if (!isEqual(validSettingsRef.current.customVariables, convo.customVariables)) {
return false;
}

return true;
}, []);

Expand Down Expand Up @@ -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<string, string> = {};
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 };
};

Expand Down
2 changes: 1 addition & 1 deletion client/src/routes/ChatRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export default function ChatRoute() {

const queryParams: Record<string, string> = {};
searchParams.forEach((value, key) => {
if (key !== 'prompt' && key !== 'q' && key !== 'submit') {
if (key !== 'prompt' && key !== 'q' && key !== 'submit' && !key.startsWith('custom_')) {
queryParams[key] = value;
}
});
Expand Down
49 changes: 49 additions & 0 deletions packages/api/src/agents/__tests__/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}');
});
});
7 changes: 7 additions & 0 deletions packages/api/src/agents/initialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
paramEndpoints,
isAgentsEndpoint,
replaceSpecialVars,
replaceCustomVariables,
providerEndpointMap,
} from 'librechat-data-provider';
import type {
Expand Down Expand Up @@ -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 !== '') {
Expand Down
67 changes: 66 additions & 1 deletion packages/data-provider/specs/parsers.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
19 changes: 18 additions & 1 deletion packages/data-provider/src/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string> | 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
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/data-provider/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -1255,6 +1256,7 @@ export const compactAgentsBaseSchema = tConversationSchema.pick({
agent_id: true,
instructions: true,
additional_instructions: true,
customVariables: true,
});

export const compactAgentsSchema = compactAgentsBaseSchema
Expand Down
4 changes: 3 additions & 1 deletion packages/data-provider/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ export type TEndpointOption = Pick<
| 'effort'
// Assistant/Agent fields
| 'assistant_id'
| 'agent_id'
| 'agent_id'
| 'customVariables'
// UI/Display fields
| 'iconURL'
| 'greeting'
Expand All @@ -71,6 +72,7 @@ export type TEndpointOption = Pick<
| 'examples'
// Context
| 'context'

> & {
// Fields specific to endpoint options that don't exist on TConversation
modelDisplayLabel?: string;
Expand Down
13 changes: 13 additions & 0 deletions packages/data-schemas/src/methods/conversation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/data-schemas/src/methods/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading