From 8d74fcd44a4480b3996c83f5927c0bffedda0950 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Jan 2026 10:38:01 -0500 Subject: [PATCH 001/147] =?UTF-8?q?=F0=9F=93=A6=20chore:=20npm=20audit=20f?= =?UTF-8?q?ix=20(#11346)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgraded several dependencies including browserify-sign (4.2.3 to 4.2.5), hono (4.11.3 to 4.11.4), parse-asn1 (5.1.7 to 5.1.9), pbkdf2 (3.1.3 to 3.1.5), and ripemd160 (2.0.2 to 2.0.3). - Adjusted engine requirements for compatibility with older Node.js versions. - Cleaned up unnecessary nested module entries for pbkdf2. --- package-lock.json | 141 +++++++++++++++++++++++++--------------------- 1 file changed, 77 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index 18e55217d6..5c3c3a9cf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22544,25 +22544,24 @@ } }, "node_modules/browserify-sign": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.3.tgz", - "integrity": "sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", + "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", "dev": true, "license": "ISC", "dependencies": { - "bn.js": "^5.2.1", - "browserify-rsa": "^4.1.0", + "bn.js": "^5.2.2", + "browserify-rsa": "^4.1.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.5", - "hash-base": "~3.0", + "elliptic": "^6.6.1", "inherits": "^2.0.4", - "parse-asn1": "^5.1.7", + "parse-asn1": "^5.1.9", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1" }, "engines": { - "node": ">= 0.12" + "node": ">= 0.10" } }, "node_modules/browserify-sign/node_modules/isarray": { @@ -28113,9 +28112,9 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/hono": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", - "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "license": "MIT", "peer": true, "engines": { @@ -34561,17 +34560,16 @@ } }, "node_modules/parse-asn1": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.7.tgz", - "integrity": "sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", + "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", "dev": true, "license": "ISC", "dependencies": { "asn1.js": "^4.10.1", "browserify-aes": "^1.2.0", "evp_bytestokey": "^1.0.3", - "hash-base": "~3.0", - "pbkdf2": "^3.1.2", + "pbkdf2": "^3.1.5", "safe-buffer": "^5.2.1" }, "engines": { @@ -34896,55 +34894,21 @@ "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, "node_modules/pbkdf2": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.3.tgz", - "integrity": "sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", + "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", "dev": true, "license": "MIT", "dependencies": { - "create-hash": "~1.1.3", + "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "ripemd160": "=2.0.1", + "ripemd160": "^2.0.3", "safe-buffer": "^5.2.1", - "sha.js": "^2.4.11", - "to-buffer": "^1.2.0" + "sha.js": "^2.4.12", + "to-buffer": "^1.2.1" }, "engines": { - "node": ">=0.12" - } - }, - "node_modules/pbkdf2/node_modules/create-hash": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.1.3.tgz", - "integrity": "sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "sha.js": "^2.4.0" - } - }, - "node_modules/pbkdf2/node_modules/hash-base": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-2.0.2.tgz", - "integrity": "sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1" - } - }, - "node_modules/pbkdf2/node_modules/ripemd160": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.1.tgz", - "integrity": "sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hash-base": "^2.0.0", - "inherits": "^2.0.1" + "node": ">= 0.10" } }, "node_modules/peek-readable": { @@ -38527,16 +38491,65 @@ } }, "node_modules/ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", "dev": true, "license": "MIT", "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" } }, + "node_modules/ripemd160/node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ripemd160/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/ripemd160/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", From 39a227a59f19b3c272c7a1c489970e7c578d8fea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:39:46 -0500 Subject: [PATCH 002/147] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#11342)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/nb/translation.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/locales/nb/translation.json b/client/src/locales/nb/translation.json index 153e4255bf..15d77af35d 100644 --- a/client/src/locales/nb/translation.json +++ b/client/src/locales/nb/translation.json @@ -1,6 +1,6 @@ { - "chat_direction_left_to_right": "Noe bør legges inn her. Tomt felt.", - "chat_direction_right_to_left": "Noe bør legges inn her. Tomt felt.", + "chat_direction_left_to_right": "Venstre til høyre", + "chat_direction_right_to_left": "Høyre til venstre", "com_a11y_ai_composing": "KI-en skriver fortsatt.", "com_a11y_end": "KI-en har fullført svaret sitt.", "com_a11y_start": "KI-en har begynt å svare.", @@ -372,7 +372,7 @@ "com_files_number_selected": "{{0}} av {{1}} valgt", "com_files_preparing_download": "Forbereder nedlasting ...", "com_files_sharepoint_picker_title": "Velg filer", - "com_files_table": "[Plassholder: Tabell over filer]", + "com_files_table": "Fil-tabell", "com_files_upload_local_machine": "Fra lokal datamaskin", "com_files_upload_sharepoint": "Fra SharePoint", "com_generated_files": "Genererte filer:", @@ -813,7 +813,7 @@ "com_ui_download_backup": "Last ned reservekoder", "com_ui_download_backup_tooltip": "Før du fortsetter, last ned reservekodene dine. Du vil trenge dem for å få tilgang igjen hvis du mister autentiseringsenheten din.", "com_ui_download_error": "Feil ved nedlasting av fil. Filen kan ha blitt slettet.", - "com_ui_drag_drop": "Dra og slipp filer her, eller klikk for å velge.", + "com_ui_drag_drop": "Dra og slipp fil(er) her, eller klikk for å velge.", "com_ui_dropdown_variables": "Nedtrekksvariabler:", "com_ui_dropdown_variables_info": "Opprett egendefinerte nedtrekksmenyer for promptene dine: `{{variabelnavn:valg1|valg2|valg3}}`", "com_ui_duplicate": "Dupliser", From b5e4c763afda0001b05097b47a704501ec6cec4c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Jan 2026 14:07:58 -0500 Subject: [PATCH 003/147] =?UTF-8?q?=F0=9F=94=80=20refactor:=20Endpoint=20C?= =?UTF-8?q?heck=20for=20File=20Uploads=20in=20Images=20Route=20(#11352)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed the endpoint check from `isAgentsEndpoint` to `isAssistantsEndpoint` to adjust the logic for processing file uploads. - Reordered the import statements for better organization. --- api/server/routes/files/images.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/server/routes/files/images.js b/api/server/routes/files/images.js index b8be413f4f..8072612a69 100644 --- a/api/server/routes/files/images.js +++ b/api/server/routes/files/images.js @@ -2,11 +2,11 @@ const path = require('path'); const fs = require('fs').promises; const express = require('express'); const { logger } = require('@librechat/data-schemas'); -const { isAgentsEndpoint } = require('librechat-data-provider'); +const { isAssistantsEndpoint } = require('librechat-data-provider'); const { - filterFile, - processImageFile, processAgentFileUpload, + processImageFile, + filterFile, } = require('~/server/services/Files/process'); const router = express.Router(); @@ -21,7 +21,7 @@ router.post('/', async (req, res) => { metadata.temp_file_id = metadata.file_id; metadata.file_id = req.file_id; - if (isAgentsEndpoint(metadata.endpoint) && metadata.tool_resource != null) { + if (!isAssistantsEndpoint(metadata.endpoint) && metadata.tool_resource != null) { return await processAgentFileUpload({ req, res, metadata }); } From 9562f9297a80e4b39622b6035f1144d9278bed0a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Jan 2026 22:02:57 -0500 Subject: [PATCH 004/147] =?UTF-8?q?=F0=9F=AA=A8=20fix:=20Bedrock=20Provide?= =?UTF-8?q?r=20Support=20for=20Memory=20Agent=20(#11353)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Bedrock provider support in memory processing - Introduced support for the Bedrock provider in the memory processing logic. - Updated the handling of instructions to ensure they are included in user messages for Bedrock, while maintaining the standard approach for other providers. - Added tests to verify the correct behavior for both Bedrock and non-Bedrock providers regarding instruction handling. * refactor: Bedrock memory processing logic - Improved handling of the first message in Bedrock memory processing to ensure proper content is used. - Added logging for cases where the first message content is not a string. - Adjusted the processed messages to include the original content or fallback to a new HumanMessage if no messages are present. * feat: Enhance Bedrock configuration handling in memory processing - Added logic to set the temperature to 1 when using the Bedrock provider with thinking enabled. - Ensured compatibility with additional model request fields for improved memory processing. --- packages/api/src/agents/memory.spec.ts | 91 +++++++++++++++++++++++++- packages/api/src/agents/memory.ts | 62 ++++++++++++++++-- 2 files changed, 145 insertions(+), 8 deletions(-) diff --git a/packages/api/src/agents/memory.spec.ts b/packages/api/src/agents/memory.spec.ts index 1b5242f78d..ca5a34ce05 100644 --- a/packages/api/src/agents/memory.spec.ts +++ b/packages/api/src/agents/memory.spec.ts @@ -1,17 +1,42 @@ import { Types } from 'mongoose'; -import type { Response } from 'express'; import { Run } from '@librechat/agents'; import type { IUser } from '@librechat/data-schemas'; -import { createSafeUser } from '~/utils/env'; +import type { Response } from 'express'; import { processMemory } from './memory'; jest.mock('~/stream/GenerationJobManager'); + +const mockCreateSafeUser = jest.fn((user) => ({ + id: user?.id, + email: user?.email, + name: user?.name, + username: user?.username, +})); + +const mockResolveHeaders = jest.fn((opts) => { + const headers = opts.headers || {}; + const user = opts.user || {}; + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + let resolved = value as string; + resolved = resolved.replace(/\$\{(\w+)\}/g, (_match, envVar) => process.env[envVar] || ''); + resolved = resolved.replace(/\{\{LIBRECHAT_USER_EMAIL\}\}/g, user.email || ''); + resolved = resolved.replace(/\{\{LIBRECHAT_USER_ID\}\}/g, user.id || ''); + result[key] = resolved; + } + return result; +}); + jest.mock('~/utils', () => ({ Tokenizer: { getTokenCount: jest.fn(() => 10), }, + createSafeUser: (user: unknown) => mockCreateSafeUser(user), + resolveHeaders: (opts: unknown) => mockResolveHeaders(opts), })); +const { createSafeUser } = jest.requireMock('~/utils'); + jest.mock('@librechat/agents', () => ({ Run: { create: jest.fn(() => ({ @@ -20,6 +45,7 @@ jest.mock('@librechat/agents', () => ({ }, Providers: { OPENAI: 'openai', + BEDROCK: 'bedrock', }, GraphEvents: { TOOL_END: 'tool_end', @@ -295,4 +321,65 @@ describe('Memory Agent Header Resolution', () => { expect(safeUser).toHaveProperty('id'); expect(safeUser).toHaveProperty('email'); }); + + it('should include instructions in user message for Bedrock provider', async () => { + const llmConfig = { + provider: 'bedrock', + model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + }; + + const { HumanMessage } = await import('@langchain/core/messages'); + const testMessage = new HumanMessage('test chat content'); + + await processMemory({ + res: mockRes, + userId: 'user-123', + setMemory: mockMemoryMethods.setMemory, + deleteMemory: mockMemoryMethods.deleteMemory, + messages: [testMessage], + memory: 'existing memory', + messageId: 'msg-123', + conversationId: 'conv-123', + validKeys: ['preferences'], + instructions: 'test instructions', + llmConfig, + user: testUser, + }); + + expect(Run.create as jest.Mock).toHaveBeenCalled(); + const runConfig = (Run.create as jest.Mock).mock.calls[0][0]; + + // For Bedrock, instructions should NOT be passed to graphConfig + expect(runConfig.graphConfig.instructions).toBeUndefined(); + expect(runConfig.graphConfig.additional_instructions).toBeUndefined(); + }); + + it('should pass instructions to graphConfig for non-Bedrock providers', async () => { + const llmConfig = { + provider: 'openai', + model: 'gpt-4o-mini', + }; + + await processMemory({ + res: mockRes, + userId: 'user-123', + setMemory: mockMemoryMethods.setMemory, + deleteMemory: mockMemoryMethods.deleteMemory, + messages: [], + memory: 'existing memory', + messageId: 'msg-123', + conversationId: 'conv-123', + validKeys: ['preferences'], + instructions: 'test instructions', + llmConfig, + user: testUser, + }); + + expect(Run.create as jest.Mock).toHaveBeenCalled(); + const runConfig = (Run.create as jest.Mock).mock.calls[0][0]; + + // For non-Bedrock providers, instructions should be passed to graphConfig + expect(runConfig.graphConfig.instructions).toBe('test instructions'); + expect(runConfig.graphConfig.additional_instructions).toBeDefined(); + }); }); diff --git a/packages/api/src/agents/memory.ts b/packages/api/src/agents/memory.ts index dcf26a8666..6a46ab68c3 100644 --- a/packages/api/src/agents/memory.ts +++ b/packages/api/src/agents/memory.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { tool } from '@langchain/core/tools'; import { Tools } from 'librechat-data-provider'; import { logger } from '@librechat/data-schemas'; +import { HumanMessage } from '@langchain/core/messages'; import { Run, Providers, GraphEvents } from '@librechat/agents'; import type { OpenAIClientOptions, @@ -13,13 +14,12 @@ import type { ToolEndData, LLMConfig, } from '@librechat/agents'; -import type { TAttachment, MemoryArtifact } from 'librechat-data-provider'; import type { ObjectId, MemoryMethods, IUser } from '@librechat/data-schemas'; +import type { TAttachment, MemoryArtifact } from 'librechat-data-provider'; import type { BaseMessage, ToolMessage } from '@langchain/core/messages'; import type { Response as ServerResponse } from 'express'; import { GenerationJobManager } from '~/stream/GenerationJobManager'; -import { resolveHeaders, createSafeUser } from '~/utils/env'; -import { Tokenizer } from '~/utils'; +import { Tokenizer, resolveHeaders, createSafeUser } from '~/utils'; type RequiredMemoryMethods = Pick< MemoryMethods, @@ -369,6 +369,19 @@ ${memory ?? 'No existing memories'}`; } } + // Handle Bedrock with thinking enabled - temperature must be 1 + const bedrockConfig = finalLLMConfig as { + additionalModelRequestFields?: { thinking?: unknown }; + temperature?: number; + }; + if ( + llmConfig?.provider === Providers.BEDROCK && + bedrockConfig.additionalModelRequestFields?.thinking != null && + bedrockConfig.temperature != null + ) { + (finalLLMConfig as unknown as Record).temperature = 1; + } + const llmConfigWithHeaders = finalLLMConfig as OpenAIClientOptions; if (llmConfigWithHeaders?.configuration?.defaultHeaders != null) { llmConfigWithHeaders.configuration.defaultHeaders = resolveHeaders({ @@ -383,14 +396,51 @@ ${memory ?? 'No existing memories'}`; [GraphEvents.TOOL_END]: new BasicToolEndHandler(memoryCallback), }; + /** + * For Bedrock provider, include instructions in the user message instead of as a system prompt. + * Bedrock's Converse API requires conversations to start with a user message, not a system message. + * Other providers can use the standard system prompt approach. + */ + const isBedrock = llmConfig?.provider === Providers.BEDROCK; + + let graphInstructions: string | undefined = instructions; + let graphAdditionalInstructions: string | undefined = memoryStatus; + let processedMessages = messages; + + if (isBedrock) { + const combinedInstructions = [instructions, memoryStatus].filter(Boolean).join('\n\n'); + + if (messages.length > 0) { + const firstMessage = messages[0]; + const originalContent = + typeof firstMessage.content === 'string' ? firstMessage.content : ''; + + if (typeof firstMessage.content !== 'string') { + logger.warn( + 'Bedrock memory processing: First message has non-string content, using empty string', + ); + } + + const bedrockUserMessage = new HumanMessage( + `${combinedInstructions}\n\n${originalContent}`, + ); + processedMessages = [bedrockUserMessage, ...messages.slice(1)]; + } else { + processedMessages = [new HumanMessage(combinedInstructions)]; + } + + graphInstructions = undefined; + graphAdditionalInstructions = undefined; + } + const run = await Run.create({ runId: messageId, graphConfig: { type: 'standard', llmConfig: finalLLMConfig, tools: [memoryTool, deleteMemoryTool], - instructions, - additional_instructions: memoryStatus, + instructions: graphInstructions, + additional_instructions: graphAdditionalInstructions, toolEnd: true, }, customHandlers, @@ -410,7 +460,7 @@ ${memory ?? 'No existing memories'}`; } as const; const inputs = { - messages, + messages: processedMessages, }; const content = await run.processStream(inputs, config); if (content) { From bb0fa3b7f7efb460fcdc90520a704890eeb48dd9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 15 Jan 2026 21:24:49 -0500 Subject: [PATCH 005/147] =?UTF-8?q?=F0=9F=93=A6=20chore:=20Cleanup=20Unuse?= =?UTF-8?q?d=20Packages=20(#11369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: remove unused 'diff' package from dependencies * chore: update undici package to version 7.18.2 * chore: remove unused '@types/diff' package from dependencies * chore: remove unused '@types/diff' package from package.json and package-lock.json --- api/package.json | 2 +- package-lock.json | 28 +++++----------------------- packages/api/package.json | 4 +--- packages/data-schemas/package.json | 1 - 4 files changed, 7 insertions(+), 28 deletions(-) diff --git a/api/package.json b/api/package.json index 0881070652..9ceb9b624c 100644 --- a/api/package.json +++ b/api/package.json @@ -108,7 +108,7 @@ "tiktoken": "^1.0.15", "traverse": "^0.6.7", "ua-parser-js": "^1.0.36", - "undici": "^7.10.0", + "undici": "^7.18.2", "winston": "^3.11.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.22.4" diff --git a/package-lock.json b/package-lock.json index 5c3c3a9cf9..d9fd999fc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -122,7 +122,7 @@ "tiktoken": "^1.0.15", "traverse": "^0.6.7", "ua-parser-js": "^1.0.36", - "undici": "^7.10.0", + "undici": "^7.18.2", "winston": "^3.11.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.22.4" @@ -20369,12 +20369,6 @@ "@types/ms": "*" } }, - "node_modules/@types/diff": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@types/diff/-/diff-6.0.0.tgz", - "integrity": "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==", - "dev": true - }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -25001,15 +24995,6 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, - "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -41136,9 +41121,9 @@ "dev": true }, "node_modules/undici": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", - "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -43115,7 +43100,6 @@ "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-typescript": "^12.1.2", "@types/bun": "^1.2.15", - "@types/diff": "^6.0.0", "@types/express": "^5.0.0", "@types/express-session": "^1.18.2", "@types/jest": "^29.5.2", @@ -43151,7 +43135,6 @@ "@smithy/node-http-handler": "^4.4.5", "axios": "^1.12.1", "connect-redis": "^8.1.0", - "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^5.1.0", "express-session": "^1.18.2", @@ -43171,7 +43154,7 @@ "node-fetch": "2.7.0", "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", - "undici": "^7.10.0", + "undici": "^7.18.2", "zod": "^3.22.4" } }, @@ -45573,7 +45556,6 @@ "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", - "@types/diff": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.0", diff --git a/packages/api/package.json b/packages/api/package.json index 5f5576e293..d8a06aad2b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -55,7 +55,6 @@ "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-typescript": "^12.1.2", "@types/bun": "^1.2.15", - "@types/diff": "^6.0.0", "@types/express": "^5.0.0", "@types/express-session": "^1.18.2", "@types/jest": "^29.5.2", @@ -94,7 +93,6 @@ "@smithy/node-http-handler": "^4.4.5", "axios": "^1.12.1", "connect-redis": "^8.1.0", - "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^5.1.0", "express-session": "^1.18.2", @@ -114,7 +112,7 @@ "node-fetch": "2.7.0", "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", - "undici": "^7.10.0", + "undici": "^7.18.2", "zod": "^3.22.4" } } diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index 49c29f8561..eb143c0dd6 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -44,7 +44,6 @@ "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", - "@types/diff": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.0", From 476882455e44cfb0d8d63c4a6c07d80524b24656 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 22:23:23 -0500 Subject: [PATCH 006/147] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#11370)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/lv/translation.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index f123294a8d..f17bb9cb46 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -533,6 +533,7 @@ "com_nav_log_out": "Izrakstīties", "com_nav_long_audio_warning": "Garāku tekstu apstrāde prasīs ilgāku laiku.", "com_nav_maximize_chat_space": "Maksimāli izmantot sarunu telpas izmērus", + "com_nav_mcp_access_revoked": "MCP servera piekļuve veiksmīgi atsaukta.", "com_nav_mcp_configure_server": "Konfigurēt {{0}}", "com_nav_mcp_connect": "Savienot", "com_nav_mcp_connect_server": "Savienot {{0}}", From 81f4af55b5cc80bf9d1db6445aaf7ca35315f08e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 15 Jan 2026 22:48:48 -0500 Subject: [PATCH 007/147] =?UTF-8?q?=F0=9F=AA=A8=20feat:=20Anthropic=20Beta?= =?UTF-8?q?=20Support=20for=20Bedrock=20(#11371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🪨 feat: Anthropic Beta Support for Bedrock - Updated the Bedrock input parser to dynamically generate `anthropic_beta` headers based on the model identifier. - Added a new utility function `getBedrockAnthropicBetaHeaders` to determine applicable headers for various Anthropic models. - Modified existing tests to reflect changes in expected `anthropic_beta` values, including new test cases for full model IDs. * test: Update Bedrock Input Parser Tests for Beta Headers - Modified the test case for explicit thinking configuration to reflect the addition of `anthropic_beta` headers. - Ensured that the test now verifies the presence of specific beta header values in the additional model request fields. --- packages/data-provider/specs/bedrock.spec.ts | 52 +++++++++++++++----- packages/data-provider/src/bedrock.ts | 35 ++++++++++++- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/packages/data-provider/specs/bedrock.spec.ts b/packages/data-provider/specs/bedrock.spec.ts index 2a0de6937a..c731d18d5e 100644 --- a/packages/data-provider/specs/bedrock.spec.ts +++ b/packages/data-provider/specs/bedrock.spec.ts @@ -14,7 +14,7 @@ describe('bedrockInputParser', () => { expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); }); - test('should match anthropic.claude-sonnet-4 model', () => { + test('should match anthropic.claude-sonnet-4 model with 1M context header', () => { const input = { model: 'anthropic.claude-sonnet-4', }; @@ -22,10 +22,13 @@ describe('bedrockInputParser', () => { const additionalFields = result.additionalModelRequestFields as Record; expect(additionalFields.thinking).toBe(true); expect(additionalFields.thinkingBudget).toBe(2000); - expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); }); - test('should match anthropic.claude-opus-5 model', () => { + test('should match anthropic.claude-opus-5 model without 1M context header', () => { const input = { model: 'anthropic.claude-opus-5', }; @@ -36,7 +39,7 @@ describe('bedrockInputParser', () => { expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); }); - test('should match anthropic.claude-haiku-6 model', () => { + test('should match anthropic.claude-haiku-6 model without 1M context header', () => { const input = { model: 'anthropic.claude-haiku-6', }; @@ -47,7 +50,7 @@ describe('bedrockInputParser', () => { expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); }); - test('should match anthropic.claude-4-sonnet model', () => { + test('should match anthropic.claude-4-sonnet model with 1M context header', () => { const input = { model: 'anthropic.claude-4-sonnet', }; @@ -55,10 +58,13 @@ describe('bedrockInputParser', () => { const additionalFields = result.additionalModelRequestFields as Record; expect(additionalFields.thinking).toBe(true); expect(additionalFields.thinkingBudget).toBe(2000); - expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); }); - test('should match anthropic.claude-4.5-sonnet model', () => { + test('should match anthropic.claude-4.5-sonnet model with 1M context header', () => { const input = { model: 'anthropic.claude-4.5-sonnet', }; @@ -66,10 +72,13 @@ describe('bedrockInputParser', () => { const additionalFields = result.additionalModelRequestFields as Record; expect(additionalFields.thinking).toBe(true); expect(additionalFields.thinkingBudget).toBe(2000); - expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); }); - test('should match anthropic.claude-4-7-sonnet model', () => { + test('should match anthropic.claude-4-7-sonnet model with 1M context header', () => { const input = { model: 'anthropic.claude-4-7-sonnet', }; @@ -77,7 +86,24 @@ describe('bedrockInputParser', () => { const additionalFields = result.additionalModelRequestFields as Record; expect(additionalFields.thinking).toBe(true); expect(additionalFields.thinkingBudget).toBe(2000); - expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); + }); + + test('should match anthropic.claude-sonnet-4-20250514-v1:0 with full model ID', () => { + const input = { + model: 'anthropic.claude-sonnet-4-20250514-v1:0', + }; + const result = bedrockInputParser.parse(input) as BedrockConverseInput; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields.thinking).toBe(true); + expect(additionalFields.thinkingBudget).toBe(2000); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); }); test('should not match non-Claude models', () => { @@ -110,7 +136,7 @@ describe('bedrockInputParser', () => { expect(additionalFields?.anthropic_beta).toBeUndefined(); }); - test('should respect explicit thinking configuration', () => { + test('should respect explicit thinking configuration but still add beta headers', () => { const input = { model: 'anthropic.claude-sonnet-4', thinking: false, @@ -119,6 +145,10 @@ describe('bedrockInputParser', () => { const additionalFields = result.additionalModelRequestFields as Record; expect(additionalFields.thinking).toBeUndefined(); expect(additionalFields.thinkingBudget).toBeUndefined(); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); }); test('should respect custom thinking budget', () => { diff --git a/packages/data-provider/src/bedrock.ts b/packages/data-provider/src/bedrock.ts index b37fdc25e1..4df6bd6b65 100644 --- a/packages/data-provider/src/bedrock.ts +++ b/packages/data-provider/src/bedrock.ts @@ -15,6 +15,36 @@ type AnthropicInput = BedrockConverseInput & { AnthropicReasoning; }; +/** + * Gets the appropriate anthropic_beta headers for Bedrock Anthropic models. + * Bedrock uses `anthropic_beta` (with underscore) in additionalModelRequestFields. + * + * @param model - The Bedrock model identifier (e.g., "anthropic.claude-sonnet-4-20250514-v1:0") + * @returns Array of beta header strings, or empty array if not applicable + */ +function getBedrockAnthropicBetaHeaders(model: string): string[] { + const betaHeaders: string[] = []; + + const isClaudeThinkingModel = + model.includes('anthropic.claude-3-7-sonnet') || + /anthropic\.claude-(?:[4-9](?:\.\d+)?(?:-\d+)?-(?:sonnet|opus|haiku)|(?:sonnet|opus|haiku)-[4-9])/.test( + model, + ); + + const isSonnet4PlusModel = + /anthropic\.claude-(?:sonnet-[4-9]|[4-9](?:\.\d+)?(?:-\d+)?-sonnet)/.test(model); + + if (isClaudeThinkingModel) { + betaHeaders.push('output-128k-2025-02-19'); + } + + if (isSonnet4PlusModel) { + betaHeaders.push('context-1m-2025-08-07'); + } + + return betaHeaders; +} + export const bedrockInputSchema = s.tConversationSchema .pick({ /* LibreChat params; optionType: 'conversation' */ @@ -138,7 +168,10 @@ export const bedrockInputParser = s.tConversationSchema additionalFields.thinkingBudget = 2000; } if (typedData.model.includes('anthropic.')) { - additionalFields.anthropic_beta = ['output-128k-2025-02-19']; + const betaHeaders = getBedrockAnthropicBetaHeaders(typedData.model); + if (betaHeaders.length > 0) { + additionalFields.anthropic_beta = betaHeaders; + } } } else if (additionalFields.thinking != null || additionalFields.thinkingBudget != null) { delete additionalFields.thinking; From c378e777efa23479b27db729955087f86f500514 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 15 Jan 2026 23:02:03 -0500 Subject: [PATCH 008/147] =?UTF-8?q?=F0=9F=AA=B5=20refactor:=20Preserve=20J?= =?UTF-8?q?ob=20Error=20State=20for=20Late=20Stream=20Subscribers=20(#1137?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🪵 refactor: Preserve job error state for late stream subscribers * 🔧 fix: Enhance error handling for late subscribers in GenerationJobManager - Implemented a cleanup strategy for error jobs to prevent immediate deletion, allowing late clients to receive error messages. - Updated job status handling to prioritize error notifications over completion events. - Added integration tests to verify error preservation and proper notification to late subscribers, including scenarios with Redis support. --- .../api/src/stream/GenerationJobManager.ts | 64 +++- ...ationJobManager.stream_integration.spec.ts | 276 ++++++++++++++++++ .../implementations/InMemoryEventTransport.ts | 6 +- 3 files changed, 335 insertions(+), 11 deletions(-) diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 44b38f48f6..13544fc445 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -33,6 +33,7 @@ export interface GenerationJobManagerOptions { * @property readyPromise - Resolves immediately (legacy, kept for API compatibility) * @property resolveReady - Function to resolve readyPromise * @property finalEvent - Cached final event for late subscribers + * @property errorEvent - Cached error event for late subscribers (errors before client connects) * @property syncSent - Whether sync event was sent (reset when all subscribers leave) * @property earlyEventBuffer - Buffer for events emitted before first subscriber connects * @property hasSubscriber - Whether at least one subscriber has connected @@ -47,6 +48,7 @@ interface RuntimeJobState { readyPromise: Promise; resolveReady: () => void; finalEvent?: t.ServerSentEvent; + errorEvent?: string; syncSent: boolean; earlyEventBuffer: t.ServerSentEvent[]; hasSubscriber: boolean; @@ -421,6 +423,7 @@ class GenerationJobManagerClass { earlyEventBuffer: [], hasSubscriber: false, finalEvent, + errorEvent: jobData.error, }; this.runtimeState.set(streamId, runtime); @@ -510,6 +513,8 @@ class GenerationJobManagerClass { /** * Mark job as complete. * If cleanupOnComplete is true (default), immediately cleans up job resources. + * Exception: Jobs with errors are NOT immediately deleted to allow late-connecting + * clients to receive the error (race condition where error occurs before client connects). * Note: eventTransport is NOT cleaned up here to allow the final event to be * fully transmitted. It will be cleaned up when subscribers disconnect or * by the periodic cleanup job. @@ -527,7 +532,29 @@ class GenerationJobManagerClass { this.jobStore.clearContentState(streamId); this.runStepBuffers?.delete(streamId); - // Immediate cleanup if configured (default: true) + // For error jobs, DON'T delete immediately - keep around so late-connecting + // clients can receive the error. This handles the race condition where error + // occurs before client connects to SSE stream. + // + // Cleanup strategy: Error jobs are cleaned up by periodic cleanup (every 60s) + // via jobStore.cleanup() which checks for jobs with status 'error' and + // completedAt set. The TTL is configurable via jobStore options (default: 0, + // meaning cleanup on next interval). This gives clients ~60s to connect and + // receive the error before the job is removed. + if (error) { + await this.jobStore.updateJob(streamId, { + status: 'error', + completedAt: Date.now(), + error, + }); + // Keep runtime state so subscribe() can access errorEvent + logger.debug( + `[GenerationJobManager] Job completed with error (keeping for late subscribers): ${streamId}`, + ); + return; + } + + // Immediate cleanup if configured (default: true) - only for successful completions if (this._cleanupOnComplete) { this.runtimeState.delete(streamId); // Don't cleanup eventTransport here - let the done event fully transmit first. @@ -536,9 +563,8 @@ class GenerationJobManagerClass { } else { // Only update status if keeping the job around await this.jobStore.updateJob(streamId, { - status: error ? 'error' : 'complete', + status: 'complete', completedAt: Date.now(), - error, }); } @@ -678,14 +704,22 @@ class GenerationJobManagerClass { const jobData = await this.jobStore.getJob(streamId); - // If job already complete, send final event + // If job already complete/error, send final event or error + // Error status takes precedence to ensure errors aren't misreported as successes setImmediate(() => { - if ( - runtime.finalEvent && - jobData && - ['complete', 'error', 'aborted'].includes(jobData.status) - ) { - onDone?.(runtime.finalEvent); + if (jobData && ['complete', 'error', 'aborted'].includes(jobData.status)) { + // Check for error status FIRST and prioritize error handling + if (jobData.status === 'error' && (runtime.errorEvent || jobData.error)) { + const errorToSend = runtime.errorEvent ?? jobData.error; + if (errorToSend) { + logger.debug( + `[GenerationJobManager] Sending stored error to late subscriber: ${streamId}`, + ); + onError?.(errorToSend); + } + } else if (runtime.finalEvent) { + onDone?.(runtime.finalEvent); + } } }); @@ -986,8 +1020,18 @@ class GenerationJobManagerClass { /** * Emit an error event. + * Stores the error for late-connecting subscribers (race condition where error + * occurs before client connects to SSE stream). */ emitError(streamId: string, error: string): void { + const runtime = this.runtimeState.get(streamId); + if (runtime) { + runtime.errorEvent = error; + } + // Persist error to job store for cross-replica consistency + this.jobStore.updateJob(streamId, { error }).catch((err) => { + logger.error(`[GenerationJobManager] Failed to persist error:`, err); + }); this.eventTransport.emitError(streamId, error); } diff --git a/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts b/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts index 4471d8c95d..e3ea16c8f0 100644 --- a/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts +++ b/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts @@ -796,6 +796,282 @@ describe('GenerationJobManager Integration Tests', () => { }); }); + describe('Error Preservation for Late Subscribers', () => { + /** + * These tests verify the fix for the race condition where errors + * (like INPUT_LENGTH) occur before the SSE client connects. + * + * Problem: Error → emitError → completeJob → job deleted → client connects → 404 + * Fix: Store error, don't delete job immediately, send error to late subscriber + */ + + test('should store error in emitError for late-connecting subscribers', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `error-store-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const errorMessage = '{ "type": "INPUT_LENGTH", "info": "234856 / 172627" }'; + + // Emit error (no subscribers yet - simulates race condition) + GenerationJobManager.emitError(streamId, errorMessage); + + // Wait for async job store update + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify error is stored in job store + const job = await GenerationJobManager.getJob(streamId); + expect(job?.error).toBe(errorMessage); + + await GenerationJobManager.destroy(); + }); + + test('should NOT delete job immediately when completeJob is called with error', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: true, // Default behavior + }); + + await GenerationJobManager.initialize(); + + const streamId = `error-no-delete-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const errorMessage = 'Test error message'; + + // Complete with error + await GenerationJobManager.completeJob(streamId, errorMessage); + + // Job should still exist (not deleted) + const hasJob = await GenerationJobManager.hasJob(streamId); + expect(hasJob).toBe(true); + + // Job should have error status + const job = await GenerationJobManager.getJob(streamId); + expect(job?.status).toBe('error'); + expect(job?.error).toBe(errorMessage); + + await GenerationJobManager.destroy(); + }); + + test('should send stored error to late-connecting subscriber', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: true, + }); + + await GenerationJobManager.initialize(); + + const streamId = `error-late-sub-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const errorMessage = '{ "type": "INPUT_LENGTH", "info": "234856 / 172627" }'; + + // Simulate race condition: error occurs before client connects + GenerationJobManager.emitError(streamId, errorMessage); + await GenerationJobManager.completeJob(streamId, errorMessage); + + // Wait for async operations + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Now client connects (late subscriber) + let receivedError: string | undefined; + const subscription = await GenerationJobManager.subscribe( + streamId, + () => {}, // onChunk + () => {}, // onDone + (error) => { + receivedError = error; + }, // onError + ); + + expect(subscription).not.toBeNull(); + + // Wait for setImmediate in subscribe to fire + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Late subscriber should receive the stored error + expect(receivedError).toBe(errorMessage); + + subscription?.unsubscribe(); + await GenerationJobManager.destroy(); + }); + + test('should prioritize error status over finalEvent in subscribe', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `error-priority-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const errorMessage = 'Error should take priority'; + + // Emit error and complete with error + GenerationJobManager.emitError(streamId, errorMessage); + await GenerationJobManager.completeJob(streamId, errorMessage); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Subscribe and verify error is received (not a done event) + let receivedError: string | undefined; + let receivedDone = false; + + const subscription = await GenerationJobManager.subscribe( + streamId, + () => {}, + () => { + receivedDone = true; + }, + (error) => { + receivedError = error; + }, + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Error should be received, not done + expect(receivedError).toBe(errorMessage); + expect(receivedDone).toBe(false); + + subscription?.unsubscribe(); + await GenerationJobManager.destroy(); + }); + + test('should handle error preservation in Redis mode (cross-replica)', async () => { + if (!ioredisClient) { + console.warn('Redis not available, skipping test'); + return; + } + + const { createStreamServices } = await import('../createStreamServices'); + const { RedisJobStore } = await import('../implementations/RedisJobStore'); + + // === Replica A: Creates job and emits error === + const replicaAJobStore = new RedisJobStore(ioredisClient); + await replicaAJobStore.initialize(); + + const streamId = `redis-error-${Date.now()}`; + const errorMessage = '{ "type": "INPUT_LENGTH", "info": "234856 / 172627" }'; + + await replicaAJobStore.createJob(streamId, 'user-1'); + await replicaAJobStore.updateJob(streamId, { + status: 'error', + error: errorMessage, + completedAt: Date.now(), + }); + + // === Replica B: Fresh manager receives client connection === + jest.resetModules(); + const { GenerationJobManager } = await import('../GenerationJobManager'); + + const services = createStreamServices({ + useRedis: true, + redisClient: ioredisClient, + }); + + GenerationJobManager.configure({ + ...services, + cleanupOnComplete: false, + }); + await GenerationJobManager.initialize(); + + // Client connects to Replica B (job created on Replica A) + let receivedError: string | undefined; + const subscription = await GenerationJobManager.subscribe( + streamId, + () => {}, + () => {}, + (error) => { + receivedError = error; + }, + ); + + expect(subscription).not.toBeNull(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Error should be loaded from Redis and sent to subscriber + expect(receivedError).toBe(errorMessage); + + subscription?.unsubscribe(); + await GenerationJobManager.destroy(); + await replicaAJobStore.destroy(); + }); + + test('error jobs should be cleaned up by periodic cleanup after TTL', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + // Use a very short TTL for testing + const jobStore = new InMemoryJobStore({ ttlAfterComplete: 100 }); + + GenerationJobManager.configure({ + jobStore, + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: true, + }); + + await GenerationJobManager.initialize(); + + const streamId = `error-cleanup-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Complete with error + await GenerationJobManager.completeJob(streamId, 'Test error'); + + // Job should exist immediately after error + let hasJob = await GenerationJobManager.hasJob(streamId); + expect(hasJob).toBe(true); + + // Wait for TTL to expire + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Trigger cleanup + await jobStore.cleanup(); + + // Job should be cleaned up after TTL + hasJob = await GenerationJobManager.hasJob(streamId); + expect(hasJob).toBe(false); + + await GenerationJobManager.destroy(); + }); + }); + describe('createStreamServices Auto-Detection', () => { test('should auto-detect Redis when USE_REDIS is true', async () => { if (!ioredisClient) { diff --git a/packages/api/src/stream/implementations/InMemoryEventTransport.ts b/packages/api/src/stream/implementations/InMemoryEventTransport.ts index fd9c65e239..39b3d6029d 100644 --- a/packages/api/src/stream/implementations/InMemoryEventTransport.ts +++ b/packages/api/src/stream/implementations/InMemoryEventTransport.ts @@ -79,7 +79,11 @@ export class InMemoryEventTransport implements IEventTransport { emitError(streamId: string, error: string): void { const state = this.streams.get(streamId); - state?.emitter.emit('error', error); + // Only emit if there are listeners - Node.js throws on unhandled 'error' events + // This is intentional for the race condition where error occurs before client connects + if (state?.emitter.listenerCount('error') ?? 0 > 0) { + state?.emitter.emit('error', error); + } } getSubscriberCount(streamId: string): number { From 02d75b24a455dc72e5a527eefdcbcdd6c444ed77 Mon Sep 17 00:00:00 2001 From: Andrei Blizorukov <55080535+ablizorukov@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:30:00 +0100 Subject: [PATCH 009/147] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20improved?= =?UTF-8?q?=20retry=20logic=20during=20meili=20sync=20&=20improved=20batch?= =?UTF-8?q?ing=20(#11373)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🛠️ fix: unreliable retry logic during meili sync in case of interruption 🛠️ fix: exclude temporary documents from the count on startup for meili sync 🛠️ refactor: improved meili index cleanup before sync * fix: don't swallow the exception to prevent indefinite loop fix: update log messages for more clarity fix: more test coverage for exception handling --- .../src/models/plugins/mongoMeili.spec.ts | 361 ++++++++++++++++++ .../src/models/plugins/mongoMeili.ts | 195 ++++------ 2 files changed, 441 insertions(+), 115 deletions(-) diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts b/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts index 8f4ee87aaf..25e8d54cc1 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts @@ -6,10 +6,20 @@ import { createMessageModel } from '~/models/message'; import { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; const mockAddDocuments = jest.fn(); +const mockAddDocumentsInBatches = jest.fn(); +const mockUpdateDocuments = jest.fn(); +const mockDeleteDocument = jest.fn(); +const mockDeleteDocuments = jest.fn(); +const mockGetDocument = jest.fn(); const mockIndex = jest.fn().mockReturnValue({ getRawInfo: jest.fn(), updateSettings: jest.fn(), addDocuments: mockAddDocuments, + addDocumentsInBatches: mockAddDocumentsInBatches, + updateDocuments: mockUpdateDocuments, + deleteDocument: mockDeleteDocument, + deleteDocuments: mockDeleteDocuments, + getDocument: mockGetDocument, getDocuments: jest.fn().mockReturnValue({ results: [] }), }); jest.mock('meilisearch', () => { @@ -42,6 +52,11 @@ describe('Meilisearch Mongoose plugin', () => { beforeEach(() => { mockAddDocuments.mockClear(); + mockAddDocumentsInBatches.mockClear(); + mockUpdateDocuments.mockClear(); + mockDeleteDocument.mockClear(); + mockDeleteDocuments.mockClear(); + mockGetDocument.mockClear(); }); afterAll(async () => { @@ -264,4 +279,350 @@ describe('Meilisearch Mongoose plugin', () => { expect(indexedCount).toBe(2); }); }); + + describe('New batch processing and retry functionality', () => { + test('processSyncBatch uses addDocumentsInBatches', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + mockAddDocuments.mockClear(); + + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Conversation', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + // Run sync which should call processSyncBatch internally + await conversationModel.syncWithMeili(); + + // Verify addDocumentsInBatches was called (new batch method) + expect(mockAddDocumentsInBatches).toHaveBeenCalled(); + }); + + test('addObjectToMeili retries on failure', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + + // Mock addDocuments to fail twice then succeed + mockAddDocuments + .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({}); + + // Create a document which triggers addObjectToMeili + await conversationModel.create({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Retry', + endpoint: EModelEndpoint.openAI, + }); + + // Wait for async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify addDocuments was called multiple times due to retries + expect(mockAddDocuments).toHaveBeenCalledTimes(3); + }); + + test('getSyncProgress returns accurate progress information', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + + // Insert documents directly to control the _meiliIndex flag + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Indexed', + endpoint: EModelEndpoint.openAI, + _meiliIndex: true, + expiredAt: null, + }); + + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Not Indexed', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + const progress = await conversationModel.getSyncProgress(); + + expect(progress.totalDocuments).toBe(2); + expect(progress.totalProcessed).toBe(1); + expect(progress.isComplete).toBe(false); + }); + + test('getSyncProgress excludes TTL documents from counts', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + + // Insert syncable documents (expiredAt: null) + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Syncable Indexed', + endpoint: EModelEndpoint.openAI, + _meiliIndex: true, + expiredAt: null, + }); + + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Syncable Not Indexed', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + // Insert TTL documents (expiredAt set) - these should NOT be counted + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'TTL Document 1', + endpoint: EModelEndpoint.openAI, + _meiliIndex: true, + expiredAt: new Date(), + }); + + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'TTL Document 2', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: new Date(), + }); + + const progress = await conversationModel.getSyncProgress(); + + // Only syncable documents should be counted (2 total, 1 indexed) + expect(progress.totalDocuments).toBe(2); + expect(progress.totalProcessed).toBe(1); + expect(progress.isComplete).toBe(false); + }); + + test('getSyncProgress shows completion when all syncable documents are indexed', async () => { + const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods; + await messageModel.deleteMany({}); + + // All syncable documents are indexed + await messageModel.collection.insertOne({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + _meiliIndex: true, + expiredAt: null, + }); + + await messageModel.collection.insertOne({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: false, + _meiliIndex: true, + expiredAt: null, + }); + + // Add TTL document - should not affect completion status + await messageModel.collection.insertOne({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + _meiliIndex: false, + expiredAt: new Date(), + }); + + const progress = await messageModel.getSyncProgress(); + + expect(progress.totalDocuments).toBe(2); + expect(progress.totalProcessed).toBe(2); + expect(progress.isComplete).toBe(true); + }); + }); + + describe('Error handling in processSyncBatch', () => { + test('syncWithMeili fails when processSyncBatch encounters addDocumentsInBatches error', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + + // Insert a document to sync + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Conversation', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + // Mock addDocumentsInBatches to fail + mockAddDocumentsInBatches.mockRejectedValueOnce(new Error('MeiliSearch connection error')); + + // Sync should throw the error + await expect(conversationModel.syncWithMeili()).rejects.toThrow( + 'MeiliSearch connection error', + ); + + // Verify the error was logged + expect(mockAddDocumentsInBatches).toHaveBeenCalled(); + + // Document should NOT be marked as indexed since sync failed + // Note: direct collection.insertOne doesn't set default values, so _meiliIndex may be undefined + const doc = await conversationModel.findOne({}); + expect(doc?._meiliIndex).not.toBe(true); + }); + + test('syncWithMeili fails when processSyncBatch encounters updateMany error', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + + // Insert a document + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Conversation', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + // Mock addDocumentsInBatches to succeed but simulate updateMany failure + mockAddDocumentsInBatches.mockResolvedValueOnce({}); + + // Spy on updateMany and make it fail + const updateManySpy = jest + .spyOn(conversationModel, 'updateMany') + .mockRejectedValueOnce(new Error('Database connection error')); + + // Sync should throw the error + await expect(conversationModel.syncWithMeili()).rejects.toThrow('Database connection error'); + + expect(updateManySpy).toHaveBeenCalled(); + + // Restore original implementation + updateManySpy.mockRestore(); + }); + + test('processSyncBatch logs error and throws when addDocumentsInBatches fails', async () => { + const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods; + await messageModel.deleteMany({}); + + mockAddDocumentsInBatches.mockRejectedValueOnce(new Error('Network timeout')); + + await messageModel.collection.insertOne({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + _meiliIndex: false, + expiredAt: null, + }); + + const indexMock = mockIndex(); + const documents = await messageModel.find({ _meiliIndex: false }).lean(); + + // Should throw the error + await expect(messageModel.processSyncBatch(indexMock, documents)).rejects.toThrow( + 'Network timeout', + ); + + expect(mockAddDocumentsInBatches).toHaveBeenCalled(); + }); + + test('processSyncBatch handles empty document array gracefully', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + const indexMock = mockIndex(); + + // Should not throw with empty array + await expect(conversationModel.processSyncBatch(indexMock, [])).resolves.not.toThrow(); + + // Should not call addDocumentsInBatches + expect(mockAddDocumentsInBatches).not.toHaveBeenCalled(); + }); + + test('syncWithMeili stops processing when batch fails and does not process remaining documents', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + + // Create multiple documents + for (let i = 0; i < 5; i++) { + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: `Test Conversation ${i}`, + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + } + + // Mock addDocumentsInBatches to fail on first call + mockAddDocumentsInBatches.mockRejectedValueOnce(new Error('First batch failed')); + + // Sync should fail on the first batch + await expect(conversationModel.syncWithMeili()).rejects.toThrow('First batch failed'); + + // Should have attempted only once before failing + expect(mockAddDocumentsInBatches).toHaveBeenCalledTimes(1); + + // No documents should be indexed since sync failed + const indexedCount = await conversationModel.countDocuments({ _meiliIndex: true }); + expect(indexedCount).toBe(0); + }); + + test('error in processSyncBatch is properly logged before being thrown', async () => { + const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods; + await messageModel.deleteMany({}); + + const testError = new Error('Test error for logging'); + mockAddDocumentsInBatches.mockRejectedValueOnce(testError); + + await messageModel.collection.insertOne({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + _meiliIndex: false, + expiredAt: null, + }); + + const indexMock = mockIndex(); + const documents = await messageModel.find({ _meiliIndex: false }).lean(); + + // Should throw the same error that was passed to it + await expect(messageModel.processSyncBatch(indexMock, documents)).rejects.toThrow(testError); + }); + + test('syncWithMeili properly propagates processSyncBatch errors', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + const customError = new Error('Custom sync error'); + mockAddDocumentsInBatches.mockRejectedValueOnce(customError); + + // The error should propagate all the way up + await expect(conversationModel.syncWithMeili()).rejects.toThrow('Custom sync error'); + }); + }); }); diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.ts b/packages/data-schemas/src/models/plugins/mongoMeili.ts index 548a7d2f1a..2551c35d99 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.ts @@ -50,17 +50,11 @@ interface _DocumentWithMeiliIndex extends Document { export type DocumentWithMeiliIndex = _DocumentWithMeiliIndex & IConversation & Partial; export interface SchemaWithMeiliMethods extends Model { - syncWithMeili(options?: { resumeFromId?: string }): Promise; + syncWithMeili(): Promise; getSyncProgress(): Promise; processSyncBatch( index: Index, documents: Array>, - updateOps: Array<{ - updateOne: { - filter: Record; - update: { $set: { _meiliIndex: boolean } }; - }; - }>, ): Promise; cleanupMeiliIndex( index: Index, @@ -156,8 +150,8 @@ const createMeiliMongooseModel = ({ * Get the current sync progress */ static async getSyncProgress(this: SchemaWithMeiliMethods): Promise { - const totalDocuments = await this.countDocuments(); - const indexedDocuments = await this.countDocuments({ _meiliIndex: true }); + const totalDocuments = await this.countDocuments({ expiredAt: null }); + const indexedDocuments = await this.countDocuments({ expiredAt: null, _meiliIndex: true }); return { totalProcessed: indexedDocuments, @@ -167,106 +161,79 @@ const createMeiliMongooseModel = ({ } /** - * Synchronizes the data between the MongoDB collection and the MeiliSearch index. - * Now uses streaming and batching to reduce memory usage. - */ - static async syncWithMeili( - this: SchemaWithMeiliMethods, - options?: { resumeFromId?: string }, - ): Promise { + * Synchronizes data between the MongoDB collection and the MeiliSearch index by + * incrementally indexing only documents where `expiredAt` is `null` and `_meiliIndex` is `false` + * (i.e., non-expired documents that have not yet been indexed). + * */ + static async syncWithMeili(this: SchemaWithMeiliMethods): Promise { + const startTime = Date.now(); + const { batchSize, delayMs } = syncConfig; + + const collectionName = primaryKey === 'messageId' ? 'messages' : 'conversations'; + logger.info( + `[syncWithMeili] Starting sync for ${collectionName} with batch size ${batchSize}`, + ); + + // Get approximate total count for raw estimation, the sync should not overcome this number + const approxTotalCount = await this.estimatedDocumentCount(); + logger.info( + `[syncWithMeili] Approximate total number of all ${collectionName}: ${approxTotalCount}`, + ); + try { - const startTime = Date.now(); - const { batchSize, delayMs } = syncConfig; - - logger.info( - `[syncWithMeili] Starting sync for ${primaryKey === 'messageId' ? 'messages' : 'conversations'} with batch size ${batchSize}`, - ); - - // Build query with resume capability - // Do not sync TTL documents - const query: FilterQuery = { expiredAt: null }; - if (options?.resumeFromId) { - query._id = { $gt: options.resumeFromId }; - } - - // Get approximate total count for progress tracking - const approxTotalCount = await this.estimatedDocumentCount(); - logger.info(`[syncWithMeili] Approximate total number of documents to sync: ${approxTotalCount}`); - - let processedCount = 0; - // First, handle documents that need to be removed from Meili + logger.info(`[syncWithMeili] Starting cleanup of Meili index ${index.uid} before sync`); await this.cleanupMeiliIndex(index, primaryKey, batchSize, delayMs); - - // Process MongoDB documents in batches using cursor - const cursor = this.find(query) - .select(attributesToIndex.join(' ') + ' _meiliIndex') - .sort({ _id: 1 }) - .batchSize(batchSize) - .cursor(); - - const format = (doc: Record) => - _.omitBy(_.pick(doc, attributesToIndex), (v, k) => k.startsWith('$')); - - let documentBatch: Array> = []; - let updateOps: Array<{ - updateOne: { - filter: Record; - update: { $set: { _meiliIndex: boolean } }; - }; - }> = []; - - // Process documents in streaming fashion - for await (const doc of cursor) { - const typedDoc = doc.toObject() as unknown as Record; - const formatted = format(typedDoc); - - // Check if document needs indexing - if (!typedDoc._meiliIndex) { - documentBatch.push(formatted); - updateOps.push({ - updateOne: { - filter: { _id: typedDoc._id }, - update: { $set: { _meiliIndex: true } }, - }, - }); - } - - processedCount++; - - // Process batch when it reaches the configured size - if (documentBatch.length >= batchSize) { - await this.processSyncBatch(index, documentBatch, updateOps); - documentBatch = []; - updateOps = []; - - // Log progress - // Calculate percentage based on approximate total count sometimes might lead to more than 100% - // the difference is very small and acceptable for progress tracking - const percent = Math.round((processedCount / approxTotalCount) * 100); - const progress = Math.min(percent, 100); - logger.info(`[syncWithMeili] Progress: ${progress}% (count: ${processedCount})`); - - // Add delay to prevent overwhelming resources - if (delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - } - - // Process remaining documents - if (documentBatch.length > 0) { - await this.processSyncBatch(index, documentBatch, updateOps); - } - - const duration = Date.now() - startTime; - logger.info( - `[syncWithMeili] Completed sync for ${primaryKey === 'messageId' ? 'messages' : 'conversations'} in ${duration}ms`, - ); + logger.info(`[syncWithMeili] Completed cleanup of Meili index: ${index.uid}`); } catch (error) { - logger.error('[syncWithMeili] Error during sync:', error); + logger.error('[syncWithMeili] Error during cleanup Meili before sync:', error); throw error; } + + let processedCount = 0; + let hasMore = true; + + while (hasMore) { + const query: FilterQuery = { + expiredAt: null, + _meiliIndex: false, + }; + + try { + const documents = await this.find(query) + .select(attributesToIndex.join(' ') + ' _meiliIndex') + .limit(batchSize) + .lean(); + + // Check if there are more documents to process + if (documents.length === 0) { + logger.info('[syncWithMeili] No more documents to process'); + break; + } + + // Process the batch + await this.processSyncBatch(index, documents); + processedCount += documents.length; + logger.info(`[syncWithMeili] Processed: ${processedCount}`); + + if (documents.length < batchSize) { + hasMore = false; + } + + // Add delay to prevent overwhelming resources + if (hasMore && delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } catch (error) { + logger.error('[syncWithMeili] Error processing documents batch:', error); + throw error; + } + } + + const duration = Date.now() - startTime; + logger.info( + `[syncWithMeili] Completed sync for ${collectionName}. Processed ${processedCount} documents in ${duration}ms`, + ); } /** @@ -276,28 +243,26 @@ const createMeiliMongooseModel = ({ this: SchemaWithMeiliMethods, index: Index, documents: Array>, - updateOps: Array<{ - updateOne: { - filter: Record; - update: { $set: { _meiliIndex: boolean } }; - }; - }>, ): Promise { if (documents.length === 0) { return; } + // Format documents for MeiliSearch + const formattedDocs = documents.map((doc) => + _.omitBy(_.pick(doc, attributesToIndex), (_v, k) => k.startsWith('$')), + ); + try { // Add documents to MeiliSearch - await index.addDocuments(documents); + await index.addDocumentsInBatches(formattedDocs); // Update MongoDB to mark documents as indexed - if (updateOps.length > 0) { - await this.collection.bulkWrite(updateOps); - } + const docsIds = documents.map((doc) => doc._id); + await this.updateMany({ _id: { $in: docsIds } }, { $set: { _meiliIndex: true } }); } catch (error) { logger.error('[processSyncBatch] Error processing batch:', error); - // Don't throw - allow sync to continue with other documents + throw error; } } @@ -336,7 +301,7 @@ const createMeiliMongooseModel = ({ // Delete documents that don't exist in MongoDB const toDelete = meiliIds.filter((id) => !existingIds.has(id)); if (toDelete.length > 0) { - await Promise.all(toDelete.map((id) => index.deleteDocument(id as string))); + await index.deleteDocuments(toDelete.map(String)); logger.debug(`[cleanupMeiliIndex] Deleted ${toDelete.length} orphaned documents`); } From f7893d9507986c378f913892c0f830ac7212aa25 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 16 Jan 2026 17:45:18 -0500 Subject: [PATCH 010/147] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Update=20Z-index?= =?UTF-8?q?=20values=20for=20Navigation=20and=20Mask=20layers=20(#11375)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 fix: Update z-index values for navigation and mask layers in mobile view - Increased z-index of the .nav-mask class from 63 to 105 for improved layering. - Updated z-index of the nav component from 70 to 110 to ensure it appears above other elements. * 🔧 fix: Adjust z-index for navigation component in mobile view - Updated the z-index of the .nav class from 64 to 110 to ensure proper layering above other elements. * 🔧 fix: Standardize z-index values across conversation and navigation components - Updated z-index to 125 for various components including ConvoOptions, AccountSettings, BookmarkNav, and FavoriteItem to ensure consistent layering and visibility across the application. --- .../components/Conversations/ConvoOptions/ConvoOptions.tsx | 2 +- client/src/components/Nav/AccountSettings.tsx | 2 +- client/src/components/Nav/Bookmarks/BookmarkNav.tsx | 1 + client/src/components/Nav/Favorites/FavoriteItem.tsx | 1 + client/src/components/Nav/Nav.tsx | 2 +- client/src/mobile.css | 4 ++-- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index 14c6b424b4..2ad167a80c 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -294,6 +294,7 @@ function ConvoOptions({ portal={true} menuId={menuId} focusLoop={true} + className="z-[125]" unmountOnHide={true} isOpen={isPopoverActive} setIsOpen={setIsPopoverActive} @@ -321,7 +322,6 @@ function ConvoOptions({ } items={dropdownItems} - className="z-30" /> {showShareDialog && ( = ({ tags, setTags }: BookmarkNavProps) unmountOnHide={true} setIsOpen={setIsMenuOpen} keyPrefix="bookmark-nav-" + className="z-[125]" trigger={ Date: Sat, 17 Jan 2026 16:48:43 -0500 Subject: [PATCH 011/147] =?UTF-8?q?=F0=9F=AB=99=20fix:=20Cache=20Control?= =?UTF-8?q?=20Immutability=20for=20Multi-Agents=20(#11383)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 fix: Update @librechat/agents version to 3.0.771 in package.json and package-lock.json * 🔧 fix: Update @librechat/agents version to 3.0.772 in package.json and package-lock.json * 🔧 fix: Update @librechat/agents version to 3.0.774 in package.json and package-lock.json --- api/package.json | 2 +- package-lock.json | 10 +++++----- packages/api/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/package.json b/api/package.json index 9ceb9b624c..f528bcf303 100644 --- a/api/package.json +++ b/api/package.json @@ -45,7 +45,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.77", + "@librechat/agents": "^3.0.774", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index d9fd999fc6..066d870e36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.77", + "@librechat/agents": "^3.0.774", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -12646,9 +12646,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.0.77", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.77.tgz", - "integrity": "sha512-Wr9d8bjJAQSl03nEgnAPG6jBQT1fL3sNV3TFDN1FvFQt6WGfdok838Cbcn+/tSGXSPJcICTxNkMT7VN8P6bCPw==", + "version": "3.0.774", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.774.tgz", + "integrity": "sha512-Mf2KGhAPnkC+1i5O888Q0WDm1ybcNqZCI6yWBgbIn0EEJiHE3dMRHs9RAcBnR1e+bElRwQxBwXmTfKEtsTQ2ow==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -43129,7 +43129,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.77", + "@librechat/agents": "^3.0.774", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.25.2", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/package.json b/packages/api/package.json index d8a06aad2b..7bb9ec3c03 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -87,7 +87,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.77", + "@librechat/agents": "^3.0.774", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.25.2", "@smithy/node-http-handler": "^4.4.5", From 922cdafe81585879f49c5406b7894536b6cab0e4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 17 Jan 2026 17:05:12 -0500 Subject: [PATCH 012/147] =?UTF-8?q?=E2=9C=A8=20v0.8.2-rc3=20(#11384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 chore: Update version to v0.8.2-rc3 across multiple files * 🔧 chore: Update package versions for api, client, data-provider, and data-schemas --- Dockerfile | 2 +- Dockerfile.multi | 2 +- api/package.json | 2 +- bun.lock | 8 ++++---- client/jest.config.cjs | 2 +- client/package.json | 2 +- e2e/jestSetup.js | 2 +- helm/librechat/Chart.yaml | 4 ++-- package-lock.json | 16 ++++++++-------- package.json | 2 +- packages/api/package.json | 2 +- packages/client/package.json | 2 +- packages/data-provider/package.json | 2 +- packages/data-provider/src/config.ts | 2 +- packages/data-schemas/package.json | 2 +- 15 files changed, 26 insertions(+), 26 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5872440a33..54f84101c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# v0.8.2-rc2 +# v0.8.2-rc3 # Base node image FROM node:20-alpine AS node diff --git a/Dockerfile.multi b/Dockerfile.multi index ca66459a44..2e96f53b46 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,5 +1,5 @@ # Dockerfile.multi -# v0.8.2-rc2 +# v0.8.2-rc3 # Set configurable max-old-space-size with default ARG NODE_MAX_OLD_SPACE_SIZE=6144 diff --git a/api/package.json b/api/package.json index f528bcf303..f0ae89eb2f 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", diff --git a/bun.lock b/bun.lock index 783bfc762e..daebd2482f 100644 --- a/bun.lock +++ b/bun.lock @@ -254,7 +254,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.7.20", + "version": "1.7.21", "devDependencies": { "@babel/preset-env": "^7.21.5", "@babel/preset-react": "^7.18.6", @@ -321,7 +321,7 @@ }, "packages/client": { "name": "@librechat/client", - "version": "0.4.4", + "version": "0.4.50", "devDependencies": { "@babel/core": "^7.28.5", "@babel/preset-env": "^7.28.5", @@ -409,7 +409,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.8.220", + "version": "0.8.230", "dependencies": { "axios": "^1.12.1", "dayjs": "^1.11.13", @@ -447,7 +447,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.33", + "version": "0.0.34", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^29.0.0", diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 9a9f9f5451..1b7c664ae5 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,4 +1,4 @@ -/** v0.8.2-rc2 */ +/** v0.8.2-rc3 */ module.exports = { roots: ['/src'], testEnvironment: 'jsdom', diff --git a/client/package.json b/client/package.json index 81b2fdf255..d993695050 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "description": "", "type": "module", "scripts": { diff --git a/e2e/jestSetup.js b/e2e/jestSetup.js index ce2f7a89c2..4023741dc4 100644 --- a/e2e/jestSetup.js +++ b/e2e/jestSetup.js @@ -1,3 +1,3 @@ -// v0.8.2-rc2 +// v0.8.2-rc3 // See .env.test.example for an example of the '.env.test' file. require('dotenv').config({ path: './e2e/.env.test' }); diff --git a/helm/librechat/Chart.yaml b/helm/librechat/Chart.yaml index 996d6fc6f9..1791136dfa 100755 --- a/helm/librechat/Chart.yaml +++ b/helm/librechat/Chart.yaml @@ -15,7 +15,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.9.5 +version: 1.9.6 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to @@ -23,7 +23,7 @@ version: 1.9.5 # It is recommended to use it with quotes. # renovate: image=ghcr.io/danny-avila/librechat -appVersion: "v0.8.2-rc2" +appVersion: "v0.8.2-rc3" home: https://www.librechat.ai diff --git a/package-lock.json b/package-lock.json index 066d870e36..bcaee7a84f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "LibreChat", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "LibreChat", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "license": "ISC", "workspaces": [ "api", @@ -45,7 +45,7 @@ }, "api": { "name": "@librechat/backend", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.71.0", @@ -442,7 +442,7 @@ }, "client": { "name": "@librechat/frontend", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "license": "ISC", "dependencies": { "@ariakit/react": "^0.4.15", @@ -43087,7 +43087,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.7.20", + "version": "1.7.21", "license": "ISC", "devDependencies": { "@babel/preset-env": "^7.21.5", @@ -43198,7 +43198,7 @@ }, "packages/client": { "name": "@librechat/client", - "version": "0.4.4", + "version": "0.4.50", "devDependencies": { "@babel/core": "^7.28.5", "@babel/preset-env": "^7.28.5", @@ -45488,7 +45488,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.8.220", + "version": "0.8.230", "license": "ISC", "dependencies": { "axios": "^1.12.1", @@ -45546,7 +45546,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.33", + "version": "0.0.34", "license": "MIT", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", diff --git a/package.json b/package.json index 011551594c..13463acf4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "LibreChat", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "description": "", "workspaces": [ "api", diff --git a/packages/api/package.json b/packages/api/package.json index 7bb9ec3c03..146e54c1e9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/api", - "version": "1.7.20", + "version": "1.7.21", "type": "commonjs", "description": "MCP services for LibreChat", "main": "dist/index.js", diff --git a/packages/client/package.json b/packages/client/package.json index 9835f62ebc..374b88d352 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/client", - "version": "0.4.4", + "version": "0.4.50", "description": "React components for LibreChat", "repository": { "type": "git", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 63c7adb117..cac429ac96 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.8.220", + "version": "0.8.230", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index ebfcfa93f1..45c964cbd8 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1702,7 +1702,7 @@ export enum TTSProviders { /** Enum for app-wide constants */ export enum Constants { /** Key for the app's version. */ - VERSION = 'v0.8.2-rc2', + VERSION = 'v0.8.2-rc3', /** Key for the Custom Config's version (librechat.yaml). */ CONFIG_VERSION = '1.3.1', /** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */ diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index eb143c0dd6..57de950fbc 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/data-schemas", - "version": "0.0.33", + "version": "0.0.34", "description": "Mongoose schemas and models for LibreChat", "type": "module", "main": "dist/index.cjs", From 50376171313b9fa9d0936ae187639a47ddab1783 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 18 Jan 2026 11:59:26 -0500 Subject: [PATCH 013/147] =?UTF-8?q?=F0=9F=8E=A8=20fix:=20Layering=20for=20?= =?UTF-8?q?Right-hand=20Side=20Panel=20(#11392)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated the background color in mobile.css for improved visibility. * Refactored class names in SidePanelGroup.tsx to utilize a utility function for better consistency and maintainability. --- client/src/components/SidePanel/SidePanelGroup.tsx | 6 +++--- client/src/mobile.css | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/SidePanel/SidePanelGroup.tsx b/client/src/components/SidePanel/SidePanelGroup.tsx index 14473127b5..171947cd6b 100644 --- a/client/src/components/SidePanel/SidePanelGroup.tsx +++ b/client/src/components/SidePanel/SidePanelGroup.tsx @@ -6,7 +6,7 @@ import { ResizablePanel, ResizablePanelGroup, useMediaQuery } from '@librechat/c import type { ImperativePanelHandle } from 'react-resizable-panels'; import { useGetStartupConfig } from '~/data-provider'; import ArtifactsPanel from './ArtifactsPanel'; -import { normalizeLayout } from '~/utils'; +import { normalizeLayout, cn } from '~/utils'; import SidePanel from './SidePanel'; import store from '~/store'; @@ -149,9 +149,9 @@ const SidePanelGroup = memo( )} {!hideSidePanel && interfaceConfig.sidePanel === true && ( + + ); +}`; + + const artifactText = `${ARTIFACT_START}{identifier="react-app" type="application/vnd.react" title="React App"} +\`\`\`jsx +${jsxContent} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const updatedJsx = jsxContent.replace('Increment', 'Click me'); + const result = replaceArtifactContent(artifactText, artifacts[0], jsxContent, updatedJsx); + + expect(result).not.toBeNull(); + expect(result).toContain('Click me'); + expect(result).not.toContain('Increment'); + expect(result).toMatch(/```jsx\n/); + }); + + test('should handle mermaid diagram content', () => { + const mermaidContent = `graph TD + A[Start] --> B{Is it?} + B -->|Yes| C[OK] + B -->|No| D[End]`; + + const artifactText = `${ARTIFACT_START}{identifier="diagram" type="application/vnd.mermaid" title="Flow"} +\`\`\`mermaid +${mermaidContent} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const updatedMermaid = mermaidContent.replace('Start', 'Begin'); + const result = replaceArtifactContent( + artifactText, + artifacts[0], + mermaidContent, + updatedMermaid, + ); + + expect(result).not.toBeNull(); + expect(result).toContain('Begin'); + expect(result).toMatch(/```mermaid\n/); + }); + + test('should handle artifact without code block (plain text)', () => { + const content = 'Just plain text without code fences'; + const artifactText = `${ARTIFACT_START}{identifier="plain" type="text/plain" title="Plain"} +${content} +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const result = replaceArtifactContent( + artifactText, + artifacts[0], + content, + 'updated plain text', + ); + + expect(result).not.toBeNull(); + expect(result).toContain('updated plain text'); + expect(result).not.toContain('```'); + }); + + test('should handle multiline content with various newline patterns', () => { + const content = `Line 1 +Line 2 + +Line 4 after empty line + Indented line + Double indented`; + + const artifactText = `${ARTIFACT_START}{identifier="test" type="text/plain" title="Test"} +\`\`\` +${content} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const updated = content.replace('Line 1', 'First Line'); + const result = replaceArtifactContent(artifactText, artifacts[0], content, updated); + + expect(result).not.toBeNull(); + expect(result).toContain('First Line'); + expect(result).toContain(' Indented line'); + expect(result).toContain(' Double indented'); + }); }); }); From 32e6f3b8e50edd0cf3cdbbdd50927f588c4a827d Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:41:28 -0800 Subject: [PATCH 020/147] =?UTF-8?q?=F0=9F=93=A2=20fix:=20Alert=20for=20Age?= =?UTF-8?q?nt=20Builder=20Name=20Invalidation=20(#11430)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/SidePanel/Agents/AgentConfig.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index 2e247a00f0..a81ef780a9 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -209,6 +209,7 @@ export default function AgentConfig() { 'mt-1 w-56 text-sm text-red-500', errors.name ? 'visible h-auto' : 'invisible h-0', )} + role="alert" > {errors.name ? errors.name.message : ' '} From 36c5a88c4eca0a489cfd42bdf489a39d4dceb19d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 20 Jan 2026 14:43:19 -0500 Subject: [PATCH 021/147] =?UTF-8?q?=F0=9F=92=B0=20fix:=20Multi-Agent=20Tok?= =?UTF-8?q?en=20Spending=20&=20Prevent=20Double-Spend=20(#11433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Token Spending Logic for Multi-Agents on Abort Scenarios * Implemented logic to skip token spending if a conversation is aborted, preventing double-spending. * Introduced `spendCollectedUsage` function to handle token spending for multiple models during aborts, ensuring accurate accounting for parallel agents. * Updated `GenerationJobManager` to store and retrieve collected usage data for improved abort handling. * Added comprehensive tests for the new functionality, covering various scenarios including cache token handling and parallel agent usage. * fix: Memory Context Handling for Multi-Agents * Refactored `buildMessages` method to pass memory context to parallel agents, ensuring they share the same user context. * Improved handling of memory context when no existing instructions are present for parallel agents. * Added comprehensive tests to verify memory context propagation and behavior under various scenarios, including cases with no memory available and empty agent configurations. * Enhanced logging for better traceability of memory context additions to agents. * chore: Memory Context Documentation for Parallel Agents * Updated documentation in the `AgentClient` class to clarify the in-place mutation of agentConfig objects when passing memory context to parallel agents. * Added notes on the implications of mutating objects directly to ensure all parallel agents receive the correct memory context before execution. * chore: UsageMetadata Interface docs for Token Spending * Expanded the UsageMetadata interface to support both OpenAI and Anthropic cache token formats. * Added detailed documentation for cache token properties, including mutually exclusive fields for different model types. * Improved clarity on how to access cache token details for accurate token spending tracking. * fix: Enhance Token Spending Logic in Abort Middleware * Refactored `spendCollectedUsage` function to utilize Promise.all for concurrent token spending, improving performance and ensuring all operations complete before clearing the collectedUsage array. * Added documentation to clarify the importance of clearing the collectedUsage array to prevent double-spending in abort scenarios. * Updated tests to verify the correct behavior of the spending logic and the clearing of the array after spending operations. --- api/server/controllers/agents/client.js | 45 +- api/server/controllers/agents/client.test.js | 220 ++++++++ api/server/middleware/abortMiddleware.js | 100 +++- api/server/middleware/abortMiddleware.spec.js | 428 ++++++++++++++++ .../services/Endpoints/agents/initialize.js | 9 +- .../api/src/stream/GenerationJobManager.ts | 41 +- .../stream/__tests__/collectedUsage.spec.ts | 482 ++++++++++++++++++ .../implementations/InMemoryJobStore.ts | 31 +- .../stream/implementations/RedisJobStore.ts | 38 +- packages/api/src/stream/index.ts | 5 +- .../api/src/stream/interfaces/IJobStore.ts | 69 +++ 11 files changed, 1440 insertions(+), 28 deletions(-) create mode 100644 api/server/middleware/abortMiddleware.spec.js create mode 100644 packages/api/src/stream/__tests__/collectedUsage.spec.ts diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 2b5872411b..5f3618de4c 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -522,14 +522,36 @@ class AgentClient extends BaseClient { } const withoutKeys = await this.useMemory(); - if (withoutKeys) { - systemContent += `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`; + const memoryContext = withoutKeys + ? `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}` + : ''; + if (memoryContext) { + systemContent += memoryContext; } if (systemContent) { this.options.agent.instructions = systemContent; } + /** + * Pass memory context to parallel agents (addedConvo) so they have the same user context. + * + * NOTE: This intentionally mutates the agentConfig objects in place. The agentConfigs Map + * holds references to config objects that will be passed to the graph runtime. Mutating + * them here ensures all parallel agents receive the memory context before execution starts. + * Creating new objects would not work because the Map references would still point to the old objects. + */ + if (memoryContext && this.agentConfigs?.size > 0) { + for (const [agentId, agentConfig] of this.agentConfigs.entries()) { + if (agentConfig.instructions) { + agentConfig.instructions = agentConfig.instructions + '\n\n' + memoryContext; + } else { + agentConfig.instructions = memoryContext; + } + logger.debug(`[AgentClient] Added memory context to parallel agent: ${agentId}`); + } + } + return result; } @@ -1084,11 +1106,20 @@ class AgentClient extends BaseClient { this.artifactPromises.push(...attachments); } - await this.recordCollectedUsage({ - context: 'message', - balance: balanceConfig, - transactions: transactionsConfig, - }); + /** Skip token spending if aborted - the abort handler (abortMiddleware.js) handles it + This prevents double-spending when user aborts via `/api/agents/chat/abort` */ + const wasAborted = abortController?.signal?.aborted; + if (!wasAborted) { + await this.recordCollectedUsage({ + context: 'message', + balance: balanceConfig, + transactions: transactionsConfig, + }); + } else { + logger.debug( + '[api/server/controllers/agents/client.js #chatCompletion] Skipping token spending - handled by abort middleware', + ); + } } catch (err) { logger.error( '[api/server/controllers/agents/client.js #chatCompletion] Error in cleanup phase', diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 14f0df9bb0..402d011fd6 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -1849,4 +1849,224 @@ describe('AgentClient - titleConvo', () => { }); }); }); + + describe('buildMessages - memory context for parallel agents', () => { + let client; + let mockReq; + let mockRes; + let mockAgent; + let mockOptions; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAgent = { + id: 'primary-agent', + name: 'Primary Agent', + endpoint: EModelEndpoint.openAI, + provider: EModelEndpoint.openAI, + instructions: 'Primary agent instructions', + model_parameters: { + model: 'gpt-4', + }, + tools: [], + }; + + mockReq = { + user: { + id: 'user-123', + personalization: { + memories: true, + }, + }, + body: { + endpoint: EModelEndpoint.openAI, + }, + config: { + memory: { + disabled: false, + }, + }, + }; + + mockRes = {}; + + mockOptions = { + req: mockReq, + res: mockRes, + agent: mockAgent, + endpoint: EModelEndpoint.agents, + }; + + client = new AgentClient(mockOptions); + client.conversationId = 'convo-123'; + client.responseMessageId = 'response-123'; + client.shouldSummarize = false; + client.maxContextTokens = 4096; + }); + + it('should pass memory context to parallel agents (addedConvo)', async () => { + const memoryContent = 'User prefers dark mode. User is a software developer.'; + client.useMemory = jest.fn().mockResolvedValue(memoryContent); + + const parallelAgent1 = { + id: 'parallel-agent-1', + name: 'Parallel Agent 1', + instructions: 'Parallel agent 1 instructions', + provider: EModelEndpoint.openAI, + }; + + const parallelAgent2 = { + id: 'parallel-agent-2', + name: 'Parallel Agent 2', + instructions: 'Parallel agent 2 instructions', + provider: EModelEndpoint.anthropic, + }; + + client.agentConfigs = new Map([ + ['parallel-agent-1', parallelAgent1], + ['parallel-agent-2', parallelAgent2], + ]); + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await client.buildMessages(messages, null, { + instructions: 'Base instructions', + additional_instructions: null, + }); + + expect(client.useMemory).toHaveBeenCalled(); + + expect(client.options.agent.instructions).toContain('Base instructions'); + expect(client.options.agent.instructions).toContain(memoryContent); + + expect(parallelAgent1.instructions).toContain('Parallel agent 1 instructions'); + expect(parallelAgent1.instructions).toContain(memoryContent); + + expect(parallelAgent2.instructions).toContain('Parallel agent 2 instructions'); + expect(parallelAgent2.instructions).toContain(memoryContent); + }); + + it('should not modify parallel agents when no memory context is available', async () => { + client.useMemory = jest.fn().mockResolvedValue(undefined); + + const parallelAgent = { + id: 'parallel-agent-1', + name: 'Parallel Agent 1', + instructions: 'Original parallel instructions', + provider: EModelEndpoint.openAI, + }; + + client.agentConfigs = new Map([['parallel-agent-1', parallelAgent]]); + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await client.buildMessages(messages, null, { + instructions: 'Base instructions', + additional_instructions: null, + }); + + expect(parallelAgent.instructions).toBe('Original parallel instructions'); + }); + + it('should handle parallel agents without existing instructions', async () => { + const memoryContent = 'User is a data scientist.'; + client.useMemory = jest.fn().mockResolvedValue(memoryContent); + + const parallelAgentNoInstructions = { + id: 'parallel-agent-no-instructions', + name: 'Parallel Agent No Instructions', + provider: EModelEndpoint.openAI, + }; + + client.agentConfigs = new Map([ + ['parallel-agent-no-instructions', parallelAgentNoInstructions], + ]); + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await client.buildMessages(messages, null, { + instructions: null, + additional_instructions: null, + }); + + expect(parallelAgentNoInstructions.instructions).toContain(memoryContent); + }); + + it('should not modify agentConfigs when none exist', async () => { + const memoryContent = 'User prefers concise responses.'; + client.useMemory = jest.fn().mockResolvedValue(memoryContent); + + client.agentConfigs = null; + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await expect( + client.buildMessages(messages, null, { + instructions: 'Base instructions', + additional_instructions: null, + }), + ).resolves.not.toThrow(); + + expect(client.options.agent.instructions).toContain(memoryContent); + }); + + it('should handle empty agentConfigs map', async () => { + const memoryContent = 'User likes detailed explanations.'; + client.useMemory = jest.fn().mockResolvedValue(memoryContent); + + client.agentConfigs = new Map(); + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await expect( + client.buildMessages(messages, null, { + instructions: 'Base instructions', + additional_instructions: null, + }), + ).resolves.not.toThrow(); + + expect(client.options.agent.instructions).toContain(memoryContent); + }); + }); }); diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index b85f1439cc..d07a09682d 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -7,13 +7,89 @@ const { sanitizeMessageForTransmit, } = require('@librechat/api'); const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider'); +const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { truncateText, smartTruncateText } = require('~/app/clients/prompts'); const clearPendingReq = require('~/cache/clearPendingReq'); const { sendError } = require('~/server/middleware/error'); -const { spendTokens } = require('~/models/spendTokens'); const { saveMessage, getConvo } = require('~/models'); const { abortRun } = require('./abortRun'); +/** + * Spend tokens for all models from collected usage. + * This handles both sequential and parallel agent execution. + * + * IMPORTANT: After spending, this function clears the collectedUsage array + * to prevent double-spending. The array is shared with AgentClient.collectedUsage, + * so clearing it here prevents the finally block from also spending tokens. + * + * @param {Object} params + * @param {string} params.userId - User ID + * @param {string} params.conversationId - Conversation ID + * @param {Array} params.collectedUsage - Usage metadata from all models + * @param {string} [params.fallbackModel] - Fallback model name if not in usage + */ +async function spendCollectedUsage({ userId, conversationId, collectedUsage, fallbackModel }) { + if (!collectedUsage || collectedUsage.length === 0) { + return; + } + + const spendPromises = []; + + for (const usage of collectedUsage) { + if (!usage) { + continue; + } + + // Support both OpenAI format (input_token_details) and Anthropic format (cache_*_input_tokens) + const cache_creation = + Number(usage.input_token_details?.cache_creation) || + Number(usage.cache_creation_input_tokens) || + 0; + const cache_read = + Number(usage.input_token_details?.cache_read) || Number(usage.cache_read_input_tokens) || 0; + + const txMetadata = { + context: 'abort', + conversationId, + user: userId, + model: usage.model ?? fallbackModel, + }; + + if (cache_creation > 0 || cache_read > 0) { + spendPromises.push( + spendStructuredTokens(txMetadata, { + promptTokens: { + input: usage.input_tokens, + write: cache_creation, + read: cache_read, + }, + completionTokens: usage.output_tokens, + }).catch((err) => { + logger.error('[abortMiddleware] Error spending structured tokens for abort', err); + }), + ); + continue; + } + + spendPromises.push( + spendTokens(txMetadata, { + promptTokens: usage.input_tokens, + completionTokens: usage.output_tokens, + }).catch((err) => { + logger.error('[abortMiddleware] Error spending tokens for abort', err); + }), + ); + } + + // Wait for all token spending to complete + await Promise.all(spendPromises); + + // Clear the array to prevent double-spending from the AgentClient finally block. + // The collectedUsage array is shared by reference with AgentClient.collectedUsage, + // so clearing it here ensures recordCollectedUsage() sees an empty array and returns early. + collectedUsage.length = 0; +} + /** * Abort an active message generation. * Uses GenerationJobManager for all agent requests. @@ -39,9 +115,8 @@ async function abortMessage(req, res) { return; } - const { jobData, content, text } = abortResult; + const { jobData, content, text, collectedUsage } = abortResult; - // Count tokens and spend them const completionTokens = await countTokens(text); const promptTokens = jobData?.promptTokens ?? 0; @@ -62,10 +137,21 @@ async function abortMessage(req, res) { tokenCount: completionTokens, }; - await spendTokens( - { ...responseMessage, context: 'incomplete', user: userId }, - { promptTokens, completionTokens }, - ); + // Spend tokens for ALL models from collectedUsage (handles parallel agents/addedConvo) + if (collectedUsage && collectedUsage.length > 0) { + await spendCollectedUsage({ + userId, + conversationId: jobData?.conversationId, + collectedUsage, + fallbackModel: jobData?.model, + }); + } else { + // Fallback: no collected usage, use text-based token counting for primary model only + await spendTokens( + { ...responseMessage, context: 'incomplete', user: userId }, + { promptTokens, completionTokens }, + ); + } await saveMessage( req, diff --git a/api/server/middleware/abortMiddleware.spec.js b/api/server/middleware/abortMiddleware.spec.js new file mode 100644 index 0000000000..93f2ce558b --- /dev/null +++ b/api/server/middleware/abortMiddleware.spec.js @@ -0,0 +1,428 @@ +/** + * 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. + */ + +const mockSpendTokens = jest.fn().mockResolvedValue(); +const mockSpendStructuredTokens = jest.fn().mockResolvedValue(); + +jest.mock('~/models/spendTokens', () => ({ + spendTokens: (...args) => mockSpendTokens(...args), + spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args), +})); + +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(), + }, + 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(), +})); + +jest.mock('~/models', () => ({ + saveMessage: jest.fn().mockResolvedValue(), + getConvo: jest.fn().mockResolvedValue({ title: 'Test Chat' }), +})); + +jest.mock('./abortRun', () => ({ + abortRun: jest.fn(), +})); + +// Import the module after mocks are set up +// We need to extract the spendCollectedUsage function for testing +// Since it's not exported, we'll test it through the handleAbort flow + +describe('abortMiddleware - spendCollectedUsage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('spendCollectedUsage logic', () => { + // Since spendCollectedUsage is not exported, we test the logic directly + // by replicating the function here for unit testing + + const spendCollectedUsage = async ({ + userId, + conversationId, + collectedUsage, + fallbackModel, + }) => { + if (!collectedUsage || collectedUsage.length === 0) { + return; + } + + const spendPromises = []; + + for (const usage of collectedUsage) { + if (!usage) { + continue; + } + + const cache_creation = + Number(usage.input_token_details?.cache_creation) || + Number(usage.cache_creation_input_tokens) || + 0; + const cache_read = + Number(usage.input_token_details?.cache_read) || + Number(usage.cache_read_input_tokens) || + 0; + + const txMetadata = { + context: 'abort', + conversationId, + user: userId, + model: usage.model ?? fallbackModel, + }; + + if (cache_creation > 0 || cache_read > 0) { + spendPromises.push( + mockSpendStructuredTokens(txMetadata, { + promptTokens: { + input: usage.input_tokens, + write: cache_creation, + read: cache_read, + }, + completionTokens: usage.output_tokens, + }).catch(() => { + // Log error but don't throw + }), + ); + continue; + } + + spendPromises.push( + mockSpendTokens(txMetadata, { + promptTokens: usage.input_tokens, + completionTokens: usage.output_tokens, + }).catch(() => { + // Log error but don't throw + }), + ); + } + + // Wait for all token spending to complete + await Promise.all(spendPromises); + + // Clear the array to prevent double-spending + collectedUsage.length = 0; + }; + + it('should return early if collectedUsage is empty', async () => { + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage: [], + fallbackModel: 'gpt-4', + }); + + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).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(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + }); + + it('should skip null entries in collectedUsage', async () => { + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + null, + { input_tokens: 200, output_tokens: 60, model: 'gpt-4' }, + ]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gpt-4', + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(2); + }); + + it('should spend tokens for single model', async () => { + const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gpt-4', + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(1); + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ + context: 'abort', + conversationId: 'convo-123', + user: 'user-123', + model: 'gpt-4', + }), + { promptTokens: 100, completionTokens: 50 }, + ); + }); + + it('should spend tokens 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(mockSpendTokens).toHaveBeenCalledTimes(3); + + // Verify each model was called + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ model: 'gpt-4' }), + { promptTokens: 100, completionTokens: 50 }, + ); + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ model: 'claude-3' }), + { promptTokens: 80, completionTokens: 40 }, + ); + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ model: 'gemini-pro' }), + { promptTokens: 120, completionTokens: 60 }, + ); + }); + + it('should use fallbackModel when usage.model is missing', async () => { + const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'fallback-model', + }); + + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'fallback-model' }), + expect.any(Object), + ); + }); + + it('should use spendStructuredTokens for OpenAI format cache tokens', async () => { + const collectedUsage = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'gpt-4', + input_token_details: { + cache_creation: 20, + cache_read: 10, + }, + }, + ]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gpt-4', + }); + + expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'gpt-4', context: 'abort' }), + { + promptTokens: { + input: 100, + write: 20, + read: 10, + }, + completionTokens: 50, + }, + ); + }); + + it('should use spendStructuredTokens for Anthropic format cache tokens', async () => { + const collectedUsage = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'claude-3', + cache_creation_input_tokens: 25, + cache_read_input_tokens: 15, + }, + ]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'claude-3', + }); + + expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'claude-3' }), + { + promptTokens: { + input: 100, + write: 25, + read: 15, + }, + completionTokens: 50, + }, + ); + }); + + it('should handle mixed cache and non-cache entries', async () => { + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { + input_tokens: 150, + output_tokens: 30, + model: 'claude-3', + cache_creation_input_tokens: 20, + cache_read_input_tokens: 10, + }, + { input_tokens: 200, output_tokens: 20, model: 'gemini-pro' }, + ]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gpt-4', + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(2); + expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); + }); + + it('should handle real-world parallel agent abort scenario', async () => { + // Simulates: Primary agent (gemini) + addedConvo agent (gpt-5) aborted mid-stream + 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(mockSpendTokens).toHaveBeenCalledTimes(2); + + // Primary model + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ model: 'gemini-3-flash-preview' }), + { promptTokens: 31596, completionTokens: 151 }, + ); + + // Parallel model (addedConvo) + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ model: 'gpt-5.2' }), + { promptTokens: 28000, completionTokens: 120 }, + ); + }); + + it('should clear collectedUsage array after spending to prevent double-spending', async () => { + // This tests the race condition fix: 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. + 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(mockSpendTokens).toHaveBeenCalledTimes(2); + + // The array should be cleared after spending + expect(collectedUsage.length).toBe(0); + }); + + it('should await all token spending operations before clearing array', async () => { + // Ensure we don't clear the array before spending completes + let spendCallCount = 0; + mockSpendTokens.mockImplementation(async () => { + spendCallCount++; + // Simulate async delay + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + 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', + }); + + // Both spend calls should have completed + expect(spendCallCount).toBe(2); + + // Array should be cleared after awaiting + expect(collectedUsage.length).toBe(0); + }); + }); +}); diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 626beed153..a691480119 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -3,10 +3,11 @@ const { createContentAggregator } = require('@librechat/agents'); const { initializeAgent, validateAgentModel, - getCustomEndpointConfig, - createSequentialChainEdges, createEdgeCollector, filterOrphanedEdges, + GenerationJobManager, + getCustomEndpointConfig, + createSequentialChainEdges, } = require('@librechat/api'); const { EModelEndpoint, @@ -314,6 +315,10 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents, }); + if (streamId) { + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + } + return { client, userMCPAuthMap }; }; diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 13544fc445..26c2ef73a6 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -1,9 +1,11 @@ import { logger } from '@librechat/data-schemas'; import type { StandardGraph } from '@librechat/agents'; -import type { Agents } from 'librechat-data-provider'; +import { parseTextParts } from 'librechat-data-provider'; +import type { Agents, TMessageContentParts } from 'librechat-data-provider'; import type { SerializableJobData, IEventTransport, + UsageMetadata, AbortResult, IJobStore, } from './interfaces/IJobStore'; @@ -585,7 +587,14 @@ class GenerationJobManagerClass { if (!jobData) { logger.warn(`[GenerationJobManager] Cannot abort - job not found: ${streamId}`); - return { success: false, jobData: null, content: [], finalEvent: null }; + return { + text: '', + content: [], + jobData: null, + success: false, + finalEvent: null, + collectedUsage: [], + }; } // Emit abort signal for cross-replica support (Redis mode) @@ -599,15 +608,21 @@ class GenerationJobManagerClass { runtime.abortController.abort(); } - // Get content before clearing state + /** Content before clearing state */ const result = await this.jobStore.getContentParts(streamId); const content = result?.content ?? []; - // Detect "early abort" - aborted before any generation happened (e.g., during tool loading) - // In this case, no messages were saved to DB, so frontend shouldn't navigate to conversation + /** Collected usage for all models */ + const collectedUsage = this.jobStore.getCollectedUsage(streamId); + + /** Text from content parts for fallback token counting */ + const text = parseTextParts(content as TMessageContentParts[]); + + /** Detect "early abort" - aborted before any generation happened (e.g., during tool loading) + In this case, no messages were saved to DB, so frontend shouldn't navigate to conversation */ const isEarlyAbort = content.length === 0 && !jobData.responseMessageId; - // Create final event for abort + /** Final event for abort */ const userMessageId = jobData.userMessage?.messageId; const abortFinalEvent: t.ServerSentEvent = { @@ -669,6 +684,8 @@ class GenerationJobManagerClass { jobData, content, finalEvent: abortFinalEvent, + text, + collectedUsage, }; } @@ -933,6 +950,18 @@ class GenerationJobManagerClass { this.jobStore.setContentParts(streamId, contentParts); } + /** + * Set reference to the collectedUsage array. + * This array accumulates token usage from all models during generation. + */ + setCollectedUsage(streamId: string, collectedUsage: UsageMetadata[]): void { + // Use runtime state check for performance (sync check) + if (!this.runtimeState.has(streamId)) { + return; + } + this.jobStore.setCollectedUsage(streamId, collectedUsage); + } + /** * Set reference to the graph instance. */ diff --git a/packages/api/src/stream/__tests__/collectedUsage.spec.ts b/packages/api/src/stream/__tests__/collectedUsage.spec.ts new file mode 100644 index 0000000000..3e534b537a --- /dev/null +++ b/packages/api/src/stream/__tests__/collectedUsage.spec.ts @@ -0,0 +1,482 @@ +/** + * Tests for collected usage functionality in GenerationJobManager. + * + * This tests the storage and retrieval of collectedUsage for abort handling, + * ensuring all models (including parallel agents from addedConvo) have their + * tokens spent when a conversation is aborted. + */ + +import type { UsageMetadata } from '../interfaces/IJobStore'; + +describe('CollectedUsage - InMemoryJobStore', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should store and retrieve collectedUsage', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const streamId = 'test-stream-1'; + await store.createJob(streamId, 'user-1'); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + + store.setCollectedUsage(streamId, collectedUsage); + const retrieved = store.getCollectedUsage(streamId); + + expect(retrieved).toEqual(collectedUsage); + expect(retrieved).toHaveLength(2); + + await store.destroy(); + }); + + it('should return empty array when no collectedUsage set', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const streamId = 'test-stream-2'; + await store.createJob(streamId, 'user-1'); + + const retrieved = store.getCollectedUsage(streamId); + + expect(retrieved).toEqual([]); + + await store.destroy(); + }); + + it('should return empty array for non-existent stream', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const retrieved = store.getCollectedUsage('non-existent-stream'); + + expect(retrieved).toEqual([]); + + await store.destroy(); + }); + + it('should update collectedUsage when set multiple times', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const streamId = 'test-stream-3'; + await store.createJob(streamId, 'user-1'); + + const usage1: UsageMetadata[] = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; + store.setCollectedUsage(streamId, usage1); + + // Simulate more usage being added + const usage2: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + store.setCollectedUsage(streamId, usage2); + + const retrieved = store.getCollectedUsage(streamId); + expect(retrieved).toHaveLength(2); + + await store.destroy(); + }); + + it('should clear collectedUsage when clearContentState is called', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const streamId = 'test-stream-4'; + await store.createJob(streamId, 'user-1'); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + store.setCollectedUsage(streamId, collectedUsage); + + expect(store.getCollectedUsage(streamId)).toHaveLength(1); + + store.clearContentState(streamId); + + expect(store.getCollectedUsage(streamId)).toEqual([]); + + await store.destroy(); + }); + + it('should clear collectedUsage when job is deleted', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const streamId = 'test-stream-5'; + await store.createJob(streamId, 'user-1'); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + store.setCollectedUsage(streamId, collectedUsage); + + await store.deleteJob(streamId); + + expect(store.getCollectedUsage(streamId)).toEqual([]); + + await store.destroy(); + }); +}); + +describe('CollectedUsage - GenerationJobManager', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should set and retrieve collectedUsage through manager', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `manager-test-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + + // Retrieve through abort + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.collectedUsage).toEqual(collectedUsage); + expect(abortResult.collectedUsage).toHaveLength(2); + + await GenerationJobManager.destroy(); + }); + + it('should return empty collectedUsage when none set', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `no-usage-test-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.collectedUsage).toEqual([]); + + await GenerationJobManager.destroy(); + }); + + it('should not set collectedUsage if job does not exist', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + }); + + await GenerationJobManager.initialize(); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + // This should not throw, just silently do nothing + GenerationJobManager.setCollectedUsage('non-existent-stream', collectedUsage); + + const abortResult = await GenerationJobManager.abortJob('non-existent-stream'); + expect(abortResult.success).toBe(false); + + await GenerationJobManager.destroy(); + }); +}); + +describe('AbortJob - Text and CollectedUsage', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should extract text from content parts on abort', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `text-extract-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Set content parts with text + const contentParts = [ + { type: 'text', text: 'Hello ' }, + { type: 'text', text: 'world!' }, + ]; + GenerationJobManager.setContentParts(streamId, contentParts as never); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.text).toBe('Hello world!'); + expect(abortResult.success).toBe(true); + + await GenerationJobManager.destroy(); + }); + + it('should return empty text when no content parts', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `empty-text-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.text).toBe(''); + + await GenerationJobManager.destroy(); + }); + + it('should return both text and collectedUsage on abort', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `full-abort-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Set content parts + const contentParts = [{ type: 'text', text: 'Partial response...' }]; + GenerationJobManager.setContentParts(streamId, contentParts as never); + + // Set collected usage + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.success).toBe(true); + expect(abortResult.text).toBe('Partial response...'); + expect(abortResult.collectedUsage).toEqual(collectedUsage); + expect(abortResult.content).toHaveLength(1); + + await GenerationJobManager.destroy(); + }); + + it('should return empty values for non-existent job', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + }); + + await GenerationJobManager.initialize(); + + const abortResult = await GenerationJobManager.abortJob('non-existent-job'); + + expect(abortResult.success).toBe(false); + expect(abortResult.text).toBe(''); + expect(abortResult.collectedUsage).toEqual([]); + expect(abortResult.content).toEqual([]); + expect(abortResult.jobData).toBeNull(); + + await GenerationJobManager.destroy(); + }); +}); + +describe('Real-world Scenarios', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should handle parallel agent abort with collected usage', async () => { + /** + * Scenario: User aborts a conversation with addedConvo (parallel agents) + * - Primary agent: gemini-3-flash-preview + * - Parallel agent: gpt-5.2 + * Both should have their tokens spent on abort + */ + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `parallel-abort-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Simulate content from primary agent + const contentParts = [ + { type: 'text', text: 'Primary agent output...' }, + { type: 'text', text: 'More content...' }, + ]; + GenerationJobManager.setContentParts(streamId, contentParts as never); + + // Simulate collected usage from both agents (as would happen during generation) + const collectedUsage: UsageMetadata[] = [ + { + input_tokens: 31596, + output_tokens: 151, + model: 'gemini-3-flash-preview', + }, + { + input_tokens: 28000, + output_tokens: 120, + model: 'gpt-5.2', + }, + ]; + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + + // Abort the job + const abortResult = await GenerationJobManager.abortJob(streamId); + + // Verify both models' usage is returned + expect(abortResult.success).toBe(true); + expect(abortResult.collectedUsage).toHaveLength(2); + expect(abortResult.collectedUsage[0].model).toBe('gemini-3-flash-preview'); + expect(abortResult.collectedUsage[1].model).toBe('gpt-5.2'); + + // Verify text is extracted + expect(abortResult.text).toContain('Primary agent output'); + + await GenerationJobManager.destroy(); + }); + + it('should handle abort with cache tokens from Anthropic', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `cache-abort-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Anthropic-style cache tokens + const collectedUsage: UsageMetadata[] = [ + { + input_tokens: 788, + output_tokens: 163, + cache_creation_input_tokens: 30808, + cache_read_input_tokens: 0, + model: 'claude-opus-4-5-20251101', + }, + ]; + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.collectedUsage[0].cache_creation_input_tokens).toBe(30808); + + await GenerationJobManager.destroy(); + }); + + it('should handle abort with sequential tool calls usage', async () => { + /** + * Scenario: Single agent with multiple tool calls, aborted mid-execution + * Usage accumulates for each LLM call + */ + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `sequential-abort-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Usage from multiple sequential LLM calls (tool use pattern) + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, // Initial call + { input_tokens: 150, output_tokens: 30, model: 'gpt-4' }, // After tool result 1 + { input_tokens: 180, output_tokens: 20, model: 'gpt-4' }, // After tool result 2 (aborted here) + ]; + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.collectedUsage).toHaveLength(3); + // All three entries should be present for proper token accounting + + await GenerationJobManager.destroy(); + }); +}); diff --git a/packages/api/src/stream/implementations/InMemoryJobStore.ts b/packages/api/src/stream/implementations/InMemoryJobStore.ts index e4a5d5d3ad..cc82a69963 100644 --- a/packages/api/src/stream/implementations/InMemoryJobStore.ts +++ b/packages/api/src/stream/implementations/InMemoryJobStore.ts @@ -1,7 +1,12 @@ import { logger } from '@librechat/data-schemas'; import type { StandardGraph } from '@librechat/agents'; import type { Agents } from 'librechat-data-provider'; -import type { IJobStore, SerializableJobData, JobStatus } from '~/stream/interfaces/IJobStore'; +import type { + SerializableJobData, + UsageMetadata, + IJobStore, + JobStatus, +} from '~/stream/interfaces/IJobStore'; /** * Content state for a job - volatile, in-memory only. @@ -10,6 +15,7 @@ import type { IJobStore, SerializableJobData, JobStatus } from '~/stream/interfa interface ContentState { contentParts: Agents.MessageContentComplex[]; graphRef: WeakRef | null; + collectedUsage: UsageMetadata[]; } /** @@ -240,6 +246,7 @@ export class InMemoryJobStore implements IJobStore { this.contentState.set(streamId, { contentParts: [], graphRef: new WeakRef(graph), + collectedUsage: [], }); } } @@ -252,10 +259,30 @@ export class InMemoryJobStore implements IJobStore { if (existing) { existing.contentParts = contentParts; } else { - this.contentState.set(streamId, { contentParts, graphRef: null }); + this.contentState.set(streamId, { contentParts, graphRef: null, collectedUsage: [] }); } } + /** + * Set collected usage reference for a job. + */ + setCollectedUsage(streamId: string, collectedUsage: UsageMetadata[]): void { + const existing = this.contentState.get(streamId); + if (existing) { + existing.collectedUsage = collectedUsage; + } else { + this.contentState.set(streamId, { contentParts: [], graphRef: null, collectedUsage }); + } + } + + /** + * Get collected usage for a job. + */ + getCollectedUsage(streamId: string): UsageMetadata[] { + const state = this.contentState.get(streamId); + return state?.collectedUsage ?? []; + } + /** * Get content parts for a job. * Returns live content from stored reference. diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index 421fa30f2c..cce636d5a1 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -1,9 +1,14 @@ import { logger } from '@librechat/data-schemas'; import { createContentAggregator } from '@librechat/agents'; -import type { IJobStore, SerializableJobData, JobStatus } from '~/stream/interfaces/IJobStore'; import type { StandardGraph } from '@librechat/agents'; import type { Agents } from 'librechat-data-provider'; import type { Redis, Cluster } from 'ioredis'; +import type { + SerializableJobData, + UsageMetadata, + IJobStore, + JobStatus, +} from '~/stream/interfaces/IJobStore'; /** * Key prefixes for Redis storage. @@ -90,6 +95,13 @@ export class RedisJobStore implements IJobStore { */ private localGraphCache = new Map>(); + /** + * Local cache for collectedUsage arrays. + * Generation happens on a single instance, so collectedUsage is only available locally. + * For cross-replica abort, the abort handler falls back to text-based token counting. + */ + private localCollectedUsageCache = new Map(); + /** Cleanup interval in ms (1 minute) */ private cleanupIntervalMs = 60000; @@ -227,6 +239,7 @@ export class RedisJobStore implements IJobStore { async deleteJob(streamId: string): Promise { // Clear local caches this.localGraphCache.delete(streamId); + this.localCollectedUsageCache.delete(streamId); // Note: userJobs cleanup is handled lazily via self-healing in getActiveJobIdsByUser // In cluster mode, separate runningJobs (global) from stream-specific keys (same slot) @@ -290,6 +303,7 @@ export class RedisJobStore implements IJobStore { if (!job) { await this.redis.srem(KEYS.runningJobs, streamId); this.localGraphCache.delete(streamId); + this.localCollectedUsageCache.delete(streamId); cleaned++; continue; } @@ -298,6 +312,7 @@ export class RedisJobStore implements IJobStore { if (job.status !== 'running') { await this.redis.srem(KEYS.runningJobs, streamId); this.localGraphCache.delete(streamId); + this.localCollectedUsageCache.delete(streamId); cleaned++; continue; } @@ -382,6 +397,7 @@ export class RedisJobStore implements IJobStore { } // Clear local caches this.localGraphCache.clear(); + this.localCollectedUsageCache.clear(); // Don't close the Redis connection - it's shared logger.info('[RedisJobStore] Destroyed'); } @@ -406,11 +422,28 @@ export class RedisJobStore implements IJobStore { * No-op for Redis - content parts are reconstructed from chunks. * Metadata (agentId, groupId) is embedded directly on content parts by the agent runtime. */ - setContentParts(_streamId: string, _contentParts: Agents.MessageContentComplex[]): void { + setContentParts(): void { // Content parts are reconstructed from chunks during getContentParts // No separate storage needed } + /** + * Store collectedUsage reference in local cache. + * This is used for abort handling to spend tokens for all models. + * Note: Only available on the generating instance; cross-replica abort uses fallback. + */ + setCollectedUsage(streamId: string, collectedUsage: UsageMetadata[]): void { + this.localCollectedUsageCache.set(streamId, collectedUsage); + } + + /** + * Get collected usage for a job. + * Only available if this is the generating instance. + */ + getCollectedUsage(streamId: string): UsageMetadata[] { + return this.localCollectedUsageCache.get(streamId) ?? []; + } + /** * Get aggregated content - tries local cache first, falls back to Redis reconstruction. * @@ -528,6 +561,7 @@ export class RedisJobStore implements IJobStore { clearContentState(streamId: string): void { // Clear local caches immediately this.localGraphCache.delete(streamId); + this.localCollectedUsageCache.delete(streamId); // Fire and forget - async cleanup for Redis this.clearContentStateAsync(streamId).catch((err) => { diff --git a/packages/api/src/stream/index.ts b/packages/api/src/stream/index.ts index 4e9bab324c..74c13a2bf0 100644 --- a/packages/api/src/stream/index.ts +++ b/packages/api/src/stream/index.ts @@ -5,11 +5,12 @@ export { } from './GenerationJobManager'; export type { - AbortResult, SerializableJobData, + IEventTransport, + UsageMetadata, + AbortResult, JobStatus, IJobStore, - IEventTransport, } from './interfaces/IJobStore'; export { createStreamServices } from './createStreamServices'; diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts index 14611a7fad..af681fb2e9 100644 --- a/packages/api/src/stream/interfaces/IJobStore.ts +++ b/packages/api/src/stream/interfaces/IJobStore.ts @@ -45,6 +45,54 @@ export interface SerializableJobData { promptTokens?: number; } +/** + * Usage metadata for token spending across different LLM providers. + * + * This interface supports two mutually exclusive cache token formats: + * + * **OpenAI format** (GPT-4, o1, etc.): + * - Uses `input_token_details.cache_creation` and `input_token_details.cache_read` + * - Cache tokens are nested under the `input_token_details` object + * + * **Anthropic format** (Claude models): + * - Uses `cache_creation_input_tokens` and `cache_read_input_tokens` + * - Cache tokens are top-level properties + * + * When processing usage data, check both formats: + * ```typescript + * const cacheCreation = usage.input_token_details?.cache_creation + * || usage.cache_creation_input_tokens || 0; + * ``` + */ +export interface UsageMetadata { + /** Total input tokens (prompt tokens) */ + input_tokens?: number; + /** Total output tokens (completion tokens) */ + output_tokens?: number; + /** Model identifier that generated this usage */ + model?: string; + /** + * OpenAI-style cache token details. + * Present for OpenAI models (GPT-4, o1, etc.) + */ + input_token_details?: { + /** Tokens written to cache */ + cache_creation?: number; + /** Tokens read from cache */ + cache_read?: number; + }; + /** + * Anthropic-style cache creation tokens. + * Present for Claude models. Mutually exclusive with input_token_details. + */ + cache_creation_input_tokens?: number; + /** + * Anthropic-style cache read tokens. + * Present for Claude models. Mutually exclusive with input_token_details. + */ + cache_read_input_tokens?: number; +} + /** * Result returned from aborting a job - contains all data needed * for token spending and message saving without storing callbacks @@ -58,6 +106,10 @@ export interface AbortResult { content: Agents.MessageContentComplex[]; /** Final event to send to client */ finalEvent: unknown; + /** Concatenated text from all content parts for token counting fallback */ + text: string; + /** Collected usage metadata from all models for token spending */ + collectedUsage: UsageMetadata[]; } /** @@ -210,6 +262,23 @@ export interface IJobStore { * @param runSteps - Run steps to save */ saveRunSteps?(streamId: string, runSteps: Agents.RunStep[]): Promise; + + /** + * Set collected usage reference for a job. + * This array accumulates token usage from all models during generation. + * + * @param streamId - The stream identifier + * @param collectedUsage - Array of usage metadata from all models + */ + setCollectedUsage(streamId: string, collectedUsage: UsageMetadata[]): void; + + /** + * Get collected usage for a job. + * + * @param streamId - The stream identifier + * @returns Array of usage metadata or empty array + */ + getCollectedUsage(streamId: string): UsageMetadata[]; } /** From f09eec846253e50d47bf2c88ae8ca969e5beffcf Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:45:07 -0800 Subject: [PATCH 022/147] =?UTF-8?q?=E2=9C=85=20feat:=20Zod=20Email=20Valid?= =?UTF-8?q?ation=20at=20Login=20(#11434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Auth/LoginForm.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index 0a6e1e8614..c51c2002e3 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -5,6 +5,7 @@ import { ThemeContext, Spinner, Button, isDark } from '@librechat/client'; import type { TLoginUser, TStartupConfig } from 'librechat-data-provider'; import type { TAuthContext } from '~/common'; import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider'; +import { validateEmail } from '~/utils'; import { useLocalize } from '~/hooks'; type TLoginFormProps = { @@ -96,10 +97,9 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, {...register('email', { required: localize('com_auth_email_required'), maxLength: { value: 120, message: localize('com_auth_email_max_length') }, - pattern: { - value: useUsernameLogin ? /\S+/ : /\S+@\S+\.\S+/, - message: localize('com_auth_email_pattern'), - }, + validate: useUsernameLogin + ? undefined + : (value) => validateEmail(value, localize('com_auth_email_pattern')), })} aria-invalid={!!errors.email} className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none" From c5113a75a0ca16444f38c46c05d5bfdbb7e036f5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 20 Jan 2026 14:45:27 -0500 Subject: [PATCH 023/147] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Add=20`hasAgentAc?= =?UTF-8?q?cess`=20to=20dependencies=20in=20`useNewConvo`=20hook=20(#11427?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated the dependency array in the useNewConvo hook to include hasAgentAccess for improved state management and functionality. --- client/src/hooks/useNewConvo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index fd2e20e0ee..c468ab30a2 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -249,7 +249,7 @@ const useNewConvo = (index = 0) => { state: disableFocus ? {} : { focusChat: true }, }); }, - [endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data], + [endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data, hasAgentAccess], ); const newConversation = useCallback( From 24e182d20e64cb7ea8179d2975c28c2a42005d6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 09:25:02 -0500 Subject: [PATCH 024/147] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#11439)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/lv/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index f17bb9cb46..89a5d0552d 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -1398,7 +1398,7 @@ "com_ui_upload_image_input": "Augšupielādēt failu kā attēlu", "com_ui_upload_invalid": "Nederīgs augšupielādējamais fails. Attēlam jābūt tādam, kas nepārsniedz ierobežojumu.", "com_ui_upload_invalid_var": "Nederīgs augšupielādējams fails. Attēlam jābūt ne lielākam par {{0}} MB", - "com_ui_upload_ocr_text": "Augšupielādēt failu kā tekstu", + "com_ui_upload_ocr_text": "Augšupielādēt failu kā kontekstu", "com_ui_upload_provider": "Augšupielādēt pakalpojumu sniedzējam", "com_ui_upload_success": "Fails veiksmīgi augšupielādēts", "com_ui_upload_type": "Izvēlieties augšupielādes veidu", From e608c652e56f43994d5e6dfba70c367e7fee5f59 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:44:20 -0800 Subject: [PATCH 025/147] =?UTF-8?q?=E2=9C=82=EF=B8=8F=20fix:=20Clipped=20F?= =?UTF-8?q?ocus=20Outlines=20in=20Conversation=20Panel=20(#11438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: focus outline clipping in Conversations panel * chore: address Copilot comments --- client/src/components/Conversations/Conversations.tsx | 2 +- client/src/components/Conversations/Convo.tsx | 2 +- client/src/components/Conversations/ConvoLink.tsx | 2 +- client/src/components/Nav/Bookmarks/BookmarkNav.tsx | 1 + client/src/components/Nav/Favorites/FavoriteItem.tsx | 2 +- client/src/components/Nav/Favorites/FavoritesList.tsx | 2 +- client/src/components/Nav/NewChat.tsx | 6 +++--- 7 files changed, 9 insertions(+), 8 deletions(-) diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index b972d251b0..fc66c0977a 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -82,7 +82,7 @@ const ChatsHeader: FC = memo(({ isExpanded, onToggle }) => { return ( {isSelected && ( -
- - - -
+ <> +