LibreChat/api/server/controllers/agents/openai.js
Dustin Healy 3f0a1ec8d9
🛡️ fix: Run message-filter PII patterns on a linear-time regex engine (ReDoS) (#14554)
* 🛡️ fix: Run message-filter PII patterns on a linear-time regex engine

The messageFilter.pii middleware compiled admin-configured customPatterns with the native RegExp engine and ran them synchronously against every message on the shared event loop, so a catastrophic-backtracking pattern such as (a+)+$ could stall the entire process (native RegExp takes tens of seconds at roughly 32 characters) and take the instance down for every user.

Compile these patterns with RE2JS, a linear-time RE2 port with no native addon, so catastrophic backtracking is impossible regardless of the pattern rather than something the code tries to detect. Patterns using features RE2 does not support, such as backreferences, fail to compile and are dropped and logged exactly as an invalid pattern already is. The filter only tests for a match, so this is a drop-in engine swap with no behavior change for valid patterns.

* 🛡️ fix: Reject RE2-incompatible messageFilter patterns at config load

The customPatterns regex was validated with native RegExp at config load, but the runtime now compiles it with a linear-time engine (RE2) that does not support backreferences or lookaround. Such a pattern passed validation, then failed to compile and was silently dropped at request time, quietly removing PII protection after upgrade.

Reject backreferences and lookaround during config validation with an explicit message, and document RE2 syntax in the example config instead of "JavaScript-flavor". The runtime engine remains the authoritative boundary and still drops-and-logs anything this load-time check misses.

* 🧹 test: Use direct MessageFilterPiiConfig annotations in the PII specs

The added ReDoS cases satisfy the exported MessageFilterPiiConfig type directly, so the `as unknown as` assertions were unnecessary. Annotate the config objects directly, matching the repo's type-safety guidance.

* 🧹 fix: Reject named backreferences in messageFilter patterns at config load

Extend the config-load check to also reject named backreferences (\k<name>), which are valid JavaScript regex but unsupported by the linear-time runtime engine, so they surface at load rather than being dropped at request time. Together with the existing numeric-backreference and lookaround checks this covers the RE2-incompatible construct set; the runtime engine remains authoritative.

* 🛡️ fix: Preserve Unicode whitespace matching in messageFilter starter patterns

RE2's \s is ASCII-only, so after the engine swap the built-in api-key and Bearer starters no
longer matched a secret separated by non-ASCII whitespace (e.g. a non-breaking space), which
native RegExp did match. Broaden the whitespace classes to [\s\p{Zs}] so those patterns keep
their original coverage, and add a regression test for a non-breaking-space separator.

* 🛡️ fix: Validate messageFilter patterns with the RE2 engine at config load

Replace the syntax blacklist (numeric/named backreferences, lookaround) with authoritative
validation: config load now compiles each custom pattern with the same linear-time engine the
runtime uses, so any RE2-incompatible construct (including control escapes like \cA) is rejected
at load with a clear error instead of being silently dropped at request time.

The validator is swappable and defaults to native RegExp so browser builds add no engine; the
server wires the RE2-backed check at startup via configureMessageFilterRegexValidator in both
entry points.

* 🛡️ fix: Match the full whitespace set in messageFilter starter patterns

RE2's `\s` omits the vertical tab and `\p{Zs}` omits U+2028, U+2029, and
U+FEFF, so a separator built from one of those characters slipped past the
`api-key` and `Bearer` starter patterns and reached the model. Broaden the
starter whitespace class to the full JavaScript whitespace set so those
separators are covered again.

* fix: fail closed when messageFilter.pii compiles to zero patterns

DB and admin config overrides bypass the RE2 schema validation (it only
runs at YAML load), so an override whose only pattern is RE2-incompatible
was dropped at compile time, left zero patterns, and let the request
through. compile() now returns a failClosed flag when a config declared
patterns but every one failed to compile; the middleware returns 400 and
findPiiMatchInMessages returns a distinct misconfigured match that the
OpenAI and Responses controllers surface with an admin-facing message.

* 🛡️ fix: Fail closed when any messageFilter.pii custom pattern drops

compile() previously set failClosed only when every pattern dropped (patterns.length === 0 && dropped > 0). With the default starters present, a single RE2-incompatible custom override incremented dropped but left patterns.length > 0, so the filter silently enforced only the surviving subset and text matching only the dropped rule passed.

failClosed now keys off dropped > 0, so any dropped custom pattern blocks with the misconfigured 400. YAML patterns are RE2-validated at load, so dropped stays 0 for valid configs and only unvalidated DB or admin overrides can trip it. Reframed the two keeps-others-active specs to assert fail-closed and added a default-starters partial-drop regression.

* 🧹 fix: Correct the misconfigured JSDoc and drop redundant casts in the PII specs

The misconfigured flag now means any configured custom pattern failed to compile, not that every pattern failed, so its JSDoc on PiiMatch is updated to match. The partial-drop regressions now use direct MessageFilterPiiConfig annotations instead of as-unknown-as casts, keeping the specs type-checked, consistent with the rest of the suite.
2026-08-05 13:42:18 -04:00

1051 lines
34 KiB
JavaScript

const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents');
const {
EModelEndpoint,
ResourceType,
PermissionBits,
hasPermissions,
AgentCapabilities,
} = require('librechat-data-provider');
const {
writeSSE,
createRun,
createChunk,
buildToolSet,
AgentRunEnvelopeError,
createAgentRunEnvelope,
loadSkillStates,
sendFinalChunk,
createSafeUser,
validateRequest,
initializeAgent,
getBalanceConfig,
injectSkillPrimes,
extractManualSkills,
createErrorResponse,
recordCollectedUsage,
createSubagentUsageSink,
getTransactionsConfig,
resolveRecursionLimit,
findPiiMatchInMessages,
discoverConnectedAgents,
getRemoteAgentPermissions,
createToolExecuteHandler,
buildNonStreamingResponse,
createOpenAIStreamTracker,
resolveAgentScopedSkillIds,
createOpenAIContentAggregator,
isChatCompletionValidationFailure,
stripActivityLabelParts,
} = require('@librechat/api');
const {
buildSummarizationHandlers,
markSummarizationUsage,
createToolEndCallback,
agentLogHandlerObj,
} = require('~/server/controllers/agents/callbacks');
const {
loadAgentTools,
loadToolsForExecution,
getAccessibleMcpServerNames,
} = require('~/server/services/ToolService');
const {
findAccessibleResources,
getEffectivePermissions,
} = require('~/server/services/PermissionService');
const {
getSkillToolDeps,
getSkillDbMethods,
canAuthorSkillFiles,
withDeploymentSkillIds,
buildAgentToolContext,
enrichLoadedToolsWithAgentContext,
} = require('~/server/services/Endpoints/agents/skillDeps');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { logViolation } = require('~/cache');
const db = require('~/models');
const filterFilesByRemoteAgentAccess = (params) =>
filterFilesByAgentAccess({ ...params, resourceType: ResourceType.REMOTE_AGENT });
/**
* Creates a tool loader function for the agent.
* @param {AbortSignal} signal - The abort signal
* @param {boolean} [definitionsOnly=true] - When true, returns only serializable
* tool definitions without creating full tool instances (for event-driven mode)
*/
function createToolLoader(signal, definitionsOnly = true) {
return async function loadTools({
req,
res,
tools,
model,
agentId,
provider,
tool_options,
tool_resources,
accessibleMcpServerNames,
}) {
const agent = { id: agentId, tools, provider, model, tool_options };
try {
return await loadAgentTools({
req,
res,
agent,
signal,
tool_resources,
agentResourceType: ResourceType.REMOTE_AGENT,
definitionsOnly,
accessibleMcpServerNames,
streamId: null, // No resumable stream for OpenAI compat
});
} catch (error) {
logger.error('Error loading tools for agent ' + agentId, error);
}
};
}
/**
* Convert content part to internal format
* @param {Object} part - Content part
* @returns {Object} Converted part
*/
function convertContentPart(part) {
if (part.type === 'text') {
return { type: 'text', text: part.text };
}
if (part.type === 'image_url') {
return { type: 'image_url', image_url: part.image_url };
}
return part;
}
/**
* Convert OpenAI messages to internal format
* @param {Array} messages - OpenAI format messages
* @returns {Array} Internal format messages
*/
function convertMessages(messages) {
return messages.map((msg) => {
let content;
if (typeof msg.content === 'string') {
content = msg.content;
} else if (msg.content) {
content = msg.content.map(convertContentPart);
} else {
content = '';
}
return {
role: msg.role,
content,
...(msg.name && { name: msg.name }),
...(msg.tool_calls && { tool_calls: msg.tool_calls }),
...(msg.tool_call_id && { tool_call_id: msg.tool_call_id }),
};
});
}
/**
* Send an error response in OpenAI format
*/
function sendErrorResponse(res, statusCode, message, type = 'invalid_request_error', code = null) {
res.status(statusCode).json(createErrorResponse(message, type, code));
}
/**
* Runs a validated chat-completions envelope in the current process.
* Express remains runtime-only state while the envelope is the portable run input.
*
* @param {import('@librechat/api').ChatCompletionRunEnvelope} envelope
* @param {{req: import('express').Request, res: import('express').Response}} runtime
*/
const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
const appConfig = req.config;
const requestStartTime = envelope.receivedAt;
const request = envelope.payload;
const { principal } = envelope;
// The local executor keeps the current Express-dependent initialization path,
// but all request-body reads now observe the detached envelope payload.
req.body = request;
const agentId = request.model;
// Look up the agent
const agent = await db.getAgent({ id: agentId });
if (!agent) {
return sendErrorResponse(
res,
404,
`Agent not found: ${agentId}`,
'invalid_request_error',
'model_not_found',
);
}
const piiHit = findPiiMatchInMessages(request.messages, appConfig?.messageFilter?.pii);
if (piiHit != null) {
return sendErrorResponse(
res,
400,
piiHit.misconfigured
? 'Message filtering is misconfigured; contact your administrator.'
: `Message contains a ${piiHit.label}. Remove it and try again.`,
'invalid_request_error',
'message_filter_pii_block',
);
}
const responseId = `chatcmpl-${nanoid()}`;
const created = Math.floor(Date.now() / 1000);
/** @type {import('@librechat/api').OpenAIResponseContext} — key must be `requestId` to match the type used by createChunk/buildNonStreamingResponse */
const context = {
created,
requestId: responseId,
model: agentId,
};
logger.debug(
`[OpenAI API] Response ${responseId} started for agent ${agentId}, stream: ${request.stream}`,
);
// Set up abort controller
const abortController = new AbortController();
// Handle client disconnect
req.on('close', () => {
if (!abortController.signal.aborted) {
abortController.abort();
logger.debug('[OpenAI API] Client disconnected, aborting');
}
});
try {
if (request.conversation_id != null) {
if (typeof request.conversation_id !== 'string') {
return sendErrorResponse(
res,
400,
'conversation_id must be a string',
'invalid_request_error',
);
}
if (!(await db.getConvo(principal.userId, request.conversation_id))) {
return sendErrorResponse(res, 404, 'Conversation not found', 'invalid_request_error');
}
}
const conversationId = request.conversation_id ?? nanoid();
const parentMessageId = request.parent_message_id ?? null;
const agentsEConfig = appConfig?.endpoints?.[EModelEndpoint.agents];
const allowedProviders = new Set(agentsEConfig?.allowedProviders);
// Create tool loader
const loadTools = createToolLoader(abortController.signal);
// Initialize the agent first to check for disableStreaming
const endpointOption = {
endpoint: agent.provider,
model_parameters: agent.model_parameters ?? {},
};
const skillDbMethods = getSkillDbMethods();
const dbMethods = {
getConvoFiles: db.getConvoFiles,
getFiles: db.getFiles,
filterFilesByAgentAccess: filterFilesByRemoteAgentAccess,
getUserKey: db.getUserKey,
getMessages: db.getMessages,
getAccessibleMcpServerNames,
updateFilesUsage: db.updateFilesUsage,
getUserKeyValues: db.getUserKeyValues,
getUserCodeFiles: db.getUserCodeFiles,
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
};
const enabledCapabilities = new Set(agentsEConfig?.capabilities);
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true;
const accessibleSkillIds = skillsCapabilityEnabled
? withDeploymentSkillIds(
await findAccessibleResources({
userId: principal.userId,
role: principal.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
}),
)
: [];
const editableSkillIds = skillsCapabilityEnabled
? await findAccessibleResources({
userId: principal.userId,
role: principal.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.EDIT,
})
: [];
const skillCreateAllowed = skillsCapabilityEnabled
? await getSkillToolDeps().canCreateSkill({ req })
: false;
const { skillStates, defaultActiveOnShare } = await loadSkillStates({
userId: principal.userId,
appConfig,
getUserById: db.getUserById,
accessibleSkillIds,
});
const manualSkills = extractManualSkills(request);
const primaryScopedSkillIds = resolveAgentScopedSkillIds({
agent,
accessibleSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const primaryScopedEditableSkillIds = resolveAgentScopedSkillIds({
agent,
accessibleSkillIds: editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const primaryConfig = await initializeAgent(
{
req,
res,
loadTools,
requestFiles: [],
conversationId,
parentMessageId,
agent,
endpointOption,
allowedProviders,
isInitialAgent: true,
accessibleSkillIds: primaryScopedSkillIds,
skillAuthoringAvailable: canAuthorSkillFiles({
agent,
scopedEditableSkillIds: primaryScopedEditableSkillIds,
skillCreateAllowed,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
statefulSessionsAvailable: enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
),
skillStates,
defaultActiveOnShare,
manualSkills,
},
dbMethods,
);
/**
* Per-agent tool-execution context map, keyed by agentId.
* Needed so the ON_TOOL_EXECUTE callback routes each sub-agent's tool calls
* to the correct toolRegistry / userMCPAuthMap / tool_resources.
* @type {Map<string, {
* agent: object,
* toolRegistry?: import('@librechat/agents').LCToolRegistry,
* requestScopedConnections?: import('@librechat/api').RequestScopedMCPConnectionStore,
* userMCPAuthMap?: Record<string, Record<string, string>>,
* tool_resources?: object,
* actionsEnabled?: boolean,
* }>}
*/
const agentToolContexts = new Map();
agentToolContexts.set(
primaryConfig.id,
buildAgentToolContext({ agent, config: primaryConfig }),
);
// Only run BFS discovery (and pay `getModelsConfig` upfront) when the
// primary has edges to follow — the common API case is single-agent.
let handoffAgentConfigs = new Map();
let discoveredEdges = [];
let discoveredMCPAuthMap;
if (primaryConfig.edges?.length) {
const modelsConfig = await getModelsConfig(req);
({
agentConfigs: handoffAgentConfigs,
edges: discoveredEdges,
userMCPAuthMap: discoveredMCPAuthMap,
} = await discoverConnectedAgents(
{
req,
res,
primaryConfig,
endpointOption,
allowedProviders,
modelsConfig,
loadTools,
requestFiles: [],
conversationId,
parentMessageId,
// The route enforces REMOTE_AGENT on the primary; every discovered
// sub-agent must clear the same sharing boundary, not the looser
// in-app AGENT one.
resourceType: ResourceType.REMOTE_AGENT,
computeAccessibleSkillIds: (handoffAgent) =>
resolveAgentScopedSkillIds({
agent: handoffAgent,
accessibleSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
computeSkillAuthoringAvailable: (handoffAgent) =>
canAuthorSkillFiles({
agent: handoffAgent,
scopedEditableSkillIds: resolveAgentScopedSkillIds({
agent: handoffAgent,
accessibleSkillIds: editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
skillCreateAllowed,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
skillStates,
defaultActiveOnShare,
/** @see DiscoverConnectedAgentsParams.codeEnvAvailable */
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
statefulSessionsAvailable: enabledCapabilities.has(
AgentCapabilities.stateful_code_sessions,
),
},
{
getAgent: db.getAgent,
// Use `getRemoteAgentPermissions` so sub-agent authorization
// matches what the route's `createCheckRemoteAgentAccess`
// middleware does for the primary: AGENT owners with the SHARE
// bit are treated as remotely authorized even without an
// explicit REMOTE_AGENT grant.
checkPermission: async ({ userId, role, resourceId, requiredPermission }) => {
const permissions = await getRemoteAgentPermissions(
{ getEffectivePermissions },
userId,
role,
resourceId,
);
return hasPermissions(permissions, requiredPermission);
},
logViolation,
db: dbMethods,
onAgentInitialized: (agentId, handoffAgent, config) => {
agentToolContexts.set(agentId, buildAgentToolContext({ agent: handoffAgent, config }));
},
initializeAgent,
},
));
}
primaryConfig.edges = discoveredEdges;
// Determine if streaming is enabled (check both request and agent config)
const streamingDisabled = !!primaryConfig.model_parameters?.disableStreaming;
const isStreaming = request.stream === true && !streamingDisabled;
// Create tracker for streaming or aggregator for non-streaming
const tracker = isStreaming ? createOpenAIStreamTracker() : null;
const aggregator = isStreaming ? null : createOpenAIContentAggregator();
// Set up response for streaming
if (isStreaming) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// Send initial chunk with role
const initialChunk = createChunk(context, { role: 'assistant' });
writeSSE(res, initialChunk);
}
// Create handler config for OpenAI streaming (only used when streaming)
const handlerConfig = isStreaming
? {
res,
context,
tracker,
}
: null;
const collectedUsage = [];
/** @type {Promise<import('librechat-data-provider').TAttachment | null>[]} */
const artifactPromises = [];
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null });
/* Stable for the turn: the primary prime list is fixed once
`initializeAgent` resolves and is used as the fallback when a
specific agent context is unavailable. `codeEnvAvailable` is read
per-agent from the stored tool context (admin cap AND that
agent's `tools` list includes `execute_code`) — a skills-only
agent never gains sandbox access even if the admin enabled the
capability globally. */
const toolExecuteOptions = {
loadTools: async (toolNames, agentId) => {
const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
const result = await loadToolsForExecution({
req,
res,
agentResourceType: ResourceType.REMOTE_AGENT,
toolNames,
agent: ctx.agent ?? agent,
signal: abortController.signal,
toolRegistry: ctx.toolRegistry,
backgroundToolNames: ctx.backgroundToolNames,
intentToolNames: ctx.intentToolNames,
mcpAvailableTools: ctx.mcpAvailableTools,
requestScopedConnections: ctx.requestScopedConnections,
userMCPAuthMap: ctx.userMCPAuthMap,
tool_resources: ctx.tool_resources,
actionsEnabled: ctx.actionsEnabled,
accessibleMcpServerNames: ctx.accessibleMcpServerNames,
});
return enrichLoadedToolsWithAgentContext({
result,
req,
ctx,
});
},
toolEndCallback,
...getSkillToolDeps(),
};
const summarizationConfig = appConfig?.summarization;
const openaiMessages = convertMessages(request.messages);
const toolSet = buildToolSet(primaryConfig);
const formatted = formatAgentMessages(stripActivityLabelParts(openaiMessages), {}, toolSet);
const formattedMessages = formatted.messages;
const initialSummary = formatted.summary;
let indexTokenCountMap = formatted.indexTokenCountMap;
/**
* Inject manual + always-apply skill primes so the model sees SKILL.md
* bodies for this turn — parity with AgentClient's chat path. OpenAI-
* compatible streaming uses its own tracker/aggregator shape, so the
* LibreChat-style card SSE events don't apply here; only the
* message-context part carries over.
*/
const manualSkillPrimes = primaryConfig.manualSkillPrimes;
const alwaysApplySkillPrimes = primaryConfig.alwaysApplySkillPrimes;
if (
(manualSkillPrimes && manualSkillPrimes.length > 0) ||
(alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0)
) {
const primeResult = injectSkillPrimes({
initialMessages: formattedMessages,
indexTokenCountMap,
manualSkillPrimes,
alwaysApplySkillPrimes,
});
indexTokenCountMap = primeResult.indexTokenCountMap;
/* Surface the cap-driven always-apply truncation at the controller
layer too — `injectSkillPrimes` already logs internally, but the
controller-level warn includes endpoint context so operators can
tell at a glance which path hit the cap. Mirrors AgentClient's
warn in `client.js`. */
if (primeResult.alwaysApplyDropped > 0) {
logger.warn(
`[OpenAI API] Dropped ${primeResult.alwaysApplyDropped} always-apply prime(s) to stay within MAX_PRIMED_SKILLS_PER_TURN.`,
);
}
}
/**
* Create a simple handler that processes data
*/
const createHandler = (processor) => ({
handle: (_event, data) => {
if (processor) {
processor(data);
}
},
});
/**
* Stream text content in OpenAI format
*/
const streamText = (text) => {
if (!text) {
return;
}
if (isStreaming) {
tracker.addText();
writeSSE(res, createChunk(context, { content: text }));
} else {
aggregator.addText(text);
}
};
/**
* Stream reasoning content in OpenAI format (OpenRouter convention)
*/
const streamReasoning = (text) => {
if (!text) {
return;
}
if (isStreaming) {
tracker.addReasoning();
writeSSE(res, createChunk(context, { reasoning: text }));
} else {
aggregator.addReasoning(text);
}
};
// Event handlers for OpenAI-compatible streaming
const handlers = {
// Text content streaming
on_message_delta: createHandler((data) => {
const content = data?.delta?.content;
if (Array.isArray(content)) {
for (const part of content) {
if (part.type === 'text' && part.text) {
streamText(part.text);
}
}
}
}),
// Reasoning/thinking content streaming
on_reasoning_delta: createHandler((data) => {
const content = data?.delta?.content;
if (Array.isArray(content)) {
for (const part of content) {
const text = part.think || part.text;
if (text) {
streamReasoning(text);
}
}
}
}),
// Tool call initiation - streams id and name (from on_run_step)
on_run_step: createHandler((data) => {
const stepDetails = data?.stepDetails;
if (stepDetails?.type === 'tool_calls' && stepDetails.tool_calls) {
for (const tc of stepDetails.tool_calls) {
const toolIndex = data.index ?? 0;
const toolId = tc.id ?? '';
const toolName = tc.name ?? '';
const toolCall = {
id: toolId,
type: 'function',
function: { name: toolName, arguments: '' },
};
// Track tool call in tracker or aggregator
if (isStreaming) {
if (!tracker.toolCalls.has(toolIndex)) {
tracker.toolCalls.set(toolIndex, toolCall);
}
// Stream initial tool call chunk (like OpenAI does)
writeSSE(
res,
createChunk(context, {
tool_calls: [{ index: toolIndex, ...toolCall }],
}),
);
} else {
if (!aggregator.toolCalls.has(toolIndex)) {
aggregator.toolCalls.set(toolIndex, toolCall);
}
}
}
}
}),
// Tool call argument streaming (from on_run_step_delta)
on_run_step_delta: createHandler((data) => {
const delta = data?.delta;
if (delta?.type === 'tool_calls' && delta.tool_calls) {
for (const tc of delta.tool_calls) {
const args = tc.args ?? '';
if (!args) {
continue;
}
const toolIndex = tc.index ?? 0;
// Update tool call arguments
const targetMap = isStreaming ? tracker.toolCalls : aggregator.toolCalls;
const tracked = targetMap.get(toolIndex);
if (tracked) {
tracked.function.arguments += args;
}
// Stream argument delta (only for streaming)
if (isStreaming) {
writeSSE(
res,
createChunk(context, {
tool_calls: [
{
index: toolIndex,
function: { arguments: args },
},
],
}),
);
}
}
}
}),
// Usage tracking
on_chat_model_end: {
handle: (_event, data, metadata) => {
const usage = data?.output?.usage_metadata;
if (usage) {
const taggedUsage = markSummarizationUsage(usage, metadata);
collectedUsage.push(taggedUsage);
const target = isStreaming ? tracker : aggregator;
target.usage.promptTokens += taggedUsage.input_tokens ?? 0;
target.usage.completionTokens += taggedUsage.output_tokens ?? 0;
}
},
},
on_run_step_completed: createHandler(),
// Use proper ToolEndHandler for processing artifacts (images, file citations, code output)
on_tool_end: new ToolEndHandler(toolEndCallback, logger),
on_chain_stream: createHandler(),
on_chain_end: createHandler(),
on_agent_update: createHandler(),
on_agent_log: agentLogHandlerObj,
on_custom_event: createHandler(),
on_tool_execute: createToolExecuteHandler(toolExecuteOptions),
...(summarizationConfig?.enabled !== false
? buildSummarizationHandlers({ isStreaming, res })
: {}),
};
// Create and run the agent
const userId = principal.userId;
// Extract merged userMCPAuthMap (needed for MCP tool connections across
// the primary and any discovered handoff sub-agents)
const userMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap;
const runAgents = [primaryConfig, ...handoffAgentConfigs.values()];
const run = await createRun({
agents: runAgents,
messages: formattedMessages,
indexTokenCountMap,
initialSummary,
runId: responseId,
summarizationConfig,
appConfig,
signal: abortController.signal,
customHandlers: handlers,
requestBody: {
messageId: responseId,
conversationId,
},
user: { id: userId },
tenantId: principal.tenantId,
/** Bills subagent child-run model calls (reported outside the
* streamEvents loop) into the same collectedUsage array. */
subagentUsageSink: createSubagentUsageSink(collectedUsage),
});
if (!run) {
throw new Error('Failed to create agent run');
}
const config = {
runName: 'AgentRun',
configurable: {
thread_id: conversationId,
user_id: userId,
user: createSafeUser(req.user),
requestBody: {
messageId: responseId,
conversationId,
},
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
recursionLimit: resolveRecursionLimit(agentsEConfig, agent),
signal: abortController.signal,
streamMode: 'values',
version: 'v2',
};
await run.processStream({ messages: formattedMessages }, config, {
callbacks: {
[Callback.TOOL_ERROR]: (graph, error, toolId) => {
logger.error(`[OpenAI API] Tool Error "${toolId}"`, error);
},
},
});
// Record token usage against balance
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
recordCollectedUsage(
{
spendTokens: db.spendTokens,
spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
},
{
user: userId,
conversationId,
collectedUsage,
context: 'message',
messageId: responseId,
balance: balanceConfig,
transactions: transactionsConfig,
model: primaryConfig.model || agent.model_parameters?.model,
},
).catch((err) => {
logger.error('[OpenAI API] Error recording usage:', err);
});
// Finalize response
const duration = Date.now() - requestStartTime;
if (isStreaming) {
sendFinalChunk(handlerConfig);
res.end();
logger.debug(`[OpenAI API] Response ${responseId} completed in ${duration}ms (streaming)`);
// Wait for artifact processing after response ends (non-blocking)
if (artifactPromises.length > 0) {
Promise.all(artifactPromises).catch((artifactError) => {
logger.warn('[OpenAI API] Error processing artifacts:', artifactError);
});
}
} else {
// For non-streaming, wait for artifacts before sending response
if (artifactPromises.length > 0) {
try {
await Promise.all(artifactPromises);
} catch (artifactError) {
logger.warn('[OpenAI API] Error processing artifacts:', artifactError);
}
}
// Build usage from aggregated data
const usage = {
prompt_tokens: aggregator.usage.promptTokens,
completion_tokens: aggregator.usage.completionTokens,
total_tokens: aggregator.usage.promptTokens + aggregator.usage.completionTokens,
};
if (aggregator.usage.reasoningTokens > 0) {
usage.completion_tokens_details = {
reasoning_tokens: aggregator.usage.reasoningTokens,
};
}
const response = buildNonStreamingResponse(
context,
aggregator.getText(),
aggregator.getReasoning(),
aggregator.toolCalls,
usage,
);
res.json(response);
logger.debug(
`[OpenAI API] Response ${responseId} completed in ${duration}ms (non-streaming)`,
);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An error occurred';
logger.error('[OpenAI API] Error:', error);
// Check if we already started streaming (headers sent)
if (res.headersSent) {
// Headers already sent, send error in stream
const errorChunk = createChunk(context, { content: `\n\nError: ${errorMessage}` }, 'stop');
writeSSE(res, errorChunk);
writeSSE(res, '[DONE]');
res.end();
} else {
// Forward upstream provider status codes (e.g., Anthropic 400s) instead of masking as 500
const statusCode =
typeof error?.status === 'number' && error.status >= 400 && error.status < 600
? error.status
: 500;
const errorType =
statusCode >= 400 && statusCode < 500 ? 'invalid_request_error' : 'server_error';
sendErrorResponse(res, statusCode, errorMessage, errorType);
}
}
};
/**
* OpenAI-compatible chat completions ingress adapter for agents.
* Authentication and remote-agent authorization have already run in route middleware.
*
* POST /v1/chat/completions
*/
const OpenAIChatCompletionController = async (req, res) => {
const receivedAt = Date.now();
const validation = validateRequest(req.body);
if (isChatCompletionValidationFailure(validation)) {
return sendErrorResponse(res, 400, validation.error);
}
let envelope;
try {
envelope = createAgentRunEnvelope({
protocol: 'chat.completions',
requestId: req.requestId ?? req.id ?? `agent-run-${nanoid()}`,
receivedAt,
principal: req.user,
payload: validation.request,
});
} catch (error) {
if (error instanceof AgentRunEnvelopeError) {
return sendErrorResponse(res, 400, error.message, 'invalid_request_error');
}
throw error;
}
return executeOpenAIChatCompletion(envelope, { req, res });
};
/**
* List available agents as models (filtered by remote access permissions)
*
* GET /v1/models
*/
const ListModelsController = async (req, res) => {
try {
const userId = req.user?.id;
const userRole = req.user?.role;
if (!userId) {
return sendErrorResponse(res, 401, 'Authentication required', 'auth_error');
}
// Find agents the user has remote access to (VIEW permission on REMOTE_AGENT)
const accessibleAgentIds = await findAccessibleResources({
userId,
role: userRole,
resourceType: ResourceType.REMOTE_AGENT,
requiredPermissions: PermissionBits.VIEW,
});
// Get the accessible agents
let agents = [];
if (accessibleAgentIds.length > 0) {
agents = await db.getAgents({ _id: { $in: accessibleAgentIds } });
}
const models = agents.map((agent) => ({
id: agent.id,
object: 'model',
created: Math.floor(new Date(agent.createdAt || Date.now()).getTime() / 1000),
owned_by: 'librechat',
permission: [],
root: agent.id,
parent: null,
// LibreChat extensions
name: agent.name,
description: agent.description,
provider: agent.provider,
}));
res.json({
object: 'list',
data: models,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to list models';
logger.error('[OpenAI API] Error listing models:', error);
sendErrorResponse(res, 500, errorMessage, 'server_error');
}
};
/**
* Get a specific model/agent (with remote access permission check)
*
* GET /v1/models/:model
*/
const GetModelController = async (req, res) => {
try {
const { model } = req.params;
const userId = req.user?.id;
const userRole = req.user?.role;
if (!userId) {
return sendErrorResponse(res, 401, 'Authentication required', 'auth_error');
}
const agent = await db.getAgent({ id: model });
if (!agent) {
return sendErrorResponse(
res,
404,
`Model not found: ${model}`,
'invalid_request_error',
'model_not_found',
);
}
// Check if user has remote access to this agent
const accessibleAgentIds = await findAccessibleResources({
userId,
role: userRole,
resourceType: ResourceType.REMOTE_AGENT,
requiredPermissions: PermissionBits.VIEW,
});
const hasAccess = accessibleAgentIds.some((id) => id.toString() === agent._id.toString());
if (!hasAccess) {
return sendErrorResponse(
res,
403,
`No remote access to model: ${model}`,
'permission_error',
'access_denied',
);
}
res.json({
id: agent.id,
object: 'model',
created: Math.floor(new Date(agent.createdAt || Date.now()).getTime() / 1000),
owned_by: 'librechat',
permission: [],
root: agent.id,
parent: null,
// LibreChat extensions
name: agent.name,
description: agent.description,
provider: agent.provider,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to get model';
logger.error('[OpenAI API] Error getting model:', error);
sendErrorResponse(res, 500, errorMessage, 'server_error');
}
};
module.exports = {
OpenAIChatCompletionController,
ListModelsController,
GetModelController,
};