Compare commits

...

9 Commits

Author SHA1 Message Date
Dustin Healy
e9d0442531 feat: add body to other endpoints 2025-08-10 16:00:29 -07:00
Dustin Healy
274987712c feat: add body for mcp tool calls 2025-08-10 16:00:29 -07:00
Gopal Sharma
888e3a31cf Merge branch 'dev' into feat/custom-endpoint-conversation-id 2025-08-10 23:47:15 +05:30
Dustin Healy
7ea23c5a7d fix: type error in unit test 2025-08-09 16:04:54 -07:00
Dustin Healy
f4833b6b25 style: minor styling cleanup 2025-08-09 16:02:51 -07:00
Gopal Sharma
d37db43e29 refactor resolveHeaders 2025-08-10 03:55:35 +05:30
Gopal Sharma
eec10bf745 feat: add support for request body placeholders in custom endpoint headers
- Add {{LIBRECHAT_BODY_*}} placeholders for conversationId, parentMessageId, messageId
- Update tests to reflect new body placeholder functionality
2025-08-10 03:55:35 +05:30
s10gopal
3508839d6d fix: filter out unresolved placeholders from headers (thanks @MrunmayS) 2025-08-10 03:55:35 +05:30
Gopal Sharma
a8babbcebf feat: Add conversation ID support to custom endpoint headers
- Add LIBRECHAT_CONVERSATION_ID to customUserVars when provided
- Pass conversation ID to header resolution for dynamic headers
- Add comprehensive test coverage

Enables custom endpoints to access conversation context using {{LIBRECHAT_CONVERSATION_ID}} placeholder.
2025-08-10 03:55:35 +05:30
13 changed files with 148 additions and 63 deletions

View File

@@ -653,8 +653,10 @@ class OpenAIClient extends BaseClient {
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
configOptions.baseOptions = {
headers: resolveHeaders({
...headers,
...configOptions?.baseOptions?.headers,
headers: {
...headers,
...configOptions?.baseOptions?.headers,
},
}),
};
}
@@ -749,7 +751,7 @@ class OpenAIClient extends BaseClient {
groupMap,
});
this.options.headers = resolveHeaders(headers);
this.options.headers = resolveHeaders({ headers });
this.options.reverseProxyUrl = baseURL ?? null;
this.langchainProxy = extractBaseURL(this.options.reverseProxyUrl);
this.apiKey = azureOptions.azureOpenAIApiKey;
@@ -1181,7 +1183,7 @@ ${convo}
modelGroupMap,
groupMap,
});
opts.defaultHeaders = resolveHeaders(headers);
opts.defaultHeaders = resolveHeaders({ headers });
this.langchainProxy = extractBaseURL(baseURL);
this.apiKey = azureOptions.azureOpenAIApiKey;

View File

@@ -109,14 +109,15 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
apiKey = azureOptions.azureOpenAIApiKey;
opts.defaultQuery = { 'api-version': azureOptions.azureOpenAIApiVersion };
opts.defaultHeaders = resolveHeaders(
{
opts.defaultHeaders = resolveHeaders({
headers: {
...headers,
'api-key': apiKey,
'OpenAI-Beta': `assistants=${version}`,
},
req.user,
);
user: req.user,
body: req.body,
});
opts.model = azureOptions.azureOpenAIApiDeploymentName;
if (initAppClient) {

View File

@@ -28,7 +28,11 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
let resolvedHeaders = resolveHeaders(endpointConfig.headers, req.user);
let resolvedHeaders = resolveHeaders({
headers: endpointConfig.headers,
user: req.user,
body: req.body,
});
if (CUSTOM_API_KEY.match(envVarRegex)) {
throw new Error(`Missing API Key for ${endpoint}.`);

View File

@@ -64,13 +64,14 @@ describe('custom/initializeClient', () => {
jest.clearAllMocks();
});
it('calls resolveHeaders with headers and user', async () => {
it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => {
const { resolveHeaders } = require('@librechat/api');
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
expect(resolveHeaders).toHaveBeenCalledWith(
{ 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
{ id: 'user-123', email: 'test@example.com' },
);
expect(resolveHeaders).toHaveBeenCalledWith({
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
user: { id: 'user-123', email: 'test@example.com' },
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
});
});
it('throws if endpoint config is missing', async () => {

View File

@@ -81,10 +81,11 @@ const initializeClient = async ({
serverless = _serverless;
clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
clientOptions.headers = resolveHeaders(
{ ...headers, ...(clientOptions.headers ?? {}) },
req.user,
);
clientOptions.headers = resolveHeaders({
headers: { ...headers, ...(clientOptions.headers ?? {}) },
user: req.user,
body: req.body,
});
clientOptions.titleConvo = azureConfig.titleConvo;
clientOptions.titleModel = azureConfig.titleModel;

View File

@@ -189,6 +189,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
},
oauthStart,
oauthEnd,
body: req.body,
});
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {

View File

@@ -259,6 +259,8 @@ endpoints:
# recommended environment variables:
apiKey: '${OPENROUTER_KEY}'
baseURL: 'https://openrouter.ai/api/v1'
headers:
x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}'
models:
default: ['meta-llama/llama-3-70b-instruct']
fetch: true

View File

@@ -87,10 +87,10 @@ export const initializeOpenAI = async ({
});
clientOptions.reverseProxyUrl = configBaseURL ?? clientOptions.reverseProxyUrl;
clientOptions.headers = resolveHeaders(
{ ...headers, ...(clientOptions.headers ?? {}) },
req.user,
);
clientOptions.headers = resolveHeaders({
headers: { ...headers, ...(clientOptions.headers ?? {}) },
user: req.user,
});
const groupName = modelGroupMap[modelName || '']?.group;
if (groupName && groupMap[groupName]) {

View File

@@ -3,7 +3,7 @@ import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol
import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { TokenMethods } from '@librechat/data-schemas';
import type { TUser } from 'librechat-data-provider';
import type { TUser, RequestBody } from 'librechat-data-provider';
import type { MCPOAuthTokens, MCPOAuthFlowMetadata } from './oauth/types';
import type { FlowStateManager } from '~/flow/manager';
import type { JsonSchemaType } from '~/types/zod';
@@ -373,6 +373,7 @@ export class MCPManager {
oauthEnd,
signal,
returnOnOAuth = false,
body,
}: {
user: TUser;
serverName: string;
@@ -383,6 +384,7 @@ export class MCPManager {
oauthEnd?: () => Promise<void>;
signal?: AbortSignal;
returnOnOAuth?: boolean;
body?: RequestBody;
}): Promise<MCPConnection> {
const userId = user.id;
if (!userId) {
@@ -432,7 +434,7 @@ export class MCPManager {
);
}
config = { ...(processMCPEnv(config, user, customUserVars) ?? {}) };
config = { ...(processMCPEnv(config, user, customUserVars, body) ?? {}) };
/** If no in-memory tokens, tokens from persistent storage */
let tokens: MCPOAuthTokens | null = null;
if (tokenMethods?.findToken) {
@@ -859,6 +861,7 @@ export class MCPManager {
oauthStart,
oauthEnd,
customUserVars,
body,
}: {
user?: TUser;
serverName: string;
@@ -871,6 +874,7 @@ export class MCPManager {
flowManager: FlowStateManager<MCPOAuthTokens | null>;
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
body?: RequestBody;
}): Promise<t.FormattedToolResponse> {
/** User-specific connection */
let connection: MCPConnection | undefined;
@@ -890,6 +894,7 @@ export class MCPManager {
oauthEnd,
signal: options?.signal,
customUserVars,
body,
});
} else {
/** App-level connection */

View File

@@ -36,12 +36,14 @@ describe('resolveHeaders', () => {
});
it('should return empty object when headers is null', () => {
const result = resolveHeaders(null as unknown as Record<string, string> | undefined);
const result = resolveHeaders({
headers: null as unknown as Record<string, string>,
});
expect(result).toEqual({});
});
it('should return empty object when headers is empty', () => {
const result = resolveHeaders({});
const result = resolveHeaders({ headers: {} });
expect(result).toEqual({});
});
@@ -52,7 +54,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers);
const result = resolveHeaders({ headers });
expect(result).toEqual({
Authorization: 'test-api-key-value',
@@ -68,7 +70,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Id': 'test-user-123',
@@ -82,7 +84,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers);
const result = resolveHeaders({ headers });
expect(result).toEqual({
'User-Id': '{{LIBRECHAT_USER_ID}}',
@@ -97,7 +99,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Id': '{{LIBRECHAT_USER_ID}}',
@@ -123,7 +125,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Email': 'test@example.com',
@@ -148,7 +150,7 @@ describe('resolveHeaders', () => {
'Non-Existent': '{{LIBRECHAT_USER_NONEXISTENT}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Email': 'test@example.com',
@@ -171,7 +173,7 @@ describe('resolveHeaders', () => {
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
Authorization: 'Bearer user-specific-token',
@@ -194,7 +196,7 @@ describe('resolveHeaders', () => {
'Test-Email': '{{LIBRECHAT_USER_EMAIL}}',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
'Test-Email': 'custom-email@example.com',
@@ -213,7 +215,7 @@ describe('resolveHeaders', () => {
'User-Id': '{{LIBRECHAT_USER_ID}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'User-Role': 'admin',
@@ -233,7 +235,7 @@ describe('resolveHeaders', () => {
'Backup-Email': '{{LIBRECHAT_USER_EMAIL}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result).toEqual({
'Primary-Email': 'test@example.com',
@@ -259,7 +261,7 @@ describe('resolveHeaders', () => {
'Content-Type': 'application/json',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
Authorization: 'Bearer secret-token',
@@ -277,7 +279,7 @@ describe('resolveHeaders', () => {
};
const user = { id: 'user-123' };
const result = resolveHeaders(originalHeaders, user);
const result = resolveHeaders({ headers: originalHeaders, user });
// Verify the result is processed
expect(result).toEqual({
@@ -306,7 +308,7 @@ describe('resolveHeaders', () => {
'Dot-Header': '{{CUSTOM.VAR}}',
};
const result = resolveHeaders(headers, user, customUserVars);
const result = resolveHeaders({ headers, user, customUserVars });
expect(result).toEqual({
'Dash-Header': 'dash-value',
@@ -357,7 +359,7 @@ describe('resolveHeaders', () => {
'X-User-TermsAccepted': '{{LIBRECHAT_USER_TERMSACCEPTED}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result['X-User-ID']).toBe('abc');
expect(result['X-User-Name']).toBe('Test User');
@@ -384,7 +386,7 @@ describe('resolveHeaders', () => {
'X-Multi': 'User: {{LIBRECHAT_USER_ID}}, Env: ${TEST_API_KEY}, Custom: {{MY_CUSTOM}}',
};
const customVars = { MY_CUSTOM: 'custom-value' };
const result = resolveHeaders(headers, user, customVars);
const result = resolveHeaders({ headers, user, customUserVars: customVars });
expect(result['X-Multi']).toBe('User: abc, Env: test-api-key-value, Custom: custom-value');
});
@@ -394,7 +396,7 @@ describe('resolveHeaders', () => {
'X-Unknown': '{{SOMETHING_NOT_RECOGNIZED}}',
'X-Known': '{{LIBRECHAT_USER_ID}}',
};
const result = resolveHeaders(headers, user);
const result = resolveHeaders({ headers, user });
expect(result['X-Unknown']).toBe('{{SOMETHING_NOT_RECOGNIZED}}');
expect(result['X-Known']).toBe('abc');
});
@@ -416,7 +418,7 @@ describe('resolveHeaders', () => {
'X-Boolean': '{{LIBRECHAT_USER_EMAILVERIFIED}}',
};
const customVars = { MY_CUSTOM: 'custom-value' };
const result = resolveHeaders(headers, user, customVars);
const result = resolveHeaders({ headers, user, customUserVars: customVars });
expect(result['X-User']).toBe('abc');
expect(result['X-Env']).toBe('test-api-key-value');
@@ -426,4 +428,15 @@ describe('resolveHeaders', () => {
expect(result['X-Empty']).toBe('');
expect(result['X-Boolean']).toBe('true');
});
it('should process LIBRECHAT_BODY placeholders', () => {
const body = {
conversationId: 'conv-123',
parentMessageId: 'parent-456',
messageId: 'msg-789',
};
const headers = { 'X-Conversation': '{{LIBRECHAT_BODY_CONVERSATIONID}}' };
const result = resolveHeaders({ headers, body });
expect(result['X-Conversation']).toBe('conv-123');
});
});

View File

@@ -1,5 +1,5 @@
import { extractEnvVariable } from 'librechat-data-provider';
import type { TUser, MCPOptions } from 'librechat-data-provider';
import type { TUser, MCPOptions, RequestBody } from 'librechat-data-provider';
/**
* List of allowed user fields that can be used in MCP environment variables.
@@ -25,6 +25,12 @@ const ALLOWED_USER_FIELDS = [
'termsAccepted',
] as const;
/**
* List of allowed request body fields that can be used in header placeholders.
* These are common fields from the request body that are safe to expose in headers.
*/
const ALLOWED_BODY_FIELDS = ['conversationId', 'parentMessageId', 'messageId'] as const;
/**
* Processes a string value to replace user field placeholders
* @param value - The string value to process
@@ -61,21 +67,48 @@ function processUserPlaceholders(value: string, user?: TUser): string {
return value;
}
/**
* Replaces request body field placeholders within a string.
* Recognized placeholders: `{{LIBRECHAT_BODY_<FIELD>}}` where `<FIELD>` ∈ ALLOWED_BODY_FIELDS.
* If a body field is absent or null/undefined, it is replaced with an empty string.
*
* @param value - The string value to process
* @param body - The request body object
* @returns The processed string with placeholders replaced
*/
function processBodyPlaceholders(value: string, body: RequestBody): string {
for (const field of ALLOWED_BODY_FIELDS) {
const placeholder = `{{LIBRECHAT_BODY_${field.toUpperCase()}}}`;
if (!value.includes(placeholder)) {
continue;
}
const fieldValue = body[field];
const replacementValue = fieldValue == null ? '' : String(fieldValue);
value = value.replace(new RegExp(placeholder, 'g'), replacementValue);
}
return value;
}
/**
* Processes a single string value by replacing various types of placeholders
* @param originalValue - The original string value to process
* @param customUserVars - Optional custom user variables to replace placeholders
* @param user - Optional user object for replacing user field placeholders
* @param body - Optional request body object for replacing body field placeholders
* @returns The processed string with all placeholders replaced
*/
function processSingleValue({
originalValue,
customUserVars,
user,
body = undefined,
}: {
originalValue: string;
customUserVars?: Record<string, string>;
user?: TUser;
body?: RequestBody;
}): string {
let value = originalValue;
@@ -92,7 +125,12 @@ function processSingleValue({
// 2. Replace user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}}, {{LIBRECHAT_USER_ID}})
value = processUserPlaceholders(value, user);
// 3. Replace system environment variables
// 3. Replace body field placeholders (e.g., {{LIBRECHAT_BODY_CONVERSATIONID}}, {{LIBRECHAT_BODY_PARENTMESSAGEID}})
if (body) {
value = processBodyPlaceholders(value, body);
}
// 4. Replace system environment variables
value = extractEnvVariable(value);
return value;
@@ -103,12 +141,14 @@ function processSingleValue({
* @param obj - The object to process
* @param user - The user object containing all user fields
* @param customUserVars - vars that user set in settings
* @param body - the body of the request that is being processed
* @returns - The processed object with environment variables replaced
*/
export function processMCPEnv(
obj: Readonly<MCPOptions>,
user?: TUser,
customUserVars?: Record<string, string>,
body?: RequestBody,
): MCPOptions {
if (obj === null || obj === undefined) {
return obj;
@@ -119,7 +159,7 @@ export function processMCPEnv(
if ('env' in newObj && newObj.env) {
const processedEnv: Record<string, string> = {};
for (const [key, originalValue] of Object.entries(newObj.env)) {
processedEnv[key] = processSingleValue({ originalValue, customUserVars, user });
processedEnv[key] = processSingleValue({ originalValue, customUserVars, user, body });
}
newObj.env = processedEnv;
}
@@ -127,7 +167,7 @@ export function processMCPEnv(
if ('args' in newObj && newObj.args) {
const processedArgs: string[] = [];
for (const originalValue of newObj.args) {
processedArgs.push(processSingleValue({ originalValue, customUserVars, user }));
processedArgs.push(processSingleValue({ originalValue, customUserVars, user, body }));
}
newObj.args = processedArgs;
}
@@ -137,39 +177,47 @@ export function processMCPEnv(
if ('headers' in newObj && newObj.headers) {
const processedHeaders: Record<string, string> = {};
for (const [key, originalValue] of Object.entries(newObj.headers)) {
processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user });
processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user, body });
}
newObj.headers = processedHeaders;
}
// Process URL if it exists (for WebSocket, SSE, StreamableHTTP types)
if ('url' in newObj && newObj.url) {
newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user });
newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user, body });
}
return newObj;
}
/**
* Resolves header values by replacing user placeholders, custom variables, and environment variables
* @param headers - The headers object to process
* @param user - Optional user object for replacing user field placeholders (can be partial with just id)
* @param customUserVars - Optional custom user variables to replace placeholders
* @returns - The processed headers with all placeholders replaced
* Resolves header values by replacing user placeholders, body variables, custom variables, and environment variables.
*
* @param options - Optional configuration object.
* @param options.headers - The headers object to process.
* @param options.user - Optional user object for replacing user field placeholders (can be partial with just id).
* @param options.body - Optional request body object for replacing body field placeholders.
* @param options.customUserVars - Optional custom user variables to replace placeholders.
* @returns The processed headers with all placeholders replaced.
*/
export function resolveHeaders(
headers: Record<string, string> | undefined,
user?: Partial<TUser> | { id: string },
customUserVars?: Record<string, string>,
) {
const resolvedHeaders = { ...(headers ?? {}) };
export function resolveHeaders(options?: {
headers: Record<string, string> | undefined;
user?: Partial<TUser> | { id: string };
body?: RequestBody;
customUserVars?: Record<string, string>;
}) {
const { headers, user, body, customUserVars } = options ?? {};
const inputHeaders = headers ?? {};
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
Object.keys(headers).forEach((key) => {
const resolvedHeaders: Record<string, string> = { ...inputHeaders };
if (inputHeaders && typeof inputHeaders === 'object' && !Array.isArray(inputHeaders)) {
Object.keys(inputHeaders).forEach((key) => {
resolvedHeaders[key] = processSingleValue({
originalValue: headers[key],
originalValue: inputHeaders[key],
customUserVars,
user: user as TUser,
body,
});
});
}

View File

@@ -27,6 +27,7 @@ export * from './types/mutations';
export * from './types/queries';
export * from './types/runs';
export * from './types/web';
export * from './types/http';
/* query/mutation keys */
export * from './keys';
/* api call helpers */

View File

@@ -0,0 +1,6 @@
export interface RequestBody {
parentMessageId: string;
messageId: string;
conversationId?: string;
[key: string]: unknown;
}