diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index b6032530c9..ce89addce8 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1450,11 +1450,12 @@ class AgentClient extends BaseClient { }); } } catch (err) { - logger.error( - '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted', - err, - ); - if (!abortController.signal.aborted) { + if (abortController.signal.aborted) { + logger.debug( + '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted by user', + { conversationId: this.conversationId, name: err?.name, code: err?.code }, + ); + } else { logger.error( '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error type', err, diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index 35bd53c579..feed002b0e 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -15,6 +15,36 @@ const { sendError } = require('~/server/middleware/error'); const { abortRun } = require('./abortRun'); const db = require('~/models'); +/** + * @param {Error | unknown} error + * @returns {boolean} + */ +const isAbortError = (error) => { + const visited = new Set(); + let current = error; + + while (current && typeof current === 'object' && !visited.has(current)) { + visited.add(current); + + const errorName = current.name; + const errorCode = current.code; + const errorMessage = typeof current.message === 'string' ? current.message : ''; + + if ( + errorName === 'AbortError' || + errorCode === 'ABORT_ERR' || + errorMessage.includes('AbortError') || + /(?:operation|request|stream) was aborted/i.test(errorMessage) + ) { + return true; + } + + current = current.cause; + } + + return false; +}; + /** * Spend tokens for all models from collected usage. * This handles both sequential and parallel agent execution. @@ -200,18 +230,26 @@ const handleAbort = function () { * @returns {Promise} */ const handleAbortError = async (res, req, error, data) => { + const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data; + if (error?.message?.includes('base64')) { logger.error('[handleAbortError] Error in base64 encoding', { ...error, stack: smartTruncateText(error?.stack, 1000), message: truncateText(error.message, 350), }); + } else if (isAbortError(error)) { + logger.debug('[handleAbortError] AI response aborted by user', { + conversationId, + code: error?.code, + name: error?.name, + message: truncateText(error?.message ?? 'AbortError', 350), + }); } else { logger.error('[handleAbortError] AI response error; aborting request:', error); } - const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data; - if (error.stack && error.stack.includes('google')) { + if (error?.stack && error.stack.includes('google')) { logger.warn( `AI Response error for conversation ${conversationId} likely caused by Google censor/filter`, ); diff --git a/api/server/middleware/abortMiddleware.spec.js b/api/server/middleware/abortMiddleware.spec.js index a4ce85674b..06e434065a 100644 --- a/api/server/middleware/abortMiddleware.spec.js +++ b/api/server/middleware/abortMiddleware.spec.js @@ -73,7 +73,18 @@ jest.mock('./abortRun', () => ({ abortRun: jest.fn(), })); -const { spendCollectedUsage } = require('./abortMiddleware'); +const { logger } = require('@librechat/data-schemas'); +const { sendError } = require('~/server/middleware/error'); +const { handleAbortError, spendCollectedUsage } = require('./abortMiddleware'); + +const buildAbortRequest = () => ({ + body: { + model: 'gpt-4', + }, + user: { + id: 'user-123', + }, +}); describe('abortMiddleware - spendCollectedUsage', () => { beforeEach(() => { @@ -237,3 +248,65 @@ describe('abortMiddleware - spendCollectedUsage', () => { }); }); }); + +describe('abortMiddleware - handleAbortError', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + [ + 'native DOMException AbortError', + new DOMException('The operation was aborted', 'AbortError'), + 'AbortError', + ], + [ + 'wrapped AbortError message', + new Error('SSE stream disconnected: AbortError: The operation was aborted'), + 'Error', + ], + [ + 'cause-nested AbortError', + new Error('Request failed', { + cause: new DOMException('The operation was aborted', 'AbortError'), + }), + 'Error', + ], + ])('logs a %s as a debug event instead of an error', async (_label, error, name) => { + await handleAbortError({}, buildAbortRequest(), error, { + sender: 'AI', + conversationId: 'convo-123', + messageId: 'message-123', + parentMessageId: 'parent-123', + userMessageId: 'user-message-123', + }); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith('[handleAbortError] AI response aborted by user', { + conversationId: 'convo-123', + code: error.code, + name, + message: error.message, + }); + expect(sendError).toHaveBeenCalledTimes(1); + }); + + it('keeps unexpected generation errors classified as errors', async () => { + const error = new Error('Provider failed'); + + await handleAbortError({}, buildAbortRequest(), error, { + sender: 'AI', + conversationId: 'convo-123', + messageId: 'message-123', + parentMessageId: 'parent-123', + userMessageId: 'user-message-123', + }); + + expect(logger.error).toHaveBeenCalledWith( + '[handleAbortError] AI response error; aborting request:', + error, + ); + expect(logger.debug).not.toHaveBeenCalled(); + expect(sendError).toHaveBeenCalledTimes(1); + }); +});