diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 89422b2eaa..d12f365d92 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -33,6 +33,7 @@ const { resolveAgentScopedSkillIds, createOpenAIContentAggregator, isChatCompletionValidationFailure, + applyMessagePiiRedactionToMessages, } = require('@librechat/api'); const { buildSummarizationHandlers, @@ -177,6 +178,30 @@ const OpenAIChatCompletionController = async (req, res) => { ); } + // Apply messagePiiFilter to user messages before any downstream + // work (formatting, run creation, LLM dispatch, Langfuse trace). The + // chat endpoint pre-redacts in api/server/controllers/agents/request.js; + // this is the same guarantee for OpenAI-compatible API clients. + const piiConfig = appConfig?.messagePiiFilter; + if (piiConfig != null && typeof piiConfig.onMatch === 'string') { + const { matches } = applyMessagePiiRedactionToMessages(request.messages, piiConfig); + if (matches.length > 0) { + const labels = matches.map((m) => m.patternLabel).join(', '); + if (piiConfig.onMatch === 'block') { + return sendErrorResponse( + res, + 400, + `Message blocked by PII filter: ${labels}. Edit and retry.`, + 'invalid_request_error', + 'message_pii_filter_block', + ); + } + logger.info( + `[messagePiiFilter] redacted ${matches.length} match(es) on OpenAI API (mode=${piiConfig.onMatch}, patterns=${matches.map((m) => m.patternId).join(',')})`, + ); + } + } + const responseId = `chatcmpl-${nanoid()}`; const created = Math.floor(Date.now() / 1000); diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 9022c1e6f0..851b4b1013 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -41,6 +41,7 @@ const { sendResponsesErrorResponse, createResponsesEventHandlers, createAggregatorEventHandlers, + applyMessagePiiRedactionToMessages, } = require('@librechat/api'); const { createResponsesToolEndCallback, @@ -575,6 +576,30 @@ const createResponse = async (req, res) => { typeof request.input === 'string' ? request.input : request.input, ); + // Apply messagePiiFilter to user input before saving, formatting, + // or dispatching to the model. The chat endpoint pre-redacts in + // api/server/controllers/agents/request.js; this is the same + // guarantee for Responses API clients. + const piiConfig = appConfig?.messagePiiFilter; + if (piiConfig != null && typeof piiConfig.onMatch === 'string') { + const { matches } = applyMessagePiiRedactionToMessages(inputMessages, piiConfig); + if (matches.length > 0) { + const labels = matches.map((m) => m.patternLabel).join(', '); + if (piiConfig.onMatch === 'block') { + return sendResponsesErrorResponse( + res, + 400, + `Message blocked by PII filter: ${labels}. Edit and retry.`, + 'invalid_request', + 'message_pii_filter_block', + ); + } + logger.info( + `[messagePiiFilter] redacted ${matches.length} match(es) on Responses API (mode=${piiConfig.onMatch}, patterns=${matches.map((m) => m.patternId).join(',')})`, + ); + } + } + // Merge previous messages with new input const allMessages = [...previousMessages, ...inputMessages]; diff --git a/packages/api/src/agents/messagePiiFilter.ts b/packages/api/src/agents/messagePiiFilter.ts index aaad1b0557..1638820110 100644 --- a/packages/api/src/agents/messagePiiFilter.ts +++ b/packages/api/src/agents/messagePiiFilter.ts @@ -187,3 +187,56 @@ export function serializeMessagePiiFilterForClient( })), }; } + +/** + * Walk an OpenAI-style messages array and apply PII redaction to user + * message content in place. Used by the OpenAI-compatible and + * Responses agent controllers, which receive a messages array instead + * of the chat endpoint's single `req.body.text`. + * + * Handles both content shapes: plain strings and arrays of content + * parts (text, image_url, etc.). Only `text`-bearing parts are + * scrubbed. Mutates the array in place and returns the aggregated + * match set so the caller can choose the mode reaction (HTTP 400 for + * block, log for warn/silent). + */ +export function applyMessagePiiRedactionToMessages( + messages: Array<{ + role?: string; + content?: string | Array<{ type?: string; text?: string; [key: string]: unknown }>; + }>, + config: MessagePiiFilterConfig | undefined, +): { matches: PatternMatch[] } { + if (config == null || !Array.isArray(messages) || messages.length === 0) { + return { matches: [] }; + } + const aggregate = new Map(); + const accumulate = (text: string): string => { + const result = applyMessagePiiRedaction(text, config); + for (const m of result.matches) { + const prior = aggregate.get(m.patternId); + if (prior == null) { + aggregate.set(m.patternId, { ...m }); + } else { + prior.count += m.count; + } + } + return result.text; + }; + + for (const msg of messages) { + if (msg == null || msg.role !== 'user') { + continue; + } + if (typeof msg.content === 'string') { + msg.content = accumulate(msg.content); + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part != null && typeof part.text === 'string') { + part.text = accumulate(part.text); + } + } + } + } + return { matches: Array.from(aggregate.values()) }; +} diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 300120c3e4..6d09b7fcf0 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -927,36 +927,37 @@ class GenerationJobManagerClass { } } runtime.earlyEventBuffer = []; - } else if (this._isRedis && !options?.skipBufferReplay && jobData?.userMessage) { + } else if (this._isRedis && !options?.skipBufferReplay) { /** - * Cross-replica fallback: the created event was buffered on the generating - * instance and published via Redis pub/sub before this subscriber was active. - * Reconstruct from persisted metadata. Only fields stored by trackUserMessage() - * are available (messageId, parentMessageId, conversationId, text); - * sender/isCreatedByUser are invariant for user messages and added back here. + * Cross-replica fallback. Events emitted on the generating + * instance were buffered there and published via Redis pub/sub + * before this subscriber was active, so reconstruct from + * persisted metadata. The two reconstructions are independent + * because they are persisted at different points in the + * request lifecycle: `pii_matches` is written by the + * pre-redaction step in the request controller (before the + * agent run starts), while `userMessage` is written by the + * agent's `onStart` callback after the user message is + * created. A subscriber that connects between those two + * moments must still receive the pii_matches reconstruction + * even though `jobData.userMessage` is not yet set. */ - logger.debug( - `[GenerationJobManager] Cross-replica subscribe: emitting created event from metadata for ${streamId}`, - ); - const createdEvent: t.CreatedEvent = { - created: true, - message: { - ...jobData.userMessage, - sender: 'User', - isCreatedByUser: true, - }, - streamId, - }; - onChunk(createdEvent); - - /** - * Replay pii_matches from persisted metadata so the warn-mode - * toast still fires for subscribers that connect on a - * different replica than the one that emitted the live event. - * The same-replica path is already covered by earlyEventBuffer - * replay above; this branch only runs when no buffer exists. - */ - if (jobData.piiMatches && jobData.piiMatches.length > 0) { + if (jobData?.userMessage) { + logger.debug( + `[GenerationJobManager] Cross-replica subscribe: emitting created event from metadata for ${streamId}`, + ); + const createdEvent: t.CreatedEvent = { + created: true, + message: { + ...jobData.userMessage, + sender: 'User', + isCreatedByUser: true, + }, + streamId, + }; + onChunk(createdEvent); + } + if (jobData?.piiMatches && jobData.piiMatches.length > 0) { logger.debug( `[GenerationJobManager] Cross-replica subscribe: emitting pii_matches from metadata for ${streamId}`, ); diff --git a/packages/api/src/types/events.ts b/packages/api/src/types/events.ts index d068888b17..8d466094bd 100644 --- a/packages/api/src/types/events.ts +++ b/packages/api/src/types/events.ts @@ -46,4 +46,18 @@ export type FinalEvent = { error?: { message: string }; }; -export type ServerSentEvent = StreamEvent | CreatedEvent | FinalEvent; +/** + * Surfaces PII / credential pattern matches detected by the + * messagePiiFilter (server-side pre-redaction or agents-side hook). + * Frontend renders the configured `warn`-mode toast off this event. + */ +export type PiiMatchesEvent = { + type: 'pii_matches'; + matches: Array<{ + patternId: string; + patternLabel: string; + count: number; + }>; +}; + +export type ServerSentEvent = StreamEvent | CreatedEvent | FinalEvent | PiiMatchesEvent;