mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-30 11:24:09 +00:00
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* fix: Demote user abort logging * fix: Handle abort causes * fix: Demote user-aborted agent completion to debug log The error users still saw originated in AgentClient's completion catch, which logged every caught error (including user aborts) at error level before checking the abort signal. Branch on abortController.signal.aborted so user-initiated aborts log at debug while real failures stay error-classified. Also give the handleAbortError it.each cases distinct titles.
312 lines
9.5 KiB
JavaScript
312 lines
9.5 KiB
JavaScript
/**
|
|
* Tests for abortMiddleware - spendCollectedUsage function
|
|
*
|
|
* This tests the token spending logic for abort scenarios,
|
|
* particularly for parallel agents (addedConvo) where multiple
|
|
* models need their tokens spent.
|
|
*
|
|
* spendCollectedUsage delegates to recordCollectedUsage from @librechat/api,
|
|
* passing pricing + bulkWriteOps deps, with context: 'abort'.
|
|
* After spending, it clears the collectedUsage array to prevent double-spending
|
|
* from the AgentClient finally block (which shares the same array reference).
|
|
*/
|
|
|
|
const mockSpendTokens = jest.fn().mockResolvedValue();
|
|
const mockSpendStructuredTokens = jest.fn().mockResolvedValue();
|
|
const mockRecordCollectedUsage = jest
|
|
.fn()
|
|
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
|
|
|
|
const mockGetMultiplier = jest.fn().mockReturnValue(1);
|
|
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
debug: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
info: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
countTokens: jest.fn().mockResolvedValue(100),
|
|
isEnabled: jest.fn().mockReturnValue(false),
|
|
sendEvent: jest.fn(),
|
|
GenerationJobManager: {
|
|
abortJob: jest.fn(),
|
|
},
|
|
recordCollectedUsage: mockRecordCollectedUsage,
|
|
sanitizeMessageForTransmit: jest.fn((msg) => msg),
|
|
}));
|
|
|
|
jest.mock('librechat-data-provider', () => ({
|
|
isAssistantsEndpoint: jest.fn().mockReturnValue(false),
|
|
ErrorTypes: { INVALID_REQUEST: 'INVALID_REQUEST', NO_SYSTEM_MESSAGES: 'NO_SYSTEM_MESSAGES' },
|
|
}));
|
|
|
|
jest.mock('~/app/clients/prompts', () => ({
|
|
truncateText: jest.fn((text) => text),
|
|
smartTruncateText: jest.fn((text) => text),
|
|
}));
|
|
|
|
jest.mock('~/cache/clearPendingReq', () => jest.fn().mockResolvedValue());
|
|
|
|
jest.mock('~/server/middleware/error', () => ({
|
|
sendError: jest.fn(),
|
|
}));
|
|
|
|
const mockUpdateBalance = jest.fn().mockResolvedValue({});
|
|
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
|
|
jest.mock('~/models', () => ({
|
|
saveMessage: jest.fn().mockResolvedValue(),
|
|
getConvo: jest.fn().mockResolvedValue({ title: 'Test Chat' }),
|
|
updateBalance: mockUpdateBalance,
|
|
bulkInsertTransactions: mockBulkInsertTransactions,
|
|
spendTokens: (...args) => mockSpendTokens(...args),
|
|
spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
|
|
getMultiplier: mockGetMultiplier,
|
|
getCacheMultiplier: mockGetCacheMultiplier,
|
|
}));
|
|
|
|
jest.mock('./abortRun', () => ({
|
|
abortRun: jest.fn(),
|
|
}));
|
|
|
|
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(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('spendCollectedUsage delegation', () => {
|
|
it('should return early if collectedUsage is empty', async () => {
|
|
await spendCollectedUsage({
|
|
userId: 'user-123',
|
|
conversationId: 'convo-123',
|
|
collectedUsage: [],
|
|
fallbackModel: 'gpt-4',
|
|
});
|
|
|
|
expect(mockRecordCollectedUsage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return early if collectedUsage is null', async () => {
|
|
await spendCollectedUsage({
|
|
userId: 'user-123',
|
|
conversationId: 'convo-123',
|
|
collectedUsage: null,
|
|
fallbackModel: 'gpt-4',
|
|
});
|
|
|
|
expect(mockRecordCollectedUsage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call recordCollectedUsage with abort context and full deps', async () => {
|
|
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
|
|
|
|
await spendCollectedUsage({
|
|
userId: 'user-123',
|
|
conversationId: 'convo-123',
|
|
collectedUsage,
|
|
fallbackModel: 'gpt-4',
|
|
messageId: 'msg-123',
|
|
});
|
|
|
|
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
|
|
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
|
{
|
|
spendTokens: expect.any(Function),
|
|
spendStructuredTokens: expect.any(Function),
|
|
pricing: {
|
|
getMultiplier: mockGetMultiplier,
|
|
getCacheMultiplier: mockGetCacheMultiplier,
|
|
},
|
|
bulkWriteOps: {
|
|
insertMany: mockBulkInsertTransactions,
|
|
updateBalance: mockUpdateBalance,
|
|
},
|
|
},
|
|
{
|
|
user: 'user-123',
|
|
conversationId: 'convo-123',
|
|
collectedUsage,
|
|
context: 'abort',
|
|
messageId: 'msg-123',
|
|
model: 'gpt-4',
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should pass context abort for multiple models (parallel agents)', async () => {
|
|
const collectedUsage = [
|
|
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
|
|
{ input_tokens: 80, output_tokens: 40, model: 'claude-3' },
|
|
{ input_tokens: 120, output_tokens: 60, model: 'gemini-pro' },
|
|
];
|
|
|
|
await spendCollectedUsage({
|
|
userId: 'user-123',
|
|
conversationId: 'convo-123',
|
|
collectedUsage,
|
|
fallbackModel: 'gpt-4',
|
|
});
|
|
|
|
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
|
|
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
context: 'abort',
|
|
collectedUsage,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should handle real-world parallel agent abort scenario', async () => {
|
|
const collectedUsage = [
|
|
{ input_tokens: 31596, output_tokens: 151, model: 'gemini-3-flash-preview' },
|
|
{ input_tokens: 28000, output_tokens: 120, model: 'gpt-5.2' },
|
|
];
|
|
|
|
await spendCollectedUsage({
|
|
userId: 'user-123',
|
|
conversationId: 'convo-123',
|
|
collectedUsage,
|
|
fallbackModel: 'gemini-3-flash-preview',
|
|
});
|
|
|
|
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
|
|
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
user: 'user-123',
|
|
conversationId: 'convo-123',
|
|
context: 'abort',
|
|
model: 'gemini-3-flash-preview',
|
|
}),
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Race condition prevention: after abort middleware spends tokens,
|
|
* the collectedUsage array is cleared so AgentClient.recordCollectedUsage()
|
|
* (which shares the same array reference) sees an empty array and returns early.
|
|
*/
|
|
it('should clear collectedUsage array after spending to prevent double-spending', async () => {
|
|
const collectedUsage = [
|
|
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
|
|
{ input_tokens: 80, output_tokens: 40, model: 'claude-3' },
|
|
];
|
|
|
|
expect(collectedUsage.length).toBe(2);
|
|
|
|
await spendCollectedUsage({
|
|
userId: 'user-123',
|
|
conversationId: 'convo-123',
|
|
collectedUsage,
|
|
fallbackModel: 'gpt-4',
|
|
});
|
|
|
|
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
|
|
expect(collectedUsage.length).toBe(0);
|
|
});
|
|
|
|
it('should await recordCollectedUsage before clearing array', async () => {
|
|
let resolved = false;
|
|
mockRecordCollectedUsage.mockImplementation(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
resolved = true;
|
|
return { input_tokens: 100, output_tokens: 50 };
|
|
});
|
|
|
|
const collectedUsage = [
|
|
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
|
|
{ input_tokens: 80, output_tokens: 40, model: 'claude-3' },
|
|
];
|
|
|
|
await spendCollectedUsage({
|
|
userId: 'user-123',
|
|
conversationId: 'convo-123',
|
|
collectedUsage,
|
|
fallbackModel: 'gpt-4',
|
|
});
|
|
|
|
expect(resolved).toBe(true);
|
|
expect(collectedUsage.length).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|