Compare commits

...

282 commits

Author SHA1 Message Date
Danny Avila
cbdc6f6060
📦 chore: Bump NPM Audit Packages (#12227)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🔧 chore: Update file-type dependency to version 21.3.2 in package-lock.json and package.json

- Upgraded the "file-type" package from version 18.7.0 to 21.3.2 to ensure compatibility with the latest features and security updates.
- Added new dependencies related to the updated "file-type" package, enhancing functionality and performance.

* 🔧 chore: Upgrade undici dependency to version 7.24.1 in package-lock.json and package.json

- Updated the "undici" package from version 7.18.2 to 7.24.1 across multiple package files to ensure compatibility with the latest features and security updates.

* 🔧 chore: Upgrade yauzl dependency to version 3.2.1 in package-lock.json

- Updated the "yauzl" package from version 3.2.0 to 3.2.1 to incorporate the latest features and security updates.

* 🔧 chore: Upgrade hono dependency to version 4.12.7 in package-lock.json

- Updated the "hono" package from version 4.12.5 to 4.12.7 to incorporate the latest features and security updates.
2026-03-14 03:36:03 -04:00
Danny Avila
f67bbb2bc5
🧹 fix: Sanitize Artifact Filenames in Code Execution Output (#12222)
* fix: sanitize artifact filenames to prevent path traversal in code output

* test: Mock sanitizeFilename function in process.spec.js to return the original filename

- Added a mock implementation for the `sanitizeFilename` function in the `process.spec.js` test file to return the original filename, ensuring that tests can run without altering the filename during the testing process.

* fix: use path.relative for traversal check, sanitize all filenames, add security logging

- Replace startsWith with path.relative pattern in saveLocalBuffer, consistent
  with deleteLocalFile and getLocalFileStream in the same file
- Hoist sanitizeFilename call before the image/non-image branch so both code
  paths store the sanitized name in MongoDB
- Log a warning when sanitizeFilename mutates a filename (potential traversal)
- Log a specific warning when saveLocalBuffer throws a traversal error, so
  security events are distinguishable from generic network errors in the catch

* test: improve traversal test coverage and remove mock reimplementation

- Remove partial sanitizeFilename reimplementation from process-traversal tests;
  use controlled mock returns to verify processCodeOutput wiring instead
- Add test for image branch sanitization
- Use mkdtempSync for test isolation in crud-traversal to avoid parallel worker
  collisions
- Add prefix-collision bypass test case (../user10/evil vs user1 directory)

* fix: use path.relative in isValidPath to prevent prefix-collision bypass

Pre-existing startsWith check without path separator had the same class
of prefix-collision vulnerability fixed in saveLocalBuffer.
2026-03-14 03:09:26 -04:00
Danny Avila
35a35dc2e9
📏 refactor: Add File Size Limits to Conversation Imports (#12221)
* fix: add file size limits to conversation import multer instance

* fix: address review findings for conversation import file size limits

* fix: use local jest.mock for data-schemas instead of global moduleNameMapper

The global @librechat/data-schemas mock in jest.config.js only provided
logger, breaking all tests that depend on createModels from the same
package. Replace with a virtual jest.mock scoped to the import spec file.

* fix: move import to top of file, pre-compute upload middleware, assert logger.warn in tests

* refactor: move resolveImportMaxFileSize to packages/api

New backend logic belongs in packages/api as TypeScript. Delete the
api/server/utils/import/limits.js wrapper and import directly from
@librechat/api in convos.js and importConversations.js. Resolver unit
tests move to packages/api; the api/ spec retains only multer behavior
tests.

* chore: rename importLimits to import

* fix: stale type reference and mock isolation in import tests

Update typeof import path from '../importLimits' to '../import' after
the rename. Clear mockLogger.warn in beforeEach to prevent cross-test
accumulation.

* fix: add resolveImportMaxFileSize to @librechat/api mock in convos.spec.js

* fix: resolve jest.mock hoisting issue in import tests

jest.mock factories are hoisted above const declarations, so the
mockLogger reference was undefined at factory evaluation time. Use a
direct import of the mocked logger module instead.

* fix: remove virtual flag from data-schemas mock for CI compatibility

virtual: true prevents the mock from intercepting the real module in
CI where @librechat/data-schemas is built, causing import.ts to use
the real logger while the test asserts against the mock.
2026-03-14 03:06:29 -04:00
Danny Avila
c6982dc180
🛡️ fix: Agent Permission Check on Image Upload Route (#12219)
* fix: add agent permission check to image upload route

* refactor: remove unused SystemRoles import and format test file for clarity

* fix: address review findings for image upload agent permission check

* refactor: move agent upload auth logic to TypeScript in packages/api

Extract pure authorization logic from agentPermCheck.js into
checkAgentUploadAuth() in packages/api/src/files/agentUploadAuth.ts.
The function returns a structured result ({ allowed, status, error })
instead of writing HTTP responses directly, eliminating the dual
responsibility and confusing sentinel return value. The JS wrapper
in /api is now a thin adapter that translates the result to HTTP.

* test: rewrite image upload permission tests as integration tests

Replace mock-heavy images-agent-perm.spec.js with integration tests
using MongoMemoryServer, real models, and real PermissionService.
Follows the established pattern in files.agents.test.js. Moves test
to sibling location (images.agents.test.js) matching backend convention.
Adds temp file cleanup assertions on 403/404 responses and covers
message_file exemption paths (boolean true, string "true", false).

* fix: widen AgentUploadAuthDeps types to accept ObjectId from Mongoose

The injected getAgent returns Mongoose documents where _id and author
are Types.ObjectId at runtime, not string. Widen the DI interface to
accept string | Types.ObjectId for _id, author, and resourceId so the
contract accurately reflects real callers.

* chore: move agent upload auth into files/agents/ subdirectory

* refactor: delete agentPermCheck.js wrapper, move verifyAgentUploadPermission to packages/api

The /api-only dependencies (getAgent, checkPermission) are now passed
as object-field params from the route call sites. Both images.js and
files.js import verifyAgentUploadPermission from @librechat/api and
inject the deps directly, eliminating the intermediate JS wrapper.

* style: fix import type ordering in agent upload auth

* fix: prevent token TTL race in MCPTokenStorage.storeTokens

When expires_in is provided, use it directly instead of round-tripping
through Date arithmetic. The previous code computed accessTokenExpiry
as a Date, then after an async encryptV2 call, recomputed expiresIn by
subtracting Date.now(). On loaded CI runners the elapsed time caused
Math.floor to truncate to 0, triggering the 1-year fallback and making
the token appear permanently valid — so refresh never fired.
2026-03-14 02:57:56 -04:00
Danny Avila
71a3b48504
🔑 fix: Require OTP Verification for 2FA Re-Enrollment and Backup Code Regeneration (#12223)
* fix: require OTP verification for 2FA re-enrollment and backup code regeneration

* fix: require OTP verification for account deletion when 2FA is enabled

* refactor: Improve code formatting and readability in TwoFactorController and UserController

- Reformatted code in TwoFactorController and UserController for better readability by aligning parameters and breaking long lines.
- Updated test cases in deleteUser.spec.js and TwoFactorController.spec.js to enhance clarity by formatting object parameters consistently.

* refactor: Consolidate OTP and backup code verification logic in TwoFactorController and UserController

- Introduced a new `verifyOTPOrBackupCode` function to streamline the verification process for TOTP tokens and backup codes across multiple controllers.
- Updated the `enable2FA`, `disable2FA`, and `deleteUserController` methods to utilize the new verification function, enhancing code reusability and readability.
- Adjusted related tests to reflect the changes in verification logic, ensuring consistent behavior across different scenarios.
- Improved error handling and response messages for verification failures, providing clearer feedback to users.

* chore: linting

* refactor: Update BackupCodesItem component to enhance OTP verification logic

- Consolidated OTP input handling by moving the 2FA verification UI logic to a more consistent location within the component.
- Improved the state management for OTP readiness, ensuring the regenerate button is only enabled when the OTP is ready.
- Cleaned up imports by removing redundant type imports, enhancing code clarity and maintainability.

* chore: lint

* fix: stage 2FA re-enrollment in pending fields to prevent disarmament window

enable2FA now writes to pendingTotpSecret/pendingBackupCodes instead of
overwriting the live fields. confirm2FA performs the atomic swap only after
the new TOTP code is verified. If the user abandons mid-flow, their
existing 2FA remains active and intact.
2026-03-14 01:51:31 -04:00
Danny Avila
189cdf581d
🔐 fix: Add User Filter to Message Deletion (#12220)
* fix: add user filter to message deletion to prevent IDOR

* refactor: streamline DELETE request syntax in messages-delete test

- Simplified the DELETE request syntax in the messages-delete.spec.js test file by combining multiple lines into a single line for improved readability. This change enhances the clarity of the test code without altering its functionality.

* fix: address review findings for message deletion IDOR fix

* fix: add user filter to message deletion in conversation tests

- Included a user filter in the message deletion test to ensure proper handling of user-specific deletions, enhancing the accuracy of the test case and preventing potential IDOR vulnerabilities.

* chore: lint
2026-03-13 23:42:37 -04:00
Danny Avila
ca79a03135
🚦 fix: Add Rate Limiting to Conversation Duplicate Endpoint (#12218)
* fix: add rate limiting to conversation duplicate endpoint

* chore: linter

* fix: address review findings for conversation duplicate rate limiting

* refactor: streamline test mocks for conversation routes

- Consolidated mock implementations into a dedicated `convos-route-mocks.js` file to enhance maintainability and readability of test files.
- Updated tests in `convos-duplicate-ratelimit.spec.js` and `convos.spec.js` to utilize the new mock structure, improving clarity and reducing redundancy.
- Enhanced the `duplicateConversation` function to accept an optional title parameter for better flexibility in conversation duplication.

* chore: rename files
2026-03-13 23:40:44 -04:00
Danny Avila
fa9e1b228a
🪪 fix: MCP API Responses and OAuth Validation (#12217)
* 🔒 fix: Validate MCP Configs in Server Responses

* 🔒 fix: Enhance OAuth URL Validation in MCPOAuthHandler

- Introduced validation for OAuth URLs to ensure they do not target private or internal addresses, enhancing security against SSRF attacks.
- Updated the OAuth flow to validate both authorization and token URLs before use, ensuring compliance with security standards.
- Refactored redirect URI handling to streamline the OAuth client registration process.
- Added comprehensive error handling for invalid URLs, improving robustness in OAuth interactions.

* 🔒 feat: Implement Permission Checks for MCP Server Management

- Added permission checkers for MCP server usage and creation, enhancing access control.
- Updated routes for reinitializing MCP servers and retrieving authentication values to include these permission checks, ensuring only authorized users can access these functionalities.
- Refactored existing permission logic to improve clarity and maintainability.

* 🔒 fix: Enhance MCP Server Response Validation and Redaction

- Updated MCP route tests to use `toMatchObject` for better validation of server response structures, ensuring consistency in expected properties.
- Refactored the `redactServerSecrets` function to streamline the removal of sensitive information, ensuring that user-sourced API keys are properly redacted while retaining their source.
- Improved OAuth security tests to validate rejection of private URLs across multiple endpoints, enhancing protection against SSRF vulnerabilities.
- Added comprehensive tests for the `redactServerSecrets` function to ensure proper handling of various server configurations, reinforcing security measures.

* chore: eslint

* 🔒 fix: Enhance OAuth Server URL Validation in MCPOAuthHandler

- Added validation for discovered authorization server URLs to ensure they meet security standards.
- Improved logging to provide clearer insights when an authorization server is found from resource metadata.
- Refactored the handling of authorization server URLs to enhance robustness against potential security vulnerabilities.

* 🔒 test: Bypass SSRF validation for MCP OAuth Flow tests

- Mocked SSRF validation functions to allow tests to use real local HTTP servers, facilitating more accurate testing of the MCP OAuth flow.
- Updated test setup to ensure compatibility with the new mocking strategy, enhancing the reliability of the tests.

* 🔒 fix: Add Validation for OAuth Metadata Endpoints in MCPOAuthHandler

- Implemented checks for the presence and validity of registration and token endpoints in the OAuth metadata, enhancing security by ensuring that these URLs are properly validated before use.
- Improved error handling and logging to provide better insights during the OAuth metadata processing, reinforcing the robustness of the OAuth flow.

* 🔒 refactor: Simplify MCP Auth Values Endpoint Logic

- Removed redundant permission checks for accessing the MCP server resource in the auth-values endpoint, streamlining the request handling process.
- Consolidated error handling and response structure for improved clarity and maintainability.
- Enhanced logging for better insights during the authentication value checks, reinforcing the robustness of the endpoint.

* 🔒 test: Refactor LeaderElection Integration Tests for Improved Cleanup

- Moved Redis key cleanup to the beforeEach hook to ensure a clean state before each test.
- Enhanced afterEach logic to handle instance resignations and Redis key deletion more robustly, improving test reliability and maintainability.
2026-03-13 23:18:56 -04:00
Danny Avila
f32907cd36
🔏 fix: MCP Server URL Schema Validation (#12204)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* fix: MCP server configuration validation and schema

- Added tests to reject URLs containing environment variable references for SSE, streamable-http, and websocket types in the MCP routes.
- Introduced a new schema in the data provider to ensure user input URLs do not resolve environment variables, enhancing security against potential leaks.
- Updated existing MCP server user input schema to utilize the new validation logic, ensuring consistent handling of user-supplied URLs across the application.

* fix: MCP URL validation to reject env variable references

- Updated tests to ensure that URLs for SSE, streamable-http, and websocket types containing environment variable patterns are rejected, improving security against potential leaks.
- Refactored the MCP server user input schema to enforce stricter validation rules, preventing the resolution of environment variables in user-supplied URLs.
- Introduced new test cases for various URL types to validate the rejection logic, ensuring consistent handling across the application.

* test: Enhance MCPServerUserInputSchema tests for environment variable handling

- Introduced new test cases to validate the prevention of environment variable exfiltration through user input URLs in the MCPServerUserInputSchema.
- Updated existing tests to confirm that URLs containing environment variable patterns are correctly resolved or rejected, improving security against potential leaks.
- Refactored test structure to better organize environment variable handling scenarios, ensuring comprehensive coverage of edge cases.
2026-03-12 23:19:31 -04:00
github-actions[bot]
65b0bfde1b
🌍 i18n: Update translation.json with latest translations (#12203)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-12 20:48:05 -04:00
Danny Avila
3ddf62c8e5
🫙 fix: Force MeiliSearch Full Sync on Empty Index State (#12202)
Some checks failed
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* fix: meili index sync with unindexed documents

- Updated `performSync` function to force a full sync when a fresh MeiliSearch index is detected, even if the number of unindexed messages or convos is below the sync threshold.
- Added logging to indicate when a fresh index is detected and a full sync is initiated.
- Introduced new tests to validate the behavior of the sync logic under various conditions, ensuring proper handling of fresh indexes and threshold scenarios.

This change improves the reliability of the synchronization process, ensuring that all documents are indexed correctly when starting with a fresh index.

* refactor: update sync logic for unindexed documents in MeiliSearch

- Renamed variables in `performSync` to improve clarity, changing `freshIndex` to `noneIndexed` for better understanding of the sync condition.
- Adjusted the logic to ensure a full sync is forced when no messages or conversations are marked as indexed, even if below the sync threshold.
- Updated related tests to reflect the new logging messages and conditions, enhancing the accuracy of the sync threshold logic.

This change improves the readability and reliability of the synchronization process, ensuring all documents are indexed correctly when starting with a fresh index.

* fix: enhance MeiliSearch index creation error handling

- Updated the `mongoMeili` function to improve logging and error handling during index creation in MeiliSearch.
- Added handling for `MeiliSearchTimeOutError` to log a warning when index creation times out.
- Enhanced logging to differentiate between successful index creation and specific failure reasons, including cases where the index already exists.
- Improved debug logging for index creation tasks to provide clearer insights into the process.

This change enhances the robustness of the index creation process and improves observability for troubleshooting.

* fix: update MeiliSearch index creation error handling

- Modified the `mongoMeili` function to check for any status other than 'succeeded' during index creation, enhancing error detection.
- Improved logging to provide clearer insights when an index creation task fails, particularly for cases where the index already exists.

This change strengthens the error handling mechanism for index creation in MeiliSearch, ensuring better observability and reliability.
2026-03-12 20:43:23 -04:00
github-actions[bot]
fc6f7a337d
🌍 i18n: Update translation.json with latest translations (#12176)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-11 11:46:55 -04:00
Danny Avila
9a5d7eaa4e
refactor: Replace tiktoken with ai-tokenizer (#12175)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* chore: Update dependencies by adding ai-tokenizer and removing tiktoken

- Added ai-tokenizer version 1.0.6 to package.json and package-lock.json across multiple packages.
- Removed tiktoken version 1.0.15 from package.json and package-lock.json in the same locations, streamlining dependency management.

* refactor: replace js-tiktoken with ai-tokenizer

- Added support for 'claude' encoding in the AgentClient class to improve model compatibility.
- Updated Tokenizer class to utilize 'ai-tokenizer' for both 'o200k_base' and 'claude' encodings, replacing the previous 'tiktoken' dependency.
- Refactored tests to reflect changes in tokenizer behavior and ensure accurate token counting for both encoding types.
- Removed deprecated references to 'tiktoken' and adjusted related tests for improved clarity and functionality.

* chore: remove tiktoken mocks from DALLE3 tests

- Eliminated mock implementations of 'tiktoken' from DALLE3-related test files to streamline test setup and align with recent dependency updates.
- Adjusted related test structures to ensure compatibility with the new tokenizer implementation.

* chore: Add distinct encoding support for Anthropic Claude models

- Introduced a new method `getEncoding` in the AgentClient class to handle the specific BPE tokenizer for Claude models, ensuring compatibility with the distinct encoding requirements.
- Updated documentation to clarify the encoding logic for Claude and other models.

* docs: Update return type documentation for getEncoding method in AgentClient

- Clarified the return type of the getEncoding method to specify that it can return an EncodingName or undefined, enhancing code readability and type safety.

* refactor: Tokenizer class and error handling

- Exported the EncodingName type for broader usage.
- Renamed encodingMap to encodingData for clarity.
- Improved error handling in getTokenCount method to ensure recovery attempts are logged and return 0 on failure.
- Updated countTokens function documentation to specify the use of 'o200k_base' encoding.

* refactor: Simplify encoding documentation and export type

- Updated the getEncoding method documentation to clarify the default behavior for non-Anthropic Claude models.
- Exported the EncodingName type separately from the Tokenizer module for improved clarity and usage.

* test: Update text processing tests for token limits

- Adjusted test cases to handle smaller text sizes, changing scenarios from ~120k tokens to ~20k tokens for both the real tokenizer and countTokens functions.
- Updated token limits in tests to reflect new constraints, ensuring tests accurately assess performance and call reduction.
- Enhanced console log messages for clarity regarding token counts and reductions in the updated scenarios.

* refactor: Update Tokenizer imports and exports

- Moved Tokenizer and countTokens exports to the tokenizer module for better organization.
- Adjusted imports in memory.ts to reflect the new structure, ensuring consistent usage across the codebase.
- Updated memory.test.ts to mock the Tokenizer from the correct module path, enhancing test accuracy.

* refactor: Tokenizer initialization and error handling

- Introduced an async `initEncoding` method to preload tokenizers, improving performance and accuracy in token counting.
- Updated `getTokenCount` to handle uninitialized tokenizers more gracefully, ensuring proper recovery and logging on errors.
- Removed deprecated synchronous tokenizer retrieval, streamlining the overall tokenizer management process.

* test: Enhance tokenizer tests with initialization and encoding checks

- Added `beforeAll` hooks to initialize tokenizers for 'o200k_base' and 'claude' encodings before running tests, ensuring proper setup.
- Updated tests to validate the loading of encodings and the correctness of token counts for both 'o200k_base' and 'claude'.
- Improved test structure to deduplicate concurrent initialization calls, enhancing performance and reliability.
2026-03-10 23:14:52 -04:00
Danny Avila
fcb344da47
🛂 fix: MCP OAuth Race Conditions, CSRF Fallback, and Token Expiry Handling (#12171)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* fix: Implement race conditions in MCP OAuth flow

- Added connection mutex to coalesce concurrent `getUserConnection` calls, preventing multiple simultaneous attempts.
- Enhanced flow state management to retry once when a flow state is missing, improving resilience against race conditions.
- Introduced `ReauthenticationRequiredError` for better error handling when access tokens are expired or missing.
- Updated tests to cover new race condition scenarios and ensure proper handling of OAuth flows.

* fix: Stale PENDING flow detection and OAuth URL re-issuance

PENDING flows in handleOAuthRequired now check createdAt age — flows
older than 2 minutes are treated as stale and replaced instead of
joined. Fixes the case where a leftover PENDING flow from a previous
session blocks new OAuth initiation.

authorizationUrl is now stored in MCPOAuthFlowMetadata so that when a
second caller joins an active PENDING flow (e.g., the SSE-emitting path
in ToolService), it can re-issue the URL to the user via oauthStart.

* fix: CSRF fallback via active PENDING flow in OAuth callback

When the OAuth callback arrives without CSRF or session cookies (common
in the chat/SSE flow where cookies can't be set on streaming responses),
fall back to validating that a PENDING flow exists for the flowId. This
is safe because the flow was created server-side after JWT authentication
and the authorization code is PKCE-protected.

* test: Extract shared OAuth test server helpers

Move MockKeyv, getFreePort, trackSockets, and createOAuthMCPServer into
a shared helpers/oauthTestServer module. Enhance the test server with
refresh token support, token rotation, metadata discovery, and dynamic
client registration endpoints. Add InMemoryTokenStore for token storage
tests.

Refactor MCPOAuthRaceCondition.test.ts to import from shared helpers.

* test: Add comprehensive MCP OAuth test modules

MCPOAuthTokenStorage — 21 tests for storeTokens/getTokens with
InMemoryTokenStore: encrypt/decrypt round-trips, expiry calculation,
refresh callback wiring, ReauthenticationRequiredError paths.

MCPOAuthFlow — 10 tests against real HTTP server: token refresh with
stored client info, refresh token rotation, metadata discovery, dynamic
client registration, full store/retrieve/expire/refresh lifecycle.

MCPOAuthConnectionEvents — 5 tests for MCPConnection OAuth event cycle
with real OAuth-gated MCP server: oauthRequired emission on 401,
oauthHandled reconnection, oauthFailed rejection, token expiry detection.

MCPOAuthTokenExpiry — 12 tests for the token expiry edge case: refresh
success/failure paths, ReauthenticationRequiredError, PENDING flow CSRF
fallback, authorizationUrl metadata storage, full re-auth cycle after
refresh failure, concurrent expired token coalescing, stale PENDING
flow detection.

* test: Enhance MCP OAuth connection tests with cooldown reset

Added a `beforeEach` hook to clear the cooldown for `MCPConnection` before each test, ensuring a clean state. Updated the race condition handling in the tests to properly clear the timeout, improving reliability in the event data retrieval process.

* refactor: PENDING flow management and state recovery in MCP OAuth

- Introduced a constant `PENDING_STALE_MS` to define the age threshold for PENDING flows, improving the handling of stale flows.
- Updated the logic in `MCPConnectionFactory` and `FlowStateManager` to check the age of PENDING flows before joining or reusing them.
- Modified the `completeFlow` method to return false when the flow state is deleted, ensuring graceful handling of race conditions.
- Enhanced tests to validate the new behavior and ensure robustness against state recovery issues.

* refactor: MCP OAuth flow management and testing

- Updated the `completeFlow` method to log warnings when a tool flow state is not found during completion, improving error handling.
- Introduced a new `normalizeExpiresAt` function to standardize expiration timestamp handling across the application.
- Refactored token expiration checks in `MCPConnectionFactory` to utilize the new normalization function, ensuring consistent behavior.
- Added a comprehensive test suite for OAuth callback CSRF fallback logic, validating the handling of PENDING flows and their staleness.
- Enhanced existing tests to cover new expiration normalization logic and ensure robust flow state management.

* test: Add CSRF fallback tests for active PENDING flows in MCP OAuth

- Introduced new tests to validate CSRF fallback behavior when a fresh PENDING flow exists without cookies, ensuring successful OAuth callback handling.
- Added scenarios to reject requests when no PENDING flow exists, when only a COMPLETED flow is present, and when a PENDING flow is stale, enhancing the robustness of flow state management.
- Improved overall test coverage for OAuth callback logic, reinforcing the handling of CSRF validation failures.

* chore: imports order

* refactor: Update UserConnectionManager to conditionally manage pending connections

- Modified the logic in `UserConnectionManager` to only set pending connections if `forceNew` is false, preventing unnecessary overwrites.
- Adjusted the cleanup process to ensure pending connections are only deleted when not forced, enhancing connection management efficiency.

* refactor: MCP OAuth flow state management

- Introduced a new method `storeStateMapping` in `MCPOAuthHandler` to securely map the OAuth state parameter to the flow ID, improving callback resolution and security against forgery.
- Updated the OAuth initiation and callback handling in `mcp.js` to utilize the new state mapping functionality, ensuring robust flow management.
- Refactored `MCPConnectionFactory` to store state mappings during flow initialization, enhancing the integrity of the OAuth process.
- Adjusted comments to clarify the purpose of state parameters in authorization URLs, reinforcing code readability.

* refactor: MCPConnection with OAuth recovery handling

- Added `oauthRecovery` flag to manage OAuth recovery state during connection attempts.
- Introduced `decrementCycleCount` method to reduce the circuit breaker's cycle count upon successful reconnection after OAuth recovery.
- Updated connection logic to reset the `oauthRecovery` flag after handling OAuth, improving state management and connection reliability.

* chore: Add debug logging for OAuth recovery cycle count decrement

- Introduced a debug log statement in the `MCPConnection` class to track the decrement of the cycle count after a successful reconnection during OAuth recovery.
- This enhancement improves observability and aids in troubleshooting connection issues related to OAuth recovery.

* test: Add OAuth recovery cycle management tests

- Introduced new tests for the OAuth recovery cycle in `MCPConnection`, validating the decrement of cycle counts after successful reconnections.
- Added scenarios to ensure that the cycle count is not decremented on OAuth failures, enhancing the robustness of connection management.
- Improved test coverage for OAuth reconnect scenarios, ensuring reliable behavior under various conditions.

* feat: Implement circuit breaker configuration in MCP

- Added circuit breaker settings to `.env.example` for max cycles, cycle window, and cooldown duration.
- Refactored `MCPConnection` to utilize the new configuration values from `mcpConfig`, enhancing circuit breaker management.
- Improved code maintainability by centralizing circuit breaker parameters in the configuration file.

* refactor: Update decrementCycleCount method for circuit breaker management

- Changed the visibility of the `decrementCycleCount` method in `MCPConnection` from private to public static, allowing it to be called with a server name parameter.
- Updated calls to `decrementCycleCount` in `MCPConnectionFactory` to use the new static method, improving clarity and consistency in circuit breaker management during connection failures and OAuth recovery.
- Enhanced the handling of circuit breaker state by ensuring the method checks for the existence of the circuit breaker before decrementing the cycle count.

* refactor: cycle count decrement on tool listing failure

- Added a call to `MCPConnection.decrementCycleCount` in the `MCPConnectionFactory` to handle cases where unauthenticated tool listing fails, improving circuit breaker management.
- This change ensures that the cycle count is decremented appropriately, maintaining the integrity of the connection recovery process.

* refactor: Update circuit breaker configuration and logic

- Enhanced circuit breaker settings in `.env.example` to include new parameters for failed rounds and backoff strategies.
- Refactored `MCPConnection` to utilize the updated configuration values from `mcpConfig`, improving circuit breaker management.
- Updated tests to reflect changes in circuit breaker logic, ensuring accurate validation of connection behavior under rapid reconnect scenarios.

* feat: Implement state mapping deletion in MCP flow management

- Added a new method `deleteStateMapping` in `MCPOAuthHandler` to remove orphaned state mappings when a flow is replaced, preventing old authorization URLs from resolving after a flow restart.
- Updated `MCPConnectionFactory` to call `deleteStateMapping` during flow cleanup, ensuring proper management of OAuth states.
- Enhanced test coverage for state mapping functionality to validate the new deletion logic.
2026-03-10 21:15:01 -04:00
Danny Avila
6167ce6e57
🧪 chore: MCP Reconnect Storm Follow-Up Fixes and Integration Tests (#12172)
* 🧪 test: Add reconnection storm regression tests for MCPConnection

Introduced a comprehensive test suite for reconnection storm scenarios, validating circuit breaker, throttling, cooldown, and timeout fixes. The tests utilize real MCP SDK transports and a StreamableHTTP server to ensure accurate behavior under rapid connect/disconnect cycles and error handling for SSE 400/405 responses. This enhances the reliability of the MCPConnection by ensuring proper handling of reconnection logic and circuit breaker functionality.

* 🔧 fix: Update createUnavailableToolStub to return structured response

Modified the `createUnavailableToolStub` function to return an array containing the unavailable message and a null value, enhancing the response structure. Additionally, added a debug log to skip tool creation when the result is null, improving the handling of reconnection scenarios in the MCP service.

* 🧪 test: Enhance MCP tool creation tests for cache and throttle interactions

Added new test cases for the `createMCPTool` function to validate the caching behavior when tools are unavailable or throttled. The tests ensure that tools are correctly cached as missing and prevent unnecessary reconnects across different users, improving the reliability of the MCP service under concurrent usage scenarios. Additionally, introduced a test for the `createMCPTools` function to verify that it returns an empty array when reconnect is throttled, ensuring proper handling of throttling logic.

* 📝 docs: Update AGENTS.md with testing philosophy and guidelines

Expanded the testing section in AGENTS.md to emphasize the importance of using real logic over mocks, advocating for the use of spies and real dependencies in tests. Added specific recommendations for testing with MongoDB and MCP SDK, highlighting the need to mock only uncontrollable external services. This update aims to improve testing practices and encourage more robust test implementations.

* 🧪 test: Enhance reconnection storm tests with socket tracking and SSE handling

Updated the reconnection storm test suite to include a new socket tracking mechanism for better resource management during tests. Improved the handling of SSE 400/405 responses by ensuring they are processed in the same branch as 404 errors, preventing unhandled cases. This enhances the reliability of the MCPConnection under rapid reconnect scenarios and ensures proper error handling.

* 🔧 fix: Implement cache eviction for stale reconnect attempts and missing tools

Added an `evictStale` function to manage the size of the `lastReconnectAttempts` and `missingToolCache` maps, ensuring they do not exceed a maximum cache size. This enhancement improves resource management by removing outdated entries based on a specified time-to-live (TTL), thereby optimizing the MCP service's performance during reconnection scenarios.
2026-03-10 17:44:13 -04:00
Danny Avila
c0e876a2e6
🔄 refactor: OAuth Metadata Discovery with Origin Fallback (#12170)
* 🔄 refactor: OAuth Metadata Discovery with Origin Fallback

Updated the `discoverWithOriginFallback` method to improve the handling of OAuth authorization server metadata discovery. The method now retries with the origin URL when discovery fails for a path-based URL, ensuring consistent behavior across `discoverMetadata` and token refresh flows. This change reduces code duplication and enhances the reliability of the OAuth flow by providing a unified implementation for origin fallback logic.

* 🧪 test: Add tests for OAuth Token Refresh with Origin Fallback

Introduced new tests for the `refreshOAuthTokens` method in `MCPOAuthHandler` to validate the retry mechanism with the origin URL when path-based discovery fails. The tests cover scenarios where the first discovery attempt throws an error and the subsequent attempt succeeds, as well as cases where the discovery fails entirely. This enhances the reliability of the OAuth token refresh process by ensuring proper handling of discovery failures.

* chore: imports order

* fix: Improve Base URL Logging and Metadata Discovery in MCPOAuthHandler

Updated the logging to use a consistent base URL object when handling discovery failures in the MCPOAuthHandler. This change enhances error reporting by ensuring that the base URL is logged correctly, and it refines the metadata discovery process by returning the result of the discovery attempt with the base URL, improving the reliability of the OAuth flow.
2026-03-10 16:19:07 -04:00
Oreon Lothamer
eb6328c1d9
🛤️ fix: Base URL Fallback for Path-based OAuth Discovery in Token Refresh (#12164)
* fix: add base URL fallback for path-based OAuth discovery in token refresh

The two `refreshOAuthTokens` paths in `MCPOAuthHandler` were missing the
origin-URL fallback that `initiateOAuthFlow` already had. With MCP SDK
1.27.1, `buildDiscoveryUrls` appends the server path to the
`.well-known` URL (e.g. `/.well-known/oauth-authorization-server/mcp`),
which returns 404 for servers like Sentry that only expose the root
discovery endpoint (`/.well-known/oauth-authorization-server`).

Without the fallback, discovery returns null during refresh, the token
endpoint resolves to the wrong URL, and users are prompted to
re-authenticate every time their access token expires instead of the
refresh token being exchanged silently.

Both refresh paths now mirror the `initiateOAuthFlow` pattern: if
discovery fails and the server URL has a non-root path, retry with just
the origin URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: extract discoverWithOriginFallback helper; add tests

Extract the duplicated path-based URL retry logic from both
`refreshOAuthTokens` branches into a single private static helper
`discoverWithOriginFallback`, reducing the risk of the two paths
drifting in the future.

Add three tests covering the new behaviour:
- stored clientInfo path: asserts discovery is called twice (path then
  origin) and that the token endpoint from the origin discovery is used
- auto-discovered path: same assertions for the branchless path
- root URL: asserts discovery is called only once when the server URL
  already has no path component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: use discoverWithOriginFallback in discoverMetadata too

Remove the inline duplicate of the origin-fallback logic from
`discoverMetadata` and replace it with a call to the shared
`discoverWithOriginFallback` helper, giving all three discovery
sites a single implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: use mock.calls + .href/.toString() for URL assertions

Replace brittle `toHaveBeenNthCalledWith(new URL(...))` comparisons
with `expect.any(URL)` matchers and explicit `.href`/`.toString()`
checks on the captured call args, consistent with the existing
mock.calls pattern used throughout handler.test.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:04:35 -04:00
matt burnett
ad5c51f62b
⛈️ fix: MCP Reconnection Storm Prevention with Circuit Breaker, Backoff, and Tool Stubs (#12162)
* fix: MCP reconnection stability - circuit breaker, throttling, and cooldown retry

* Comment and logging cleanup

* fix broken tests
2026-03-10 14:21:36 -04:00
Danny Avila
cfbe812d63
v0.8.3 (#12161)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Publish `@librechat/client` to NPM / build-and-publish (push) Has been cancelled
Publish `librechat-data-provider` to NPM / build (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / build-and-publish (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
*  v0.8.3

* chore: Bump package versions and update configuration

- Updated package versions for @librechat/api (1.7.25), @librechat/client (0.4.54), librechat-data-provider (0.8.302), and @librechat/data-schemas (0.0.38).
- Incremented configuration version in librechat.example.yaml to 1.3.6.

* feat: Add OpenRouter headers to OpenAI configuration

- Introduced 'X-OpenRouter-Title' and 'X-OpenRouter-Categories' headers in the OpenAI configuration for enhanced compatibility with OpenRouter services.
- Updated related tests to ensure the new headers are correctly included in the configuration responses.

* chore: Update package versions and dependencies

- Bumped versions for several dependencies including @eslint/eslintrc to 3.3.4, axios to 1.13.5, express to 5.2.1, and lodash to 4.17.23.
- Updated @librechat/backend and @librechat/frontend versions to 0.8.3.
- Added new dependencies: turbo and mammoth.
- Adjusted various other dependencies to their latest versions for improved compatibility and performance.
2026-03-09 15:19:57 -04:00
Danny Avila
9cf389715a
📦 chore: bump mermaid and dompurify (#12159)
* 📦 chore: bump `mermaid` and `dompurify`

- Bump mermaid to version 11.13.0 in both package-lock.json and client/package.json.
- Update monaco-editor to version 0.55.1 in both package-lock.json and client/package.json.
- Upgrade @chevrotain packages to version 11.1.2 in package-lock.json.
- Add dompurify as a dependency for monaco-editor in package.json.
- Update d3-format to version 3.1.2 and dagre-d3-es to version 7.0.14 in package-lock.json.
- Upgrade dompurify to version 3.3.2 in package-lock.json.

* chore: update language prop in ArtifactCodeEditor for read-only mode for better UX

- Adjusted the language prop in the MonacoEditor component to use 'plaintext' when in read-only mode, ensuring proper display of content without syntax highlighting.
2026-03-09 14:47:59 -04:00
Airam Hernández Hernández
873f446f8e
🕵️ fix: remoteAgents Field Omitted from Config (#12150)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* fix: include remoteAgents config in loadDefaultInterface

The loadDefaultInterface function was not passing the remoteAgents
configuration from librechat.yaml to the permission system, causing
remoteAgents permissions to never update from the YAML config even
when explicitly configured.

This fix adds the missing remoteAgents field to the returned
loadedInterface object, allowing the permission update system to
properly detect and apply remoteAgents configuration from the YAML file.

Fixes remote agents (API) configuration not being applied from librechat.yaml

* test: Add remoteAgents permission tests for USER and ADMIN roles

Introduced new tests to validate the application of remoteAgents configuration in user permissions. The tests cover scenarios for explicit configuration, full enablement, and default role behavior when remoteAgents are not configured. This ensures that permissions are correctly applied based on the provided configuration, addressing a regression related to the omission of remoteAgents in the loadDefaultInterface function.

---------

Co-authored-by: Airam Hernández Hernández <airam.hernandez@intelequia.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-03-09 11:13:53 -04:00
Danny Avila
32cadb1cc5
🩹 fix: MCP Server Recovery from Startup Inspection Failures (#12145)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* feat: MCP server reinitialization recovery mechanism

- Added functionality to store a stub configuration for MCP servers that fail inspection at startup, allowing for recovery via reinitialization.
- Introduced `reinspectServer` method in `MCPServersRegistry` to handle reinspection of previously failed servers.
- Enhanced `MCPServersInitializer` to log and manage server initialization failures, ensuring proper handling of inspection failures.
- Added integration tests to verify the recovery process for unreachable MCP servers, ensuring that stub configurations are stored and can be reinitialized successfully.
- Updated type definitions to include `inspectionFailed` flag in server configurations for better state management.

* fix: MCP server handling for inspection failures

- Updated `reinitMCPServer` to return a structured response when the server is unreachable, providing clearer feedback on the failure.
- Modified `ConnectionsRepository` to prevent connections to servers marked as inspection failed, improving error handling.
- Adjusted `MCPServersRegistry` methods to ensure proper management of server states, including throwing errors for non-failed servers during reinspection.
- Enhanced integration tests to validate the behavior of the system when dealing with unreachable MCP servers and inspection failures, ensuring robust recovery mechanisms.

* fix: Clear all cached server configurations in MCPServersRegistry

- Added a comment to clarify the necessity of clearing all cached server configurations when updating a server's configuration, as the cache is keyed by userId without a reverse index for enumeration.

* fix: Update integration test for file_tools_server inspection handling

- Modified the test to verify that the `file_tools_server` is stored as a stub when inspection fails, ensuring it can be reinitialized correctly.
- Adjusted expectations to confirm that the `inspectionFailed` flag is set to true for the stub configuration, enhancing the robustness of the recovery mechanism.

* test: Add unit tests for reinspecting servers in MCPServersRegistry

- Introduced tests for the `reinspectServer` method to validate error handling when called on a healthy server and when the server does not exist.
- Ensured that appropriate exceptions are thrown for both scenarios, enhancing the robustness of server state management.

* test: Add integration test for concurrent reinspectServer calls

- Introduced a new test to validate that multiple concurrent calls to reinspectServer do not crash or corrupt the server state.
- Ensured that at least one call succeeds and any failures are due to the server not being in a failed state, enhancing the reliability of the reinitialization process.

* test: Enhance integration test for concurrent MCP server reinitialization

- Added a new test to validate that concurrent calls to reinitialize the MCP server do not crash or corrupt the server state.
- Ensured that at least one call succeeds and that failures are handled gracefully, improving the reliability of the reinitialization process.
- Reset MCPManager instance after each test to maintain a clean state for subsequent tests.
2026-03-08 21:49:04 -04:00
Danny Avila
8b18a16446
🏷️ chore: Remove Docker Images by Named Tag in deployed-update.js (#12138)
* fix: remove docker images by named tag instead of image ID

* refactor: Simplify rebase logic and enhance error handling in deployed-update script

- Removed unnecessary condition for rebasing, streamlining the update process.
- Renamed variable for clarity when fetching Docker image tags.
- Added error handling to catch and log failures during the update process, ensuring better visibility of issues.
2026-03-08 21:48:22 -04:00
Danny Avila
4a8a5b5994
🔒 fix: Hex-normalized IPv4-mapped IPv6 in Domain Validation (#12130)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🔒 fix: handle hex-normalized IPv4-mapped IPv6 in domain validation

* fix: Enhance IPv6 private address detection in domain validation

- Added tests for detecting IPv4-compatible, 6to4, NAT64, and Teredo addresses.
- Implemented `extractEmbeddedIPv4` function to identify private IPv4 addresses within various IPv6 formats.
- Updated `isPrivateIP` function to utilize the new extraction logic for improved accuracy in address validation.

* fix: Update private IPv4 detection logic in domain validation

- Enhanced the `isPrivateIPv4` function to accurately identify additional private and non-routable IPv4 ranges.
- Adjusted the return logic in `resolveHostnameSSRF` to utilize the updated private IP detection for improved hostname validation.

* test: Expand private IP detection tests in domain validation

- Added tests for additional private IPv4 ranges including 0.0.0.0/8, 100.64.0.0/10, 192.0.0.0/24, and 198.18.0.0/15.
- Updated existing tests to ensure accurate detection of private and multicast IP addresses in the `isPrivateIP` function.
- Enhanced `resolveHostnameSSRF` to correctly identify private literal IPv4 addresses without DNS lookup.

* refactor: Rename and enhance embedded IPv4 detection in IPv6 addresses

- Renamed `extractEmbeddedIPv4` to `hasPrivateEmbeddedIPv4` for clarity on its purpose.
- Updated logic to accurately check for private IPv4 addresses embedded in Teredo, 6to4, and NAT64 IPv6 formats.
- Improved the `isPrivateIP` function to utilize the new naming and logic for better readability and accuracy.
- Enhanced documentation for clarity on the functionality of the updated methods.

* feat: Enhance private IPv4 detection in embedded IPv6 addresses

- Added additional checks in `hasPrivateEmbeddedIPv4` to ensure only valid private IPv4 formats are recognized.
- Improved the logic for identifying private IPv4 addresses embedded within various IPv6 formats, enhancing overall accuracy.

* test: Add additional test for hostname resolution in SSRF detection

- Included a new test case in `resolveHostnameSSRF` to validate the detection of private IPv4 addresses embedded in IPv6 formats for the hostname 'meta.example.com'.
- Enhanced existing tests to ensure comprehensive coverage of hostname resolution scenarios.

* fix: Set redirect option to 'manual' in undiciFetch calls

- Updated undiciFetch calls in MCPConnection to include the redirect option set to 'manual' for better control over HTTP redirects.
- Added documentation comments regarding SSRF pre-checks for WebSocket connections, highlighting the limitations of the current SDK regarding DNS resolution.

* test: Add integration tests for MCP SSRF protections

- Introduced a new test suite for MCP SSRF protections, verifying that MCPConnection does not follow HTTP redirects to private IPs and blocks WebSocket connections to private IPs when SSRF protection is enabled.
- Implemented tests to ensure correct behavior of the connection under various scenarios, including redirect handling and WebSocket DNS resolution.

* refactor: Improve SSRF protection logic for WebSocket connections

- Enhanced the SSRF pre-check for WebSocket connections to validate resolved IPs, ensuring that allowlisting a domain does not grant trust to its resolved IPs at runtime.
- Updated documentation comments to clarify the limitations of the current SDK regarding DNS resolution and the implications for SSRF protection.

* test: Enhance MCP SSRF protection tests for redirect handling and WebSocket connections

- Updated tests to ensure that MCPConnection does not follow HTTP redirects to private IPs, regardless of SSRF protection settings.
- Added checks to verify that WebSocket connections to hosts resolving to private IPs are blocked, even when SSRF protection is disabled.
- Improved documentation comments for clarity on the behavior of the tests and the implications for SSRF protection.

* test: Refactor MCP SSRF protection test for WebSocket connection errors

- Updated the test to use `await expect(...).rejects.not.toThrow(...)` for better readability and clarity.
- Simplified the error handling logic while ensuring that SSRF rejections are correctly validated during connection failures.
2026-03-07 20:13:52 -05:00
Danny Avila
2ac62a2e71
fix: Resolve Agent Provider Endpoint Type for File Upload Support (#12117)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* chore: Remove unused setValueOnChange prop from MCPServerMenuItem component

* fix: Resolve agent provider endpoint type for file upload support

When using the agents endpoint with a custom provider (e.g., Moonshot),
the endpointType was resolving to "agents" instead of the provider's
actual type ("custom"), causing "Upload to Provider" to not appear in
the file attach menu.

Adds `resolveEndpointType` utility in data-provider that follows the
chain: endpoint (if not agents) → agent.provider → agents. Applied
consistently across AttachFileChat, DragDropContext, useDragHelpers,
and AgentPanel file components (FileContext, FileSearch, Code/Files).

* refactor: Extract useAgentFileConfig hook, restore deleted tests, fix review findings

- Extract shared provider resolution logic into useAgentFileConfig hook
  (Finding #2: DRY violation across FileContext, FileSearch, Code/Files)
- Restore 18 deleted test cases in AttachFileMenu.spec.tsx covering
  agent capabilities, SharePoint, edge cases, and button state
  (Finding #1: accidental test deletion)
- Wrap fileConfigEndpoint in useMemo in AttachFileChat (Finding #3)
- Fix misleading test name in AgentFileConfig.spec.tsx (Finding #4)
- Fix import order in FileSearch.tsx, FileContext.tsx, Code/Files.tsx (Finding #5)
- Add comment about cache gap in useDragHelpers (Finding #6)
- Clarify resolveEndpointType JSDoc (Finding #7)

* refactor: Memoize Footer component for performance optimization

- Converted Footer component to a memoized version to prevent unnecessary re-renders.
- Improved import structure by adding memo to the React import statement for clarity.

* chore: Fix remaining review nits

- Widen useAgentFileConfig return type to EModelEndpoint | string
- Fix import order in FileContext.tsx and FileSearch.tsx
- Remove dead endpointType param from setupMocks in AttachFileMenu test

* fix: Pass resolved provider endpoint to file upload validation

AgentPanel file components (FileContext, FileSearch, Code/Files) were
hardcoding endpointOverride to "agents", causing both client-side
validation (file limits, MIME types) and server-side validation to
use the agents config instead of the provider-specific config.

Adds endpointTypeOverride to UseFileHandling params so endpoint and
endpointType can be set independently. Components now pass the
resolved provider name and type from useAgentFileConfig, so the full
fallback chain (provider → custom → agents → default) applies to
file upload validation on both client and server.

* test: Verify any custom endpoint is document-supported regardless of name

Adds parameterized tests with arbitrary endpoint names (spaces, hyphens,
colons, etc.) confirming that all custom endpoints resolve to
document-supported through resolveEndpointType, both as direct
endpoints and as agent providers.

* fix: Use || for provider fallback, test endpointOverride wiring

- Change providerValue ?? to providerValue || so empty string is
  treated as "no provider" consistently with resolveEndpointType
- Add wiring tests to CodeFiles, FileContext, FileSearch verifying
  endpointOverride and endpointTypeOverride are passed correctly
- Update endpointOverride JSDoc to document endpointType fallback
2026-03-07 10:45:43 -05:00
Danny Avila
cfaa6337c1
📦 chore: Bump express-rate-limit to v8.3.0 (#12115)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
2026-03-06 19:18:35 -05:00
Danny Avila
b93d60c416
🎞️ refactor: Image Rendering with Preview Caching and Layout Reservation (#12114)
* refactor: Update Image Component to Remove Lazy Loading and Enhance Rendering

- Removed the react-lazy-load-image-component dependency from the Image component, simplifying the image loading process.
- Updated the Image component to use a standard <img> tag with async decoding for improved performance and user experience.
- Adjusted related tests to reflect changes in image rendering behavior and ensure proper functionality without lazy loading.

* refactor: Enhance Image Handling and Caching Across Components

- Introduced a new previewCache utility for managing local blob preview URLs, improving image loading efficiency.
- Updated the Image component and related parts (FileRow, Files, Part, ImageAttachment, LogContent) to utilize cached previews, enhancing rendering performance and user experience.
- Added width and height properties to the Image component for better layout management and consistency across different usages.
- Improved file handling logic in useFileHandling to cache previews during file uploads, ensuring quick access to image data.
- Enhanced overall code clarity and maintainability by streamlining image rendering logic and reducing redundancy.

* refactor: Enhance OpenAIImageGen Component with Image Dimensions

- Added width and height properties to the OpenAIImageGen component for improved image rendering and layout management.
- Updated the Image component usage within OpenAIImageGen to utilize the new dimensions, enhancing visual consistency and performance.
- Improved code clarity by destructuring additional properties from the attachment object, streamlining the component's logic.

* refactor: Implement Image Size Caching in DialogImage Component

- Introduced an imageSizeCache to store and retrieve image sizes, enhancing performance by reducing redundant fetch requests.
- Updated the getImageSize function to first check the cache before making network requests, improving efficiency in image handling.
- Added decoding attribute to the image element for optimized rendering behavior.

* refactor: Enhance UserAvatar Component with Avatar Caching and Error Handling

- Introduced avatar caching logic to optimize avatar resolution based on user ID and avatar source, improving performance and reducing redundant image loads.
- Implemented error handling for failed image loads, allowing for fallback to a default avatar when necessary.
- Updated UserAvatar props to streamline the interface by removing the user object and directly accepting avatar-related properties.
- Enhanced overall code clarity and maintainability by refactoring the component structure and logic.

* fix: Layout Shift in Message and Placeholder Components for Consistent Height Management

- Adjusted the height of the PlaceholderRow and related message components to ensure consistent rendering with a minimum height of 31px.
- Updated the MessageParts and ContentRender components to utilize a minimum height for better layout stability.
- Enhanced overall code clarity by standardizing the structure of message-related components.

* tests: Update FileRow Component to Prefer Cached Previews for Image Rendering

- Modified the image URL selection logic in the FileRow component to prioritize cached previews over file paths when uploads are complete, enhancing rendering performance and user experience.
- Updated related tests to reflect changes in image URL handling, ensuring accurate assertions for both preview and file path scenarios.
- Introduced a fallback mechanism to use file paths when no preview exists, improving robustness in file handling.

* fix: Image cache lifecycle and dialog decoding

- Add deletePreview/clearPreviewCache to previewCache.ts for blob URL cleanup
- Wire deletePreview into useFileDeletion to revoke blobs on file delete
- Move dimensionCache.set into useMemo to avoid side effects during render
- Extract IMAGE_MAX_W_PX constant (512) to document coupling with max-w-lg
- Export _resetImageCaches for test isolation
- Change DialogImage decoding from "sync" to "async" to avoid blocking main thread

* fix: Avatar cache invalidation and cleanup

- Include avatarSrc in cache invalidation to prevent stale avatars
- Remove unused username parameter from resolveAvatar
- Skip caching when userId is empty to prevent cache key collisions

* test: Fix test isolation and type safety

- Reset module-level dimensionCache/paintedUrls in beforeEach via _resetImageCaches
- Replace any[] with typed mock signature in cn mock for both test files

* chore: Code quality improvements from review

- Use barrel imports for previewCache in Files.tsx and Part.tsx
- Single Map.get with truthy check instead of has+get in useEventHandlers
- Add JSDoc comments explaining EmptyText margin removal and PlaceholderRow height
- Fix FileRow toast showing "Deleting file" when file isn't actually deleted (progress < 1)

* fix: Address remaining review findings (R1-R3)

- Add deletePreview calls to deleteFiles batch path to prevent blob URL leaks
- Change useFileDeletion import from deep path to barrel (~/utils)
- Change useMemo to useEffect for dimensionCache.set (side effect, not derived value)

* fix: Address audit comments 2, 5, and 7

- Fix files preservation to distinguish null (missing) from [] (empty) in finalHandler
- Add auto-revoke on overwrite in cachePreview to prevent leaked blobs
- Add removePreviewEntry for key transfer without revoke
- Clean up stale temp_file_id cache entry after promotion to permanent file_id
2026-03-06 19:09:52 -05:00
Lionel Ringenbach
6d0938be64
🔒 refactor: Set ALLOW_SHARED_LINKS_PUBLIC to false by Default (#12100)
* fix: default ALLOW_SHARED_LINKS_PUBLIC to false for security

Shared links were publicly accessible by default when
ALLOW_SHARED_LINKS_PUBLIC was not explicitly set, which could lead to
unintentional data exposure. Users may assume their authentication
settings protect shared links when they do not.

This changes the default behavior so shared links require JWT
authentication unless ALLOW_SHARED_LINKS_PUBLIC is explicitly set to
true.

* Document ALLOW_SHARED_LINKS_PUBLIC in .env.example

Add comment explaining ALLOW_SHARED_LINKS_PUBLIC setting.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Danny Avila <danacordially@gmail.com>
2026-03-06 19:05:56 -05:00
Airam Hernández Hernández
cc3d62c640
🛡️ fix: Add Permission Guard for Temporary Chat Visibility (#12107)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
- Add useHasAccess hook for TEMPORARY_CHAT permission type
- Conditionally render TemporaryChat component based on user permissions
- Ensures feature respects role-based access control

Co-authored-by: Airam Hernández Hernández <airam.hernandez@intelequia.com>
2026-03-06 17:55:05 -05:00
Carolina
3a73907daa
📐 fix: Replace JS Image Scaling with CSS Viewport Constraints (#12089)
* fix: remove scaleImage function that stretched vertical images

* chore: lint

* refactor: Simplify Image Component Usage Across Chat Parts

- Removed height and width props from the Image component in various parts (Files, Part, ImageAttachment, LogContent) to streamline image rendering.
- Introduced a constant for maximum image height in the Image component for consistent styling.
- Updated related components to utilize the new simplified Image component structure, enhancing maintainability and reducing redundancy.

* refactor: Simplify LogContent and Enhance Image Component Tests

- Removed height and width properties from the ImageAttachment type in LogContent for cleaner code.
- Updated the image rendering logic to rely solely on the filepath, improving clarity.
- Enhanced the Image component tests with additional assertions for rendering behavior and accessibility.
- Introduced new tests for OpenAIImageGen to validate image preloading and progress handling, ensuring robust functionality.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-03-06 16:42:23 -05:00
Danny Avila
771227ecf9
🏎️ refactor: Replace Sandpack Code Editor with Monaco for Artifact Editing (#12109)
* refactor: Code Editor and Auto Scroll Functionality

- Added a useEffect hook in CodeEditor to sync streaming content with Sandpack without remounting the provider, improving performance and user experience.
- Updated useAutoScroll to accept an optional editorRef, allowing for dynamic scroll container selection based on the editor's state.
- Refactored ArtifactTabs to utilize the new editorRef in the useAutoScroll hook, ensuring consistent scrolling behavior during content updates.
- Introduced stableFiles and mergedFiles logic in CodeEditor to optimize file handling and prevent unnecessary updates during streaming content changes.

* refactor: Update CodeEditor to Sync Streaming Content Based on Read-Only State

- Modified the useEffect hook in CodeEditor to conditionally sync streaming content with Sandpack only when in read-only mode, preventing unnecessary updates during user edits.
- Enhanced the dependency array of the useEffect hook to include the readOnly state, ensuring accurate synchronization behavior.

* refactor: Monaco Editor for Artifact Code Editing

* refactor: Clean up ArtifactCodeEditor and ArtifactTabs components

- Removed unused scrollbar styles from mobile.css to streamline the code.
- Refactored ArtifactCodeEditor to improve content synchronization and read-only state handling.
- Enhanced ArtifactTabs by removing unnecessary context usage and optimizing component structure for better readability.

* feat: Add support for new artifact type 'application/vnd.ant.react'

- Introduced handling for 'application/vnd.ant.react' in artifactFilename, artifactTemplate, and dependenciesMap.
- Updated relevant mappings to ensure proper integration of the new artifact type within the application.

* refactor:ArtifactCodeEditor with Monaco Editor Configuration

- Added support for disabling validation in the Monaco Editor to improve the artifact viewer/editor experience.
- Introduced a new type definition for Monaco to enhance type safety.
- Updated the handling of the 'application/vnd.ant.react' artifact type to ensure proper integration with the editor.

* refactor: Clean up ArtifactCodeEditor and mobile.css

- Removed unnecessary whitespace in mobile.css for cleaner code.
- Refactored ArtifactCodeEditor to streamline language mapping and type handling, enhancing readability and maintainability.
- Consolidated language and type mappings into dedicated constants for improved clarity and efficiency.

* feat: Integrate Monaco Editor for Enhanced Code Editing Experience

- Added the Monaco Editor as a dependency to improve the code editing capabilities within the ArtifactCodeEditor component.
- Refactored the handling of TypeScript and JavaScript defaults in the Monaco Editor configuration for better type safety and clarity.
- Streamlined the setup for disabling validation, enhancing the artifact viewer/editor experience.

* fix: Update ArtifactCodeEditor to handle null content checks

- Modified conditional checks in ArtifactCodeEditor to use `art.content != null` instead of `art.content` for improved null safety.
- Ensured consistent handling of artifact content across various useEffect hooks to prevent potential errors when content is null.

* fix: Refine content comparison logic in ArtifactCodeEditor

- Updated the condition for checking if the code is not original by removing the redundant null check for `art.content`, ensuring more concise and clear logic.
- This change enhances the readability of the code and maintains the integrity of content comparison within the editor.

* fix: Simplify code comparison logic in ArtifactCodeEditor

- Removed redundant null check for the `code` variable, ensuring a more straightforward comparison with the current update reference.
- This change improves code clarity and maintains the integrity of the content comparison logic within the editor.
2026-03-06 15:02:04 -05:00
Danny Avila
a79f7cebd5
🤖 feat: GPT-5.4 and GPT-5.4-pro Context + Pricing (#12099)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
*  feat: Add support for new GPT-5.4 and GPT-5.4-pro models

- Introduced new token values and cache settings for 'gpt-5.4' and 'gpt-5.4-pro' in the API model configurations.
- Updated maximum output limits for the new models in the tokens utility.
- Included 'gpt-5.4' and 'gpt-5.4-pro' in the shared OpenAI models list for consistent access across the application.

* 🔧 update: Enhance GPT-5.4 and GPT-5.4-pro model configurations

- Refined token pricing and cache settings for 'gpt-5.4' and 'gpt-5.4-pro' in the API model configurations.
- Added tests for cache multipliers and maximum token limits for the new models.
- Updated shared OpenAI models list to include 'gpt-5.4-thinking' and added a note for verifying pricing before release.

* 🔧 update: Add clarification to token pricing for 'gpt-5.4-pro'

- Added a comment to the 'gpt-5.4-pro' model configuration in tokens.ts to specify that it shares the same token window as 'gpt-5.4', enhancing clarity for future reference.
2026-03-06 02:11:01 -05:00
Danny Avila
3b84cc048a
🧮 fix: XLSX/XLS Upload-as-Text via Buffer-Based SheetJS Parsing (#12098)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* 🔧 fix: Update Excel sheet parsing to use fs.promises.readFile and correct import for xlsx

- Modified the excelSheetToText function to read the file using fs.promises.readFile instead of directly accessing the file path.
- Updated the import statement for the xlsx library to use the correct read method, ensuring proper functionality in parsing Excel sheets.

* 🔧 fix: Update document parsing methods to use buffer for file reading

- Modified the wordDocToText function to read the file as a buffer using fs.promises.readFile, ensuring compatibility with the mammoth library.
- Updated the excelSheetToText function to read the Excel file as a buffer, addressing issues with the xlsx library's handling of dynamic imports and file access.

* feat: Add tests for empty xlsx document parsing and validate xlsx imports

- Introduced a new test case to verify that the `parseDocument` function correctly handles an empty xlsx file with only a sheet name, ensuring it returns the expected document structure.
- Added a test to confirm that the `xlsx` library exports `read` and `utils` as named imports, validating the functionality of the library integration.
- Included a new empty xlsx file to support the test cases.
2026-03-06 00:21:55 -05:00
Danny Avila
5209f1dc9e
refactor: Optimize Message Re-renders (#12097)
* 🔄 refactor: Update Artifacts and Messages Contexts to Use Latest Message ID and Depth

- Modified ArtifactsContext to retrieve latestMessage using Recoil state management.
- Updated MessagesViewContext to replace latestMessage with latestMessageId and latestMessageDepth for improved clarity and consistency.
- Adjusted various components (HoverButtons, MessageParts, MessageRender, ContentRender) to utilize latestMessageId instead of the entire message object, enhancing performance and reducing unnecessary re-renders.
- Refactored useChatHelpers to extract latestMessageId and latestMessageDepth, streamlining message handling across the application.

* refactor: Introduce PartWithContext Component for Optimized Message Rendering

- Added a new PartWithContext component to encapsulate message part rendering logic, improving context management and reducing redundancy in the ContentParts component.
- Updated MessageRender to utilize the new PartWithContext, streamlining the context provider setup and enhancing code clarity.
- Refactored related logic to ensure proper context values are passed, improving maintainability and performance in message rendering.

* refactor: Update Components to Use Function Declarations and Improve Readability

- Refactored several components (MessageContainer, Markdown, MarkdownCode, MarkdownCodeNoExecution, MarkdownAnchor, MarkdownParagraph, MarkdownImage, TextPart, PlaceholderRow) to use function declarations instead of arrow functions, enhancing readability and consistency across the codebase.
- Added display names to memoized components for better debugging and profiling in React DevTools.
- Improved overall code clarity and maintainability by standardizing component definitions.

* refactor: Standardize MessageRender and ContentRender Components for Improved Clarity

- Refactored MessageRender and ContentRender components to use function declarations, enhancing readability and consistency.
- Streamlined props handling by removing unnecessary parameters and improving the use of hooks for state management.
- Updated memoization and rendering logic to optimize performance and reduce unnecessary re-renders.
- Enhanced overall code clarity and maintainability by standardizing component definitions and structure.

* refactor: Enhance Header Component with Memoization for Performance

- Refactored the Header component to utilize React's memoization by wrapping it with the memo function, improving rendering performance by preventing unnecessary re-renders.
- Changed the export to a memoized version of the Header component, ensuring better debugging with a display name.
- Maintained overall code clarity and consistency in component structure.

* refactor: Transition Components to Use Recoil for State Management

- Updated multiple components (AddMultiConvo, TemporaryChat, HeaderNewChat, PresetsMenu, ModelSelectorChatContext) to utilize Recoil for state management, enhancing consistency and performance.
- Replaced useChatContext with Recoil selectors and atoms, improving data flow and reducing unnecessary re-renders.
- Introduced new selectors for conversation ID and endpoint retrieval, streamlining component logic and enhancing maintainability.
- Improved overall code clarity by standardizing state management practices across components.

* refactor: Integrate getConversation Callback for Enhanced State Management

- Updated multiple components (Mention, ModelSelectorChatContext, ModelSelectorContext, FavoritesList) to utilize a getConversation callback instead of directly accessing conversation state, improving encapsulation and maintainability.
- Refactored useSelectMention hook to accept getConversation, streamlining conversation retrieval and enhancing code clarity.
- Introduced new Recoil selectors for conversation properties, ensuring consistent state management across components.
- Enhanced overall code structure by standardizing the approach to conversation handling, reducing redundancy and improving performance.

* refactor: Optimize LiveAnnouncer Context Value with useMemo

- Updated the LiveAnnouncer component to utilize useMemo for context value creation, enhancing performance by preventing unnecessary recalculations of the context object.
- Improved overall code clarity and maintainability by ensuring that context values are only recomputed when their dependencies change.

* refactor: Update AgentPanelSwitch to Use Recoil for Agent ID Management

- Refactored AgentPanelSwitch component to utilize Recoil for retrieving the current agent ID, replacing the previous use of chat context.
- Improved state management by ensuring the agent ID is derived from Recoil, enhancing code clarity and maintainability.
- Adjusted useEffect dependencies to reflect the new state management approach, streamlining the component's logic.

* refactor: Enhance useLocalize Hook with useCallback for Improved Performance

- Updated the useLocalize hook to utilize useCallback for the translation function, optimizing performance by preventing unnecessary re-creations of the function on each render.
- Improved code clarity by ensuring that the translation function is memoized, enhancing maintainability and efficiency in localization handling.

* refactor: Rename useCreateConversationAtom to useSetConversationAtom for Clarity

- Updated the hook name from useCreateConversationAtom to useSetConversationAtom to better reflect its functionality in managing conversation state.
- Introduced a new implementation for setting conversation state, enhancing clarity and maintainability in the codebase.
- Adjusted related references in the useNewConvo hook to align with the new naming convention.

* refactor: Enhance useKeyDialog Hook with useMemo and useCallback for Improved Performance

- Updated the useKeyDialog hook to utilize useMemo for returning the dialog state and handlers, optimizing performance by preventing unnecessary recalculations.
- Refactored the onOpenChange function to use useCallback, ensuring it only changes when its dependencies do, enhancing maintainability and clarity in the code.
- Improved overall code structure and readability by streamlining the hook's logic and dependencies.

* feat: Add useRenderChangeLog Hook for Debugging Render Changes

- Introduced a new hook, useRenderChangeLog, that logs changes in tracked values between renders when a debug flag is enabled.
- Utilizes useEffect and useRef to track previous values and identify changes, enhancing debugging capabilities for component renders.
- Provides detailed console output for initial renders and value changes, improving developer insights during the rendering process.

* refactor: Update useSelectAgent Hook for Improved State Management and Performance

- Refactored the useSelectAgent hook to utilize useRecoilCallback for fetching conversation data, enhancing state management and performance.
- Replaced the use of useChatContext with a more efficient approach, streamlining the logic for selecting agents and updating conversations.
- Improved error handling and ensured asynchronous operations are properly awaited, enhancing reliability in agent selection and data fetching processes.

* refactor: Optimize useDefaultConvo Hook with useCallback for Improved Performance

- Refactored the getDefaultConversation function within the useDefaultConvo hook to utilize useCallback, enhancing performance by memoizing the function and preventing unnecessary re-creations on re-renders.
- Streamlined the logic for cleaning input and output in the conversation object, improving code clarity and maintainability.
- Ensured that dependencies for useCallback are correctly set, enhancing the reliability of the hook's behavior.

* refactor: Optimize Agent Components with Memoization for Improved Performance

- Refactored multiple agent-related components (AgentAvatar, AgentCategorySelector, AgentSelect, DeleteButton, FileContext, FileSearch, Files) to utilize React.memo for memoization, enhancing rendering performance by preventing unnecessary re-renders.
- Updated the FileRow component to make setFilesLoading optional, improving flexibility in file handling.
- Streamlined component logic and improved maintainability by ensuring that props are compared efficiently in memoized components.

* refactor: Enhance File Handling and Agent Components for Improved Performance

- Refactored multiple components (DeleteButton, FileContext, FileSearch, Files) to utilize new file handling hooks that separate chat context from file operations, improving performance and maintainability.
- Introduced useFileHandlingNoChatContext and useSharePointFileHandlingNoChatContext hooks to streamline file handling logic, enhancing flexibility in managing file states.
- Updated DeleteButton to improve conversation state management and ensure proper handling of agent deletions, enhancing user experience.
- Optimized imports and component structure for better clarity and organization across the affected files.

* refactor: Enhance useRenderChangeLog Hook with Improved Type Safety and Documentation

- Updated the useRenderChangeLog hook to improve type safety by specifying the value types as string, number, boolean, null, or undefined.
- Enhanced documentation to clarify usage and enablement of the debug feature, ensuring better developer insights during rendering.
- Added a production check to prevent logging in production builds, optimizing performance and maintaining clean console output.

* chore: imports

* refactor: Replace useRecoilCallback with useGetConversation Hook for Improved Clarity and Performance

- Refactored multiple components (AddMultiConvo, ModelSelectorChatContext, FavoritesList, useSelectAgent, usePresets) to utilize the new useGetConversation hook, enhancing clarity and reducing complexity by eliminating the use of useRecoilCallback.
- Streamlined conversation retrieval logic across components, improving maintainability and performance.
- Updated imports and component structure for better organization and readability.

* refactor: Enhance Memoization in DeleteButton Component for Improved Performance

- Updated the memoization logic in the DeleteButton component to include a comparison for the setCurrentAgentId prop, ensuring more efficient re-renders.
- This change improves performance by preventing unnecessary updates when the agent ID and current agent ID remain unchanged.

* chore: fix test

* refactor: Improve Memoization Logic in AgentSelect Component

- Updated the memoization comparison in the AgentSelect component to directly compare agentQuery.data objects, enhancing performance by ensuring accurate re-renders.
- Refactored the useCreateConversationAtom function to streamline the logic for updating conversation keys, improving clarity and maintainability.

* refactor: Simplify State Management in DeleteButton Component

- Removed unnecessary setConversationOption function, streamlining the logic for updating conversation state after agent deletion.
- Updated the conversation state directly within the deleteAgent mutation, improving clarity and maintainability of the component.
- Refactored conversationByKeySelector to directly reference conversationByIndex, enhancing performance and reducing complexity in state retrieval.

* refactor: Remove Unused Conversation Prop from Mention Component

- Eliminated the conversation prop from the Mention component, simplifying its interface and reducing unnecessary dependencies.
- Updated the ChatForm component to reflect this change, enhancing clarity and maintainability of the codebase.
- Introduced useGetConversation hook for improved conversation retrieval logic, streamlining the component's functionality.

* refactor: Simplify File Handling State Management Across Components

- Removed the unused setFilesLoading function from FileContext, FileSearch, and Files components, streamlining the file handling state management.
- Updated the FileHandlingState type to make setFilesLoading optional, enhancing flexibility in file operations.
- Improved memoization logic by directly referencing necessary state properties, ensuring better performance and maintainability.

* refactor: Update ArtifactsContext for Improved State Management

- Replaced the useChatContext hook with direct Recoil state retrieval for isSubmitting, latestMessage, and conversationId, simplifying the context provider's logic.
- Enhanced memoization by ensuring relevant state properties are directly referenced, improving performance and maintainability.
- Streamlined the context value creation to reflect the updated state management approach.

* refactor: Adjust Memoization Logic in ArtifactsContext for Consistency

- Updated the memoization logic in the ArtifactsProvider to ensure the messageId is consistently referenced, improving clarity and maintainability.
- This change enhances the performance of the context provider by ensuring all relevant properties are included in the memoization dependencies.
2026-03-06 00:03:32 -05:00
Danny Avila
c324a8d9e4
refactor: Parallelize CI Workflows with Isolated Caching and Fan-Out Test Jobs (#12088)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* refactor: CI Workflow for Backend with Build and Test Jobs

- Updated the GitHub Actions workflow to include a new build job that compiles packages and uploads build artifacts.
- Added separate test jobs for each package (`api`, `data-provider`, and `data-schemas`) to run unit tests after the build process.
- Introduced caching for build artifacts to optimize build times.
- Configured Jest to utilize 50% of available workers for improved test performance across all Jest configurations in the `api`, `data-schemas`, and `packages/api` directories.

* refactor: Update CI Workflow for Backend with Enhanced Build and Cache Management

- Modified the GitHub Actions workflow to improve the build process by separating build and cache steps for `data-provider`, `data-schemas`, and `api` packages.
- Updated artifact upload and download steps to reflect the new naming conventions for better clarity.
- Enhanced caching strategies to optimize build times and ensure efficient artifact management.

* chore: Node Modules Caching in CI Workflow

- Updated the GitHub Actions workflow to implement caching for the `node_modules` directory, improving build efficiency by restoring cached dependencies.
- Adjusted the installation step to conditionally run based on cache availability, optimizing the overall CI process.

* refactor: Enhance CI Workflow for Frontend with Build and Test Jobs

- Updated the GitHub Actions workflow to introduce a structured build process for frontend packages, including separate jobs for building and testing on both Ubuntu and Windows environments.
- Implemented caching strategies for `node_modules` and build artifacts to optimize build times and improve efficiency.
- Added artifact upload and download steps for `data-provider` and `client-package` builds, ensuring that builds are reused across jobs.
- Adjusted Node.js version specification for consistency and reliability across different jobs.

* refactor: Update CI Workflows for Backend and Frontend with Node.js 20.19 and Enhanced Caching

- Updated Node.js version to 20.19 across all jobs in both backend and frontend workflows for consistency.
- Enhanced caching strategies for build artifacts and `node_modules`, increasing retention days from 1 to 2 for better efficiency.
- Adjusted cache keys to include additional files for improved cache hit rates during builds.
- Added conditional installation of dependencies to optimize the CI process.

* chore: Configure Jest to Use 50% of Available Workers Across Client and Data Provider

- Added `maxWorkers: '50%'` setting to Jest configuration files for the client and data provider packages to optimize test performance by utilizing half of the available CPU cores during test execution.

* chore: Enhance Node Modules Caching in CI Workflows

- Updated caching paths in both backend and frontend GitHub Actions workflows to include additional `node_modules` directories for improved dependency management.
- This change optimizes the caching strategy, ensuring that all relevant modules are cached, which can lead to faster build times and more efficient CI processes.

* chore: Update Node Modules Cache Keys in CI Workflows

- Modified cache keys in both backend and frontend GitHub Actions workflows to include the Node.js version (20.19) for improved cache management.
- This change ensures that the caching mechanism is more specific, potentially enhancing cache hit rates and build efficiency.

* chore: Refactor Node Modules Cache Keys in CI Workflows

- Updated cache keys in backend and frontend GitHub Actions workflows to be more specific, distinguishing between frontend and backend caches.
- Removed references to `client/node_modules` in backend workflows to streamline caching paths and improve cache management.
2026-03-05 13:56:07 -05:00
Danny Avila
d74a62ecd5
🕰️ fix: Preserve updatedAt Timestamps During Meilisearch Batch Sync (#12084)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* refactor: Add timestamps option to updateMany in createMeiliMongooseModel

- Updated the updateMany call in createMeiliMongooseModel to include a timestamps option set to false, ensuring that the operation does not modify the document's timestamps during the indexing process. This change improves the accuracy of document state management in MongoDB.

* test: Add tests to ensure updatedAt timestamps are preserved during syncWithMeili

- Introduced new test cases for the processSyncBatch function to verify that the original updatedAt timestamps on conversations and messages remain unchanged after synchronization with Meilisearch. This enhancement ensures data integrity during the indexing process.

* docs: Update comments in createMeiliMongooseModel to clarify timestamp preservation

- Enhanced comments in the createMeiliMongooseModel function to explain the use of the { timestamps: false } option in the updateMany call, ensuring that original conversation/message timestamps are preserved during the indexing process. This change improves code clarity and maintains the integrity of document timestamps.

* test: Enhance Meilisearch sync tests to verify updatedAt timestamp preservation

- Added assertions to ensure that the updatedAt timestamps of documents remain unchanged before and after synchronization with Meilisearch. This update improves the test coverage for the syncWithMeili function, reinforcing data integrity during the indexing process.
2026-03-05 10:40:43 -05:00
Danny Avila
9956a72694
🧭 fix: Subdirectory Deployment Auth Redirect Path Doubling (#12077)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* fix: subdirectory redirects

* fix: use path-segment boundary check when stripping BASE_URL prefix

A bare `startsWith(BASE_URL)` matches on character prefix, not path
segments. With BASE_URL="/chat", a path like "/chatroom/c/abc" would
incorrectly strip to "room/c/abc" (no leading slash). Guard with an
exact-match-or-slash check: `p === BASE_URL || p.startsWith(BASE_URL + '/')`.

Also removes the dead `BASE_URL !== '/'` guard — module init already
converts '/' to ''.

* test: add path-segment boundary tests and clarify subdirectory coverage

- Add /chatroom, /chatbot, /app/chatroom regression tests to verify
  BASE_URL stripping only matches on segment boundaries
- Clarify useAuthRedirect subdirectory test documents React Router
  basename behavior (BASE_URL stripping tested in api-endpoints-subdir)
- Use `delete proc.browser` instead of undefined assignment for cleanup
- Add rationale to eslint-disable comment for isolateModules require

* fix: use relative path and correct instructions in subdirectory test script

- Replace hardcoded /home/danny/LibreChat/.env with repo-root-relative
  path so the script works from any checkout location
- Update instructions to use production build (npm run build && npm run
  backend) since nginx proxies to :3080 which only serves the SPA after
  a full build, not during frontend:dev on :3090

* fix: skip pointless redirect_to=/ for root path and fix jsdom 26+ compat

buildLoginRedirectUrl now returns plain /login when the resolved path
is root — redirect_to=/ adds no value since / immediately redirects
to /c/new after login anyway.

Also rewrites api-endpoints.spec.ts to use window.history.replaceState
instead of Object.defineProperty(window, 'location', ...) which jsdom
26+ no longer allows.

* test: fix request-interceptor.spec.ts for jsdom 26+ compatibility

Switch from jsdom to happy-dom environment which allows
Object.defineProperty on window.location. jsdom 26+ made
location non-configurable, breaking all 8 tests in this file.

* chore: update browser property handling in api-endpoints-subdir test

Changed the handling of the `proc.browser` property from deletion to setting it to false, ensuring compatibility with the current testing environment.

* chore: update backend restart instructions in test subdirectory setup script

Changed the instruction for restarting the backend from "npm run backend:dev" to "npm run backend" to reflect the correct command for the current setup.

* refactor: ensure proper cleanup in loadModuleWithBase function

Wrapped the module loading logic in a try-finally block to guarantee that the `proc.browser` property is reset to false and the base element is removed, improving reliability in the testing environment.

* refactor: improve browser property handling in loadModuleWithBase function

Revised the management of the `proc.browser` property to store the original value before modification, ensuring it is restored correctly after module loading. This enhances the reliability of the testing environment.
2026-03-05 01:38:44 -05:00
Danny Avila
afb35103f1
📦 chore: Bump multer to v2.1.1
- Updated `multer` dependency from version 2.1.0 to 2.1.1 in both package.json and package-lock.json to incorporate the latest improvements and fixes.
2026-03-04 21:49:13 -05:00
Danny Avila
0ef369af9b
📦 chore: npm audit bump (#12074)
* chore: npm audit

- Bumped versions for several packages: `@hono/node-server` to 1.19.10, `@tootallnate/once` to 3.0.1, `hono` to 4.12.5, `serialize-javascript` to 7.0.4, and `svgo` to 2.8.2.
- Removed deprecated `@trysound/sax` package from package-lock.json.
- Updated integrity hashes and resolved URLs in package-lock.json to reflect the new versions.

* chore: update dependencies and package versions

- Bumped `jest-environment-jsdom` to version 30.2.0 in both package.json and client/package.json.
- Updated related Jest packages to version 30.2.0 in package-lock.json, ensuring compatibility with the latest features and fixes.
- Added `svgo` package with version 2.8.2 to package.json for improved SVG optimization.

* chore: add @happy-dom/jest-environment and update test files

- Added `@happy-dom/jest-environment` version 20.8.3 to `package.json` and `package-lock.json` for improved testing environment.
- Updated test files to utilize the new Jest environment, replacing mock implementations of `window.location` with `window.history.replaceState` for better clarity and maintainability.
- Refactored tests in `SourcesErrorBoundary`, `useFocusChatEffect`, `AuthContext`, and `StartupLayout` to enhance reliability and reduce complexity.
2026-03-04 20:25:12 -05:00
Danny Avila
956f8fb6f0
🏆 fix: Longest-or-Exact-Key Match in findMatchingPattern, Remove Deprecated Models (#12073)
* 🔧 fix: Use longest-match in findMatchingPattern, remove deprecated PaLM2/Codey models

findMatchingPattern now selects the longest matching key instead of the
first reverse-order match, preventing cross-provider substring collisions
(e.g., "gpt-5.2-chat-2025-12-11" incorrectly matching Google's "chat-"
pattern instead of OpenAI's "gpt-5.2"). Adds early exit when key length
equals model name length. Reorders aggregateModels spreads so OpenAI is
last (preferred on same-length ties). Removes deprecated PaLM2/Codey
entries from googleModels.

* refactor: re-order models based on more likely usage

* refactor: Improve key matching logic in findMatchingPattern

Updated the findMatchingPattern function to enhance key matching by ensuring case-insensitive comparisons and maintaining the longest match priority. Clarified comments regarding key ordering and performance implications, emphasizing the importance of defining older models first for efficiency and the handling of same-length ties. This refactor aims to improve code clarity and maintainability.

* test: Enhance findMatchingPattern tests for edge cases and performance

Added new test cases to the findMatchingPattern function, covering scenarios such as empty model names, case-insensitive matching, and performance optimizations. Included checks for longest match priority and ensured deprecated PaLM2/Codey models are no longer present in token entries. This update aims to improve test coverage and validate the function's behavior under various conditions.

* test: Update findMatchingPattern test to use last key for exact match validation

Modified the test for findMatchingPattern to utilize the last key from the openAIMap for exact match checks, ensuring the test accurately reflects the expected behavior of the function. This change enhances the clarity and reliability of the test case.
2026-03-04 19:34:13 -05:00
github-actions[bot]
c6dba9f0a1
🌍 i18n: Update translation.json with latest translations (#12070)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-04 19:00:58 -05:00
Danny Avila
7e85cf71bd
v0.8.3-rc2 (#12027)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Publish `@librechat/client` to NPM / build-and-publish (push) Has been cancelled
Publish `librechat-data-provider` to NPM / build (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / build-and-publish (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
2026-03-04 09:28:20 -05:00
Danny Avila
490ad30427
🧩 fix: Expand Toolkit Definitions to Include Child Tools in Event-Driven Mode (#12066)
* chore: Update logging format for tool execution handler to improve clarity

* fix: Expand toolkit tools in loadToolDefinitions for event-driven mode

The image_gen_oai toolkit contains both image_gen_oai and image_edit_oai
tools, but the definitions-only path only returned image_gen_oai. This
adds toolkit expansion so child tools are included in definitions, and
resolves child tool names to their parent toolkit constructor at runtime.

* chore: Remove toolkit flag from gemini_image_gen

gemini_image_gen only has a single tool, so it is not a true toolkit.

* refactor: Address review findings for toolkit expansion

- Guard against duplicate constructor calls when parent and child tools
  are both in the tools array (Finding 2)
- Consolidate image tool descriptions/schemas — registry now derives from
  toolkit objects (oaiToolkit, geminiToolkit) instead of duplicating them,
  so env var overrides are respected everywhere (Finding 5)
- Move toolkitExpansion/toolkitParent to toolkits/mapping.ts with
  immutable types (Findings 6, 9)
- Add tests for toolkit expansion, deduplication, and mapping
  invariants (Finding 1)
- Fix log format to quote each tool individually (Finding 8)

* fix: Correct toolkit constructor lookup to store under requested tool name

The previous dedup guard stored the factory under toolKey (parent name)
instead of tool (requested name), causing the promise loop to miss
child tools like image_edit_oai. Now stores under both the parent key
(for dedup) and the requested name (for lookup), with a memoized
factory to ensure the constructor runs only once.
2026-03-04 09:28:20 -05:00
Danny Avila
a0bcb44b8f
🎨 chore: Update Agent Tool with new SVG assets (#12065)
- Replaced external icon URLs in manifest.json with local SVG assets for Google Search, DALL-E-3, Tavily Search, Calculator, Stable Diffusion, Azure AI Search, and Flux.
- Added new SVG files for Google Search, DALL-E-3, Tavily, Calculator, Stable Diffusion, and Azure AI Search to the assets directory, enhancing performance and reliability by using local resources.
2026-03-04 09:28:19 -05:00
Danny Avila
f1eabdbdb7
🌗 refactor: Consistent Mermaid Theming for Inline and Artifact Renderers (#12055)
* refactor: consistent theming between inline and Artifacts Mermaid Diagram

* refactor: Enhance Mermaid component with improved theming and security features

- Updated Mermaid component to utilize useCallback for performance optimization.
- Increased maximum zoom level from 4 to 10 for better diagram visibility.
- Added security level configuration to Mermaid initialization for enhanced security.
- Refactored theme handling to ensure consistent theming between inline and artifact diagrams.
- Introduced unit tests for Mermaid configuration to validate flowchart settings and theme behavior.

* refactor: Improve theme handling in useMermaid hook

- Enhanced theme variable management by merging custom theme variables with default values for dark mode.
- Ensured consistent theming across Mermaid diagrams by preserving existing theme configurations while applying new defaults.

* refactor: Consolidate imports in mermaid test file

- Combined multiple imports from the mermaid utility into a single statement for improved readability and organization in the test file.

* feat: Add subgraph title contrast adjustment for Mermaid diagrams

- Introduced a utility function to enhance text visibility on subgraph titles by adjusting the fill color based on background luminance.
- Updated the Mermaid component to utilize this function, ensuring better contrast in rendered SVGs.
- Added comprehensive unit tests to validate the contrast adjustment logic across various scenarios.

* refactor: Update MermaidHeader component for improved button accessibility and styling

- Replaced Button components with TooltipAnchor for better accessibility and user experience.
- Consolidated button styles into a single class for consistency.
- Enhanced the layout and spacing of the header for a cleaner appearance.

* fix: hex color handling and improve contrast adjustment in Mermaid diagrams

- Updated hexLuminance function to support 3-character hex shorthand by expanding it to 6 characters.
- Refined the fixSubgraphTitleContrast function to avoid double semicolons in style attributes and ensure proper fill color adjustments based on background luminance.
- Added unit tests to validate the handling of 3-character hex fills and the prevention of double semicolons in text styles.

* chore: Simplify Virtual Scrolling Performance tests by removing performance timing checks

- Removed performance timing checks and associated console logs from tests handling 1000 and 5000 agents.
- Focused tests on verifying the correct rendering of virtual list items without measuring render time.
2026-03-04 09:28:19 -05:00
Danny Avila
6ebee069c7
🤝 fix: Respect Server Token Endpoint Auth Method Preference in MCP OAuth (#12052)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* fix(mcp): respect server's token endpoint auth method preference order

* fix(mcp): update token endpoint auth method to client_secret_basic

* fix(mcp): correct auth method to client_secret_basic in OAuth handler

* test(mcp): add tests for OAuth client registration method selection based on server preferences

* refactor(mcp): extract and implement token endpoint auth methods into separate utility functions

- Moved token endpoint authentication method logic from the MCPOAuthHandler to new utility functions in methods.ts for better organization and reusability.
- Added tests for the new methods to ensure correct behavior in selecting and resolving authentication methods based on server preferences and token exchange methods.
- Updated MCPOAuthHandler to utilize the new utility functions, improving code clarity and maintainability.

* chore(mcp): remove redundant comments in OAuth handler

- Cleaned up the MCPOAuthHandler by removing unnecessary comments related to authentication methods, improving code readability and maintainability.

* refactor(mcp): update supported auth methods to use ReadonlySet for better performance

- Changed the SUPPORTED_AUTH_METHODS from an array to a ReadonlySet for improved lookup efficiency.
- Enhanced the logic in selectRegistrationAuthMethod to prioritize credential-based methods and handle cases where the server advertises 'none' correctly, ensuring compliance with RFC 7591.

* test(mcp): add tests for selectRegistrationAuthMethod to handle 'none' and empty array cases

- Introduced new test cases to ensure selectRegistrationAuthMethod correctly prioritizes credential-based methods over 'none' when listed first or before other methods.
- Added a test to verify that an empty token_endpoint_auth_methods_supported returns undefined, adhering to RFC 8414.

* refactor(mcp): streamline authentication method handling in OAuth handler

- Simplified the logic for determining the authentication method by consolidating checks into a single function call.
- Removed redundant checks for supported auth methods, enhancing code clarity and maintainability.
- Updated the request header and body handling based on the resolved authentication method.

* fix(mcp): ensure compliance with RFC 6749 by removing credentials from body when using client_secret_basic

- Updated the MCPOAuthHandler to delete client_id and client_secret from body parameters when using the client_secret_basic authentication method, ensuring adherence to RFC 6749 §2.3.1.

* test(mcp): add tests for OAuth flow handling of client_secret_basic and client_secret_post methods

- Introduced new test cases to verify that the MCPOAuthHandler correctly removes client_id and client_secret from the request body when using client_secret_basic.
- Added tests to ensure proper handling of client_secret_post and none authentication methods, confirming that the correct parameters are included or excluded based on the specified method.
- Enhanced the test suite for completeOAuthFlow to cover various scenarios, ensuring compliance with OAuth 2.0 specifications.

* test(mcp): enhance tests for selectRegistrationAuthMethod and resolveTokenEndpointAuthMethod

- Added new test cases to verify the selection of the first supported credential method from a mixed list in selectRegistrationAuthMethod.
- Included tests to ensure resolveTokenEndpointAuthMethod correctly ignores unsupported preferred methods and handles empty tokenAuthMethods, returning undefined as expected.
- Improved test coverage for various scenarios in the OAuth flow, ensuring compliance with relevant specifications.

---------

Co-authored-by: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
2026-03-03 22:44:13 -05:00
Danny Avila
4af23474e2
📦 chore: Bump @librechat/agents to v3.1.55 (#12051) 2026-03-03 21:00:27 -05:00
Danny Avila
6394982f5a
📦 chore: Update underscore to v1.13.8 (#12050)
- Bumped `underscore` version from 1.13.7 to 1.13.8 to incorporate the latest improvements and fixes.
- Updated package-lock.json to reflect the new version and ensure consistency across dependencies.
2026-03-03 20:54:32 -05:00
Peter Nancarrow
14bcab60b3
🧬 feat: Allow Agent Editors to Duplicate Agents (#12041)
* feat: allow editors to duplicate agents

* fix: Update permissions for duplicating agents and enhance visibility in AgentFooter

- Changed required permission for duplicating agents from VIEW to EDIT in the API route.
- Updated AgentFooter component to display the duplicate button for admins and users with EDIT permission, improving access control.
- Added tests to ensure the duplicate button visibility logic works correctly based on user roles and permissions.

* test: Update AgentFooter tests to reflect permission changes

- Adjusted tests in AgentFooter.spec.tsx to verify UI behavior based on user permissions.
- Updated expectations for the visibility of the grant access dialog and duplicate button, ensuring they align with the new permission logic.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-03-03 20:45:02 -05:00
Danny Avila
d3622844ad
💰 feat: Add gpt-5.3 context window and pricing (#12049)
* 💰 feat: Add gpt-5.3 context window and pricing

* 💰 feat: Add OpenAI cached input pricing and `gpt-5.2-pro` model

    - Add cached input pricing (write/read) for gpt-4o, gpt-4.1, gpt-5.x,
      o1, o3, o4-mini models with correct per-family discount tiers
    - Add gpt-5.2-pro pricing ($21/$168), context window, and max output
    - Pro models (gpt-5-pro, gpt-5.2-pro) correctly excluded from cache
      pricing as OpenAI does not support caching for these

* 🔍 fix: Address review findings for OpenAI pricing

- Add o1-preview to cacheTokenValues (50% discount, same as o1)
- Fix comment to enumerate all models per discount tier
- Add cache tests for dated variants (gpt-4o-2024-08-06, etc.)
- Add gpt-5-mini/gpt-5-nano to 10% ratio invariant test
- Replace forEach with for...of in new test code
- Fix inconsistent test description phrasing
- Add gpt-5.3-preview to context window tests
2026-03-03 20:44:05 -05:00
Danny Avila
474001c140
🌍 chore: Update translation for "no auth" message in UI (#12048)
- Changed the text for the "No Auth" message to "None (Auto-detect)" in the English translation file, enhancing clarity for users regarding authentication status.
2026-03-03 18:16:55 -05:00
Danny Avila
d3c06052d7
🗝️ feat: Credential Variables for DB-Sourced MCP Servers (#12044)
* feat: Allow Credential Variables in Headers for DB-sourced MCP Servers

- Removed the hasCustomUserVars check from ToolService.js, directly retrieving userMCPAuthMap.
- Updated MCPConnectionFactory and related classes to include a dbSourced flag for better handling of database-sourced configurations.
- Added integration tests to ensure proper behavior of dbSourced servers, verifying that sensitive placeholders are not resolved while allowing customUserVars.
- Adjusted various MCP-related files to accommodate the new dbSourced logic, ensuring consistent handling across the codebase.

* chore: MCPConnectionFactory Tests with Additional Flow Metadata for typing

- Updated MCPConnectionFactory tests to include new fields in flowMetadata: serverUrl and state.
- Enhanced mockFlowData in multiple test cases to reflect the updated structure, ensuring comprehensive coverage of the OAuth flow scenarios.
- Added authorization_endpoint to metadata in the test setup for improved validation of the OAuth process.

* refactor: Simplify MCPManager Configuration Handling

- Removed unnecessary type assertions and streamlined the retrieval of server configuration in MCPManager.
- Enhanced the handling of OAuth and database-sourced flags for improved clarity and efficiency.
- Updated tests to reflect changes in user object structure and ensure proper processing of MCP environment variables.

* refactor: Optimize User MCP Auth Map Retrieval in ToolService

- Introduced conditional loading of userMCPAuthMap based on the presence of MCP-delimited tools, improving efficiency by avoiding unnecessary calls.
- Updated the loadToolDefinitionsWrapper and loadAgentTools functions to reflect this change, enhancing overall performance and clarity.

* test: Add userMCPAuthMap gating tests in ToolService

- Introduced new tests to validate the logic for determining if MCP tools are present in the agent's tool list.
- Implemented various scenarios to ensure accurate detection of MCP tools, including edge cases for empty, undefined, and null tool lists.
- Enhanced clarity and coverage of the ToolService capability checking logic.

* refactor: Enhance MCP Environment Variable Processing

- Simplified the handling of the dbSourced parameter in the processMCPEnv function.
- Introduced a failsafe mechanism to derive dbSourced from options if not explicitly provided, improving robustness and clarity in MCP environment variable processing.

* refactor: Update Regex Patterns for Credential Placeholders in ServerConfigsDB

- Modified regex patterns to include additional credential/env placeholders that should not be allowed in user-provided configurations.
- Clarified comments to emphasize the security risks associated with credential exfiltration when MCP servers are shared between users.

* chore: field order

* refactor: Clean Up dbSourced Parameter Handling in processMCPEnv

- Reintroduced the failsafe mechanism for deriving the dbSourced parameter from options, ensuring clarity and robustness in MCP environment variable processing.
- Enhanced code readability by maintaining consistent comment structure.

* refactor: Update MCPOptions Type to Include Optional dbId

- Modified the processMCPEnv function to extend the MCPOptions type, allowing for an optional dbId property.
- Simplified the logic for deriving the dbSourced parameter by directly checking the dbId property, enhancing code clarity and maintainability.
2026-03-03 18:02:37 -05:00
Danny Avila
a2a09b556a
🤖 feat: gemini-3.1-flash-lite-preview Window & Pricing (#12043)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* 🤖 feat: `gemini-3.1-flash-lite-preview` Window & Pricing

- Updated `.env.example` to include `gemini-3.1-flash-lite-preview` in the list of available models.
- Enhanced `tx.js` to define token values for `gemini-3.1-flash-lite`.
- Adjusted `tokens.ts` to allocate input tokens for `gemini-3.1-flash-lite`.
- Modified `config.ts` to include `gemini-3.1-flash-lite-preview` in the default models list.

* chore: testing for `gemini-3.1-flash-lite` model, comments

- Updated `tx.js` to include cache token values for `gemini-3.1-flash-lite` with specific write and read rates.
- Enhanced `tx.spec.js` to include tests for the new `gemini-3.1-flash-lite-preview` model, ensuring correct rate retrieval for both prompt and completion token types.
2026-03-03 13:47:16 -05:00
Danny Avila
3e487df193
📦 chore: Bump turbo to v2.8.12 (#12042) 2026-03-03 12:12:17 -05:00
Danny Avila
2f2a259c4e
📦 chore: Bump fast-xml-parser to v5.3.8 (#12040) 2026-03-03 12:08:20 -05:00
Danny Avila
619d35360d
🔒 fix: Request interceptor for Shared Link Page Scenarios (#12036)
* ♻️ refactor: Centralize `buildLoginRedirectUrl` in data-provider

Move `buildLoginRedirectUrl` from `client/src/utils/redirect.ts` into
`packages/data-provider/src/api-endpoints.ts` so the axios 401
interceptor (and any other data-provider consumer) can use the canonical
implementation with the LOGIN_PATH_RE guard and BASE_URL awareness.

The client module now re-exports from `librechat-data-provider`, keeping
all existing imports working unchanged.

* 🔒 fix: Shared link 401 interceptor bypass and redirect loop (#12033)

Fixes three issues in the axios 401 response interceptor that prevented
private shared links (ALLOW_SHARED_LINKS_PUBLIC=false) from working:

1. `window.location.href.includes('share/')` matched the full URL
   (including query params and hash), causing false positives. Changed
   to `window.location.pathname.startsWith('/share/')`.

2. When token refresh returned no token on a share page, the
   interceptor logged and fell through without redirecting, causing an
   infinite retry loop via React Query. Now redirects to login using
   `buildLoginRedirectUrl()` which preserves the share URL for
   post-login navigation.

3. `processQueue` was never called in the no-token branch, leaving
   queued requests with dangling promise callbacks. Added
   `processQueue(error, null)` before the redirect.

*  test: Comprehensive 401 interceptor tests for shared link auth flow

Rewrite interceptor test suite to cover all shared link auth scenarios:

- Unauthenticated user on share page with failed refresh → redirect
- Authenticated user on share page with failed refresh → redirect
- share/ in query params does NOT bypass the auth header guard
- Login path guard: redirect to plain /login (no redirect_to loop)
- Refresh success: assert exact call count (toBe(3) vs toBeGreaterThan)

Test reliability improvements:
- window.location teardown moved to afterEach (no state leak on failure)
- expect.assertions(N) on all tests (catch silent false passes)
- Shared setWindowLocation helper for consistent location mocking

* ♻️ refactor: Import `buildLoginRedirectUrl` directly from data-provider

Update `AuthContext.tsx` and `useAuthRedirect.ts` to import
`buildLoginRedirectUrl` from `librechat-data-provider` instead of
re-exporting through `~/utils/redirect.ts`.

Convert `redirect.ts` to ESM-style inline exports and remove the
re-export of `buildLoginRedirectUrl`.

*  test: Move `buildLoginRedirectUrl` tests to data-provider

Tests for `buildLoginRedirectUrl` now live alongside the implementation
in `packages/data-provider/specs/api-endpoints.spec.ts`.

Removed the duplicate describe block from the client redirect test file
since it no longer owns that function.
2026-03-03 12:03:33 -05:00
Danny Avila
23237255d8
chore: bump vite to v7 (#12031)
* 🔧 chore: Update @vitejs/plugin-react to version 5.1.4 and clean up package-lock.json

- Upgraded @vitejs/plugin-react from version 4.3.4 to 5.1.4 in both package.json and package-lock.json.
- Removed unused dependencies related to previous plugin versions from package-lock.json.
- Updated @babel/compat-data to version 7.29.0 and added new dependencies for Babel plugins.

* 🔧 chore: Upgrade vite-plugin-pwa to version 1.2.0 in package.json and package-lock.json

- Updated vite-plugin-pwa from version 0.21.2 to 1.2.0 in both package.json and package-lock.json to ensure compatibility with the latest features and improvements.
- Removed outdated dependency entries related to the previous version from package-lock.json.

* 🔧 chore: Upgrade vite to version 7.3.1 in package.json and package-lock.json

- Updated vite from version 6.4.1 to 7.3.1 in both package.json and package-lock.json to leverage new features and improvements.
- Added new esbuild packages for various architectures in package-lock.json to support broader compatibility.

* 🔧 chore: Update @babel dependencies and vite-plugin-node-polyfills version in package.json and package-lock.json

- Upgraded vite-plugin-node-polyfills from version 0.23.0 to 0.25.0 for improved compatibility.
- Added several new @babel packages and updated existing ones to version 7.29.0 and 7.28.6, enhancing Babel's functionality and support.
- Removed outdated semver entries from package-lock.json to streamline dependencies.

* 🔧 chore: Vite configuration with node polyfills resolver and clean up imports

- Added a custom resolver for node polyfills shims to improve compatibility with legacy modules.
- Cleaned up import statements by removing unnecessary comments and organizing imports for better readability.
- Utilized `createRequire` to handle module resolution in a more efficient manner.

* 🔧 chore: Upgrade fast-xml-parser to version 5.3.8 in package.json and package-lock.json

- Updated fast-xml-parser from version 5.3.6 to 5.3.8 in both package.json and package-lock.json to incorporate the latest features and improvements.
- Ensured consistency across dependencies by aligning the version in all relevant files.

* 🔧 chore: Upgrade @types/node to version 20.19.35 in package.json and package-lock.json

- Updated @types/node from version 20.3.0 to 20.19.35 in both package.json and package-lock.json to ensure compatibility with the latest TypeScript features and improvements.

* 🔧 chore: Vite configuration to centralize node polyfills shims

- Moved node polyfills shims into a dedicated constant for improved readability and maintainability.
- Updated the custom resolver to utilize the new centralized shims, enhancing compatibility with legacy modules.
- Added documentation to clarify the purpose of the node polyfills shims mapping.
2026-03-03 10:25:10 -05:00
Danny Avila
b1771e0a6e
🌐 fix: Preserve URL Query Params Through Auth Refresh and Conversation Init (#12028)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🔗 fix: Preserve URL query params during silent token refresh

The silent token refresh on hard navigation was redirecting to '/c/new'
without query params, wiping the URL before ChatRoute could read them.
Now preserves the current URL (pathname + search) as the redirect
fallback, with isSafeRedirect validation.

* 🧭 fix: Apply URL query params in ChatRoute initialization

ChatRoute now reads URL search params (endpoint, model, agent_id, etc.)
and merges them into the preset passed to newConversation(), so the
first conversation init already includes the URL param settings. This
eliminates the race where useQueryParams fired too late.

- Export processValidSettings from useQueryParams for reuse
- Add getNewConvoPreset helper in ChatRoute (used in both NEW_CONVO branches)
- Query params take precedence over model spec defaults
- useQueryParams now waits for endpointsConfig before processing
- Skip redundant newQueryConvo when settings are already applied
- Clean all URL params via setSearchParams after processing

*  test: Update useQueryParams tests for new URL cleanup behavior

- Assert setSearchParams called instead of window.history.replaceState
- Mock endpoints config in deferred submission and timeout tests

* ♻️ refactor: Move processValidSettings to ~/utils and address review findings

- Move processValidSettings/parseQueryValue to createChatSearchParams.ts
  (pure utility, not hook-specific)
- Fix processSubmission: use setSearchParams instead of replaceState,
  move URL cleanup outside data.text guard
- Narrow endpointsConfig guard: only block settings application, not
  prompt-only flows
- Convert areSettingsApplied to stable useCallback ([] deps) with
  conversationRef to avoid interval churn on conversation updates
- Replace console.log with logger.log in production paths
- Restore explanatory comment on pendingSubmitRef guard
- Use for...of in processValidSettings (CLAUDE.md preference)
- Remove unused imports from useQueryParams

* 🔧 fix: Add areSettingsApplied to effect deps and fix test mocks

- Restore areSettingsApplied in main effect deps (stable identity with
  [] deps, safe to include — satisfies exhaustive-deps lint rule)
- Fix all test getQueryData mocks to properly distinguish between
  startupConfig and endpoints keys
- Assert setSearchParams call arguments (URLSearchParams + replace:true)

*  test: Assert empty URLSearchParams in setSearchParams calls

Tighten setSearchParams assertions to verify the params are empty
(toString() === ''), not just that a URLSearchParams instance was passed.

* 🔧 test: Update AuthContext tests to navigate to current URL for redirects

- Modified test cases to assert navigation to the current URL instead of a hardcoded '/c/new' when no stored redirect exists or when falling back from unsafe stored redirects.
- Enhanced test setup to define window.location for accurate simulation of redirect behavior.
2026-03-02 23:32:53 -05:00
Danny Avila
7c71875da3
🧭 fix: Restore Post-Auth Navigation After Silent Token Refresh (#12025)
* chore: Update import path for StartupLayout in tests

* 🔒 fix: Enhance AuthContext to handle stored redirects during user authentication

- Added SESSION_KEY import and logic to retrieve and clear stored redirect URLs from sessionStorage.
- Updated user context state to include redirect URL, defaulting to '/c/new' if none is found.

* 🧪 test: Add tests for silentRefresh post-login redirect handling in AuthContext

- Introduced new test suite to validate navigation behavior after successful token refresh.
- Implemented tests for stored sessionStorage redirects, default navigation, and prevention of unsafe redirects.
- Enhanced logout error handling tests to ensure proper state clearing without external redirects.

* 🔒 fix: Update AuthContext to handle unsafe stored redirects during authentication

- Removed conditional check for stored redirect in sessionStorage, ensuring it is always cleared.
- Enhanced logic to validate stored redirects, defaulting to '/c/new' for unsafe URLs.
- Updated tests to verify navigation behavior for both safe and unsafe redirects after token refresh.
2026-03-02 22:20:00 -05:00
Danny Avila
9b3152807b
🐳 chore: Update image registry references in Docker/Helm configurations (#12026)
- Changed image references from `ghcr.io` to `registry.librechat.ai` across multiple Docker and Helm files, ensuring consistency in image sourcing.
- Updated `deploy-compose.yml`, `docker-compose.override.yml.example`, `docker-compose.yml`, `rag.yml`, and various Helm chart files to reflect the new registry.
2026-03-02 22:14:50 -05:00
Dustin Healy
93560f5f5b
👥 fix: Duplicate Indicators for Model Specs (#11946)
* fix: key checkmark by endpoint , not just model name

* fix: model spec endpoint collision for checkmark indicators

* chore: fix formatting

* refactor: move isSelected into EndpointModelItem, fix SearchResults, add tests

Address PR review feedback:
- Move isSelected computation from renderEndpointModels into EndpointModelItem
  via useModelSelectorContext, eliminating fragile positional params
- Add !selectedSpec guard to SearchResults.tsx for both model and endpoint checks
- Add unit tests for EndpointModelItem selection logic

* test: update EndpointModelItem tests and add SearchResults tests

- Update EndpointModelItem tests to replace null modelSpec with an empty string for consistency in rendering logic.
- Introduce new SearchResults tests to validate selection behavior based on endpoint and model matching, including scenarios with and without active specs.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-03-02 21:48:55 -05:00
Danny Avila
b18915a96b
🚪 fix: Complete OIDC RP-Initiated Logout With id_token_hint and Redirect Race Fix (#12024)
* fix: complete OIDC logout implementation

The OIDC logout feature added in #5626 was incomplete:

1. Backend: Missing id_token_hint/client_id parameters required by the
   RP-Initiated Logout spec. Keycloak 18+ rejects logout without these.

2. Frontend: The logout redirect URL was passed through isSafeRedirect()
   which rejects all absolute URLs. The redirect was silently dropped.

Backend: Add id_token_hint (preferred) or client_id (fallback) to the
logout URL for OIDC spec compliance.

Frontend: Use window.location.replace() for logout redirects from the
backend, bypassing isSafeRedirect() which was designed for user-input
validation.

Fixes #5506

* fix: accept undefined in setTokenHeader to properly clear Authorization header

When token is undefined, delete the Authorization header instead of
setting it to "Bearer undefined". Removes the @ts-ignore workaround
in AuthContext.

* fix: skip axios 401 refresh when Authorization header is cleared

When the Authorization header has been removed (e.g. during logout),
the response interceptor now skips the token refresh flow. This
prevents a successful refresh from canceling an in-progress OIDC
external redirect via window.location.replace().

* fix: guard against undefined OPENID_CLIENT_ID in logout URL

Prevent literal "client_id=undefined" in the OIDC end-session URL
when OPENID_CLIENT_ID is not set. Log a warning when neither
id_token_hint nor client_id is available.

* fix: prevent race condition canceling OIDC logout redirect

The logout mutation wrapper's cleanup (clearStates, removeQueries)
triggers re-renders and 401s on in-flight requests. The axios
interceptor would refresh the token successfully, firing
dispatchTokenUpdatedEvent which cancels the window.location.replace()
navigation to the IdP's end_session_endpoint.

Fix:
- Clear Authorization header synchronously before redirect so the
  axios interceptor skips refresh for post-logout 401s
- Add isExternalRedirectRef to suppress silentRefresh and useEffect
  side effects during the redirect
- Add JSDoc explaining why isSafeRedirect is bypassed

* test: add LogoutController and AuthContext logout test coverage

LogoutController.spec.js (13 tests):
- id_token_hint from session and cookie fallback
- client_id fallback, including undefined OPENID_CLIENT_ID guard
- Disabled endpoint, missing issuer, non-OpenID user
- post_logout_redirect_uri (custom and default)
- Missing OpenID config and end_session_endpoint
- Error handling and cookie clearing

AuthContext.spec.tsx (3 tests):
- OIDC redirect calls window.location.replace + setTokenHeader
- Non-redirect logout path
- Logout error handling

* test: add coverage for setTokenHeader, axios interceptor guard, and silentRefresh suppression

headers-helpers.spec.ts (3 tests):
- Sets Authorization header with Bearer token
- Deletes Authorization header when called with undefined
- No-op when clearing an already absent header

request-interceptor.spec.ts (2 tests):
- Skips refresh when Authorization header is cleared (the race fix)
- Attempts refresh when Authorization header is present

AuthContext.spec.tsx (1 new test):
- Verifies silentRefresh is not triggered after OIDC redirect

* test: enhance request-interceptor tests with adapter restoration and refresh verification

- Store the original axios adapter before tests and restore it after all tests to prevent side effects.
- Add verification for the refresh endpoint call in the interceptor tests to ensure correct behavior during token refresh attempts.

* test: enhance AuthContext tests with live rendering and improved logout error handling

- Introduced a new `renderProviderLive` function to facilitate testing with silentRefresh.
- Updated tests to use the live rendering function, ensuring accurate simulation of authentication behavior.
- Enhanced logout error handling test to verify that auth state is cleared without external redirects.

* test: update LogoutController tests for OpenID config error handling

- Renamed test suite to clarify that it handles cases when OpenID config is not available.
- Modified test to check for error thrown by getOpenIdConfig instead of returning null, ensuring proper logging of the error message.

* refactor: improve OpenID config error handling in LogoutController

- Simplified error handling for OpenID configuration retrieval by using a try-catch block.
- Updated logging to provide clearer messages when the OpenID config is unavailable.
- Ensured that the end session endpoint is only accessed if the OpenID config is successfully retrieved.

---------

Co-authored-by: cloudspinner <stijn.tastenhoye@gmail.com>
2026-03-02 21:34:13 -05:00
Daniel Lew
c0236b4ba7
🔍 fix: Correct Conversations ARIA Role and Increase Placeholder Contrast (#12021)
- Set Conversations list role as "rowgroup", which better describes
  what is actually going on than "row".

- Increased contrast on placeholder text in ChatForm.
2026-03-02 21:25:48 -05:00
Daniel Lew
8f7579c2f5
🫳 fix: Restore Background on Drag and Drop Overlay (#12017)
The drag & drop background was practically translucent, which made
it hard to see the rest of the overlay (especially on light mode).

Now, we no longer make the background translucent, so you can see
the overlay clearly.
2026-03-02 21:08:58 -05:00
Daniel Lew
8130db577f
💤 fix: Add inert to Hidden/Background Content (#12016)
When content is hidden (or in the background of the active form),
users shouldn't be allowed to access that content. However, right now,
you can use a keyboard (or screen reader) to move over to this content.

By adding `inert`, we make this content no longer accessible when hidden.

I've done this in two places:

- The sidebar is now inert when it's closed.

- When the sidebar is open & the window is small, the content area is
  inert (since it's mostly obscured by the sidebar).
2026-03-02 21:04:52 -05:00
Jón Levy
f7ac449ca4
🔌 fix: Resolve MCP OAuth flow state race condition (#11941)
* 🔌 fix: Resolve MCP OAuth flow state race condition

The OAuth callback arrives before the flow state is stored because
`createFlow()` returns a long-running Promise that only resolves on
flow COMPLETION, not when the initial PENDING state is persisted.
Calling it fire-and-forget with `.catch(() => {})` meant the redirect
happened before the state existed, causing "Flow state not found"
errors.

Changes:
- Add `initFlow()` to FlowStateManager that stores PENDING state and
  returns immediately, decoupling state persistence from monitoring
- Await `initFlow()` before emitting the OAuth redirect so the
  callback always finds existing state
- Keep `createFlow()` in the background for monitoring, but log
  warnings instead of silently swallowing errors
- Increase FLOWS cache TTL from 3 minutes to 10 minutes to give
  users more time to complete OAuth consent screens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 🔌 refactor: Revert FLOWS cache TTL change

The race condition fix (initFlow) is sufficient on its own.
TTL configurability should be a separate enhancement via
librechat.yaml mcpSettings rather than a hardcoded increase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 🔌 fix: Address PR review — restore FLOWS TTL, fix blocking-path race, clean up dead args

- Restore FLOWS cache TTL to 10 minutes (was silently dropped back to 3)
- Add initFlow before oauthStart in blocking handleOAuthRequired path
  to guarantee state persistence before any redirect
- Pass {} to createFlow metadata arg (dead after initFlow writes state)
- Downgrade background monitor .catch from logger.warn to logger.debug
- Replace process.nextTick with Promise.resolve in test (correct semantics)
- Add initFlow TTL assertion test
- Add blocking-path ordering test (initFlow → oauthStart → createFlow)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:27:36 -05:00
Danny Avila
2a5123bfa1
📅 refactor: Replace Numeric Weekday Index with Named Day in Date Template Variables (#12022)
* feat(data-provider): include timezone and weekday label in current_datetime

* fix(data-provider): use named weekday for both date variables and single dayjs instance

Use a single `const now = dayjs()` instead of 5 separate instantiations,
apply named weekday to `{{current_date}}` (not just `{{current_datetime}}`),
simplify weekday format from `(weekday=Monday)` to `(Monday)`, and
harden test mock fallback to throw on unhandled format strings.

* chore(data-provider): remove dead day() mock from parsers spec

---------

Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
2026-03-02 19:22:11 -05:00
Danny Avila
a0a1749151
🔗 fix: Normalize MCP OAuth resource parameter to match token exchange (#12018)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* 🔗 fix: Normalize MCP OAuth `resource` parameter to match token exchange

The authorization request used the raw resource string from metadata while
the token exchange normalized it through `new URL().href`, causing a
trailing-slash mismatch that Cloudflare's auth server rejected. Canonicalize
the resource URL in both paths so they match.

* 🔧 test: Simplify LeaderElection integration tests for Redis

Refactored the integration tests for LeaderElection with Redis by reducing the number of instances from 100 to 1, streamlining the leadership election process. Updated assertions to verify leadership status and UUID after resignation, improving test clarity and performance. Adjusted timeout to 15 seconds for the single instance scenario.

* 🔧 test: Update LeaderElection test case description for clarity

Modified the description of the test case for leader resignation in the LeaderElection integration tests to better reflect the expected behavior, enhancing clarity and understanding of the test's purpose.

* refactor: `resource` parameter in MCP OAuth authorization URL

Updated the `MCPOAuthHandler` to ensure the `resource` parameter is added to the authorization URL even when an error occurs while retrieving it from metadata. This change improves the handling of invalid resource URLs by using the raw value as a fallback, enhancing the robustness of the authorization process.
2026-03-02 15:52:29 -05:00
github-actions[bot]
36e37003c9
🌍 i18n: Update translation.json with latest translations (#12005)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-01 19:28:03 -05:00
Danny Avila
1f82fb8692
🪵 refactor: onmessage Handler and Restructure MCP Debug Logging (#12004)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🪵 refactor: Simplify MCP Transport Log Messages

- Updated the logging in MCPConnection to provide clearer output by explicitly logging the method and ID of messages received and sent, improving traceability during debugging.
- This change replaces the previous JSON stringification of messages with a more structured log format, enhancing readability and understanding of the transport interactions.

* 🔧 refactor: Streamline MCPConnection Message Handling

- Removed redundant onmessage logging in MCPConnection to simplify the codebase.
- Introduced a dedicated setupTransportOnMessageHandler method to centralize message handling and improve clarity in transport interactions.
- Enhanced logging to provide clearer output for received messages, ensuring better traceability during debugging.

* 🔧 refactor: Rename setupTransportDebugHandlers to patchTransportSend

- Updated the MCPConnection class to rename the setupTransportDebugHandlers method to patchTransportSend for improved clarity.
- Adjusted the method call in the connection setup process to reflect the new naming, enhancing code readability and maintainability.
2026-03-01 19:23:45 -05:00
Danny Avila
5be90706b0
✂️ fix: Unicode-Safe Title Truncation and Shared View Layout Polish (#12003)
* fix: title sanitization with max length truncation and update ShareView for better text display

- Added functionality to `sanitizeTitle` to truncate titles exceeding 200 characters with an ellipsis, ensuring consistent title length.
- Updated `ShareView` component to apply a line clamp on the title, improving text display and preventing overflow in the UI.

* refactor: Update layout and styling in MessagesView and ShareView components

- Removed unnecessary padding in MessagesView to streamline the layout.
- Increased bottom padding in the message container for better spacing.
- Enhanced ShareView footer positioning and styling for improved visibility.
- Adjusted section and div classes in ShareView for better responsiveness and visual consistency.

* fix: Correct title fallback and enhance sanitization logic in sanitizeTitle

- Updated the fallback title in sanitizeTitle to use DEFAULT_TITLE_FALLBACK instead of a hardcoded string.
- Improved title truncation logic to ensure proper handling of maximum length and whitespace, including edge cases for emoji and whitespace-only titles.
- Added tests to validate the new sanitization behavior, ensuring consistent and expected results across various input scenarios.
2026-03-01 16:44:57 -05:00
Danny Avila
ce1338285c
📦 chore: update multer dependency to v2.1.0 (#12000)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
2026-03-01 12:51:31 -05:00
Danny Avila
e1e204d6cf
🧮 refactor: Bulk Transactions & Balance Updates for Token Spending (#11996)
* refactor: transaction handling by integrating pricing and bulk write operations

- Updated `recordCollectedUsage` to accept pricing functions and bulk write operations, improving transaction management.
- Refactored `AgentClient` and related controllers to utilize the new transaction handling capabilities, ensuring better performance and accuracy in token spending.
- Added tests to validate the new functionality, ensuring correct behavior for both standard and bulk transaction paths.
- Introduced a new `transactions.ts` file to encapsulate transaction-related logic and types, enhancing code organization and maintainability.

* chore: reorganize imports in agents client controller

- Moved `getMultiplier` and `getCacheMultiplier` imports to maintain consistency and clarity in the import structure.
- Removed duplicate import of `updateBalance` and `bulkInsertTransactions`, streamlining the code for better readability.

* refactor: add TransactionData type and CANCEL_RATE constant to data-schemas

Establishes a single source of truth for the transaction document shape
and the incomplete-context billing rate constant, both consumed by
packages/api and api/.

* refactor: use proper types in data-schemas transaction methods

- Replace `as unknown as { tokenCredits }` with `lean<IBalance>()`
- Use `TransactionData[]` instead of `Record<string, unknown>[]`
  for bulkInsertTransactions parameter
- Add JSDoc noting insertMany bypasses document middleware
- Remove orphan section comment in methods/index.ts

* refactor: use shared types in transactions.ts, fix bulk write logic

- Import CANCEL_RATE from data-schemas instead of local duplicate
- Import TransactionData from data-schemas for PreparedEntry/BulkWriteDeps
- Use tilde alias for EndpointTokenConfig import
- Pass valueKey through to getMultiplier
- Only sum tokenValue for balance-enabled docs in bulkWriteTransactions
- Consolidate two loops into single-pass map

* refactor: remove duplicate updateBalance from Transaction.js

Import updateBalance from ~/models (sourced from data-schemas) instead
of maintaining a second copy. Also import CANCEL_RATE from data-schemas
and remove the Balance model import (no longer needed directly).

* fix: test real spendCollectedUsage instead of IIFE replica

Export spendCollectedUsage from abortMiddleware.js and rewrite the test
file to import and test the actual function. Previously the tests ran
against a hand-written replica that could silently diverge from the real
implementation.

* test: add transactions.spec.ts and restore regression comments

Add 22 direct unit tests for transactions.ts financial logic covering
prepareTokenSpend, prepareStructuredTokenSpend, bulkWriteTransactions,
CANCEL_RATE paths, NaN guards, disabled transactions, zero tokens,
cache multipliers, and balance-enabled filtering.

Restore critical regression documentation comments in
recordCollectedUsage.spec.js explaining which production bugs the
tests guard against.

* fix: widen setValues type to include lastRefill

The UpdateBalanceParams.setValues type was Partial<Pick<IBalance,
'tokenCredits'>> which excluded lastRefill — used by
createAutoRefillTransaction. Widen to also pick 'lastRefill'.

* test: use real MongoDB for bulkWriteTransactions tests

Replace mock-based bulkWriteTransactions tests with real DB tests using
MongoMemoryServer. Pure function tests (prepareTokenSpend,
prepareStructuredTokenSpend) remain mock-based since they don't touch
DB. Add end-to-end integration tests that verify the full prepare →
bulk write → DB state pipeline with real Transaction and Balance models.

* chore: update @librechat/agents dependency to version 3.1.54 in package-lock.json and related package.json files

* test: add bulk path parity tests proving identical DB outcomes

Three test suites proving the bulk path (prepareTokenSpend/
prepareStructuredTokenSpend + bulkWriteTransactions) produces
numerically identical results to the legacy path for all scenarios:

- usage.bulk-parity.spec.ts: mirrors all legacy recordCollectedUsage
  tests; asserts same return values and verifies metadata fields on
  the insertMany docs match what spendTokens args would carry

- transactions.bulk-parity.spec.ts: real-DB tests using actual
  getMultiplier/getCacheMultiplier pricing functions; asserts exact
  tokenValue, rate, rawAmount and balance deductions for standard
  tokens, structured/cache tokens, CANCEL_RATE, premium pricing,
  multi-entry batches, and edge cases (NaN, zero, disabled)

- Transaction.spec.js: adds describe('Bulk path parity') that mirrors
  7 key legacy tests via recordCollectedUsage + bulk deps against
  real MongoDB, asserting same balance deductions and doc counts

* refactor: update llmConfig structure to use modelKwargs for reasoning effort

Refactor the llmConfig in getOpenAILLMConfig to store reasoning effort within modelKwargs instead of directly on llmConfig. This change ensures consistency in the configuration structure and improves clarity in the handling of reasoning properties in the tests.

* test: update performance checks in processAssistantMessage tests

Revise the performance assertions in the processAssistantMessage tests to ensure that each message processing time remains under 100ms, addressing potential ReDoS vulnerabilities. This change enhances the reliability of the tests by focusing on maximum processing time rather than relative ratios.

* test: fill parity test gaps — model fallback, abort context, structured edge cases

- usage.bulk-parity: add undefined model fallback test
- transactions.bulk-parity: add abort context test (txns inserted,
  balance unchanged when balance not passed), fix readTokens type cast
- Transaction.spec: add 3 missing mirrors — balance disabled with
  transactions enabled, structured transactions disabled, structured
  balance disabled

* fix: deduct balance before inserting transactions to prevent orphaned docs

Swap the order in bulkWriteTransactions: updateBalance runs before
insertMany. If updateBalance fails (after exhausting retries), no
transaction documents are written — avoiding the inconsistent state
where transactions exist in MongoDB with no corresponding balance
deduction.

* chore: import order

* test: update config.spec.ts for OpenRouter reasoning in modelKwargs

Same fix as llm.spec.ts — OpenRouter reasoning is now passed via
modelKwargs instead of llmConfig.reasoning directly.
2026-03-01 12:26:36 -05:00
Daniel Lew
0e5ee379b3
👁️‍🗨️ fix: Replace Select with Menu in AccountSettings for Screen Reader Accuracy (#11980)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
AccountSettings was using Select, but it makes more sense (for a11y)
to use Menu. The Select has the wrong role & behavior for the purpose
of AccountSettings; the "listbox" role it uses is for selecting
values in a form.

Menu matches the actual content better for screen readers; the
"menu" role is more appropriate for selecting one of a number of links.
2026-02-28 16:58:50 -05:00
Danny Avila
723acd830c
🎚️ feat: Add Thinking Level Parameter for Gemini 3+ Models (#11994)
* 🧠 feat: Add Thinking Level Config for Gemini 3 Models

- Introduced a new setting for 'thinking level' in the Google configuration, allowing users to control the depth of reasoning for Gemini 3 models.
- Updated translation files to include the new 'thinking level' label and description.
- Enhanced the Google LLM configuration to support the new 'thinking level' parameter, ensuring compatibility with both Google and Vertex AI providers.
- Added necessary schema and type definitions to accommodate the new setting across the data provider and API layers.

* test: Google LLM Configuration for Gemini 3 Models

- Added tests to validate default thinking configuration for Gemini 3 models, ensuring `thinkingConfig` is set correctly without `thinkingLevel`.
- Implemented logic to ignore `thinkingBudget` for Gemini 3+ models, confirming that it does not affect the configuration.
- Included a test to verify that `gemini-2.9-flash` is not classified as a Gemini 3+ model, maintaining expected behavior for earlier versions.
- Updated existing tests to ensure comprehensive coverage of the new configurations and behaviors.

* fix: Update translation for Google LLM thinking settings

- Revised descriptions for 'thinking budget' and 'thinking level' in the English translation file to clarify their applicability to different Gemini model versions.
- Ensured that the new descriptions accurately reflect the functionality and usage of the settings for Gemini 2.5 and 3 models.

* docs: Update comments for Gemini 3+ thinking configuration

- Added detailed comments in the Google LLM configuration to clarify the differences between `thinkingLevel` and `thinkingBudget` for Gemini 3+ models.
- Explained the necessity of `includeThoughts` in Vertex AI requests and how it interacts with `thinkingConfig` for improved understanding of the configuration logic.

* fix: Update comment for Gemini 3 model versioning

- Corrected comment in the configuration file to reflect the proper versioning for Gemini models, changing "Gemini 3.0 Models" to "Gemini 3 Models" for clarity and consistency.

* fix: Update thinkingLevel schema for Gemini 3 Models

- Removed nullable option from the thinkingLevel field in the tConversationSchema to ensure it is always defined when present, aligning with the intended configuration for Gemini 3 models.
2026-02-28 16:56:10 -05:00
Danny Avila
826b494578
🔀 feat: update OpenRouter with new Reasoning config (#11993)
* fix: Update OpenRouter reasoning handling in LLM configuration

- Modified the OpenRouter configuration to use a unified `reasoning` object instead of separate `reasoning_effort` and `include_reasoning` properties.
- Updated tests to ensure that `reasoning_summary` is excluded from the reasoning object and that the configuration behaves correctly based on the presence of reasoning parameters.
- Enhanced test coverage for OpenRouter-specific configurations, ensuring proper handling of various reasoning effort levels.

* refactor: Improve OpenRouter reasoning handling in LLM configuration

- Updated the handling of the `reasoning` object in the OpenRouter configuration to clarify the relationship between `reasoning_effort` and `include_reasoning`.
- Enhanced comments to explain the behavior of the `reasoning` object and its compatibility with legacy parameters.
- Ensured that the configuration correctly falls back to legacy behavior when no explicit reasoning effort is provided.

* test: Enhance OpenRouter LLM configuration tests

- Added a new test to verify the combination of web search plugins and reasoning object for OpenRouter configurations.
- Updated existing tests to ensure proper handling of reasoning effort levels and fallback behavior when reasoning_effort is unset.
- Improved test coverage for OpenRouter-specific configurations, ensuring accurate validation of reasoning parameters.

* chore: Update @librechat/agents dependency to version 3.1.53

- Bumped the version of @librechat/agents in package-lock.json and related package.json files to ensure compatibility with the latest features and fixes.
- Updated integrity hashes to reflect the new version.
2026-02-28 16:54:07 -05:00
Danny Avila
e6b324b259
🧠 feat: Add reasoning_effort configuration for Bedrock models (#11991)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* 🧠 feat: Add reasoning_effort configuration for Bedrock models

- Introduced a new `reasoning_effort` setting in the Bedrock configuration, allowing users to specify the reasoning level for supported models.
- Updated the input parser to map `reasoning_effort` to `reasoning_config` for Moonshot and ZAI models, ensuring proper handling of reasoning levels.
- Enhanced tests to validate the mapping of `reasoning_effort` to `reasoning_config` and to ensure correct behavior for various model types, including Anthropic models.
- Updated translation files to include descriptions for the new configuration option.

* chore: Update translation keys for Bedrock reasoning configuration

- Renamed translation key from `com_endpoint_bedrock_reasoning_config` to `com_endpoint_bedrock_reasoning_effort` for consistency with the new configuration setting.
- Updated the parameter settings to reflect the change in the description key, ensuring accurate mapping in the application.

* 🧪 test: Enhance bedrockInputParser tests for reasoning_config handling

- Added tests to ensure that stale `reasoning_config` is stripped when switching models from Moonshot to Meta and ZAI to DeepSeek.
- Included additional tests to verify that `reasoning_effort` values of "none", "minimal", and "xhigh" do not forward to `reasoning_config` for Moonshot and ZAI models.
- Improved coverage for the bedrockInputParser functionality to ensure correct behavior across various model configurations.

* feat: Introduce Bedrock reasoning configuration and update input parser

- Added a new `BedrockReasoningConfig` enum to define reasoning levels: low, medium, and high.
- Updated the `bedrockInputParser` to utilize the new reasoning configuration, ensuring proper handling of `reasoning_effort` values.
- Enhanced logic to validate `reasoning_effort` against the defined configuration values before assigning to `reasoning_config`.
- Improved code clarity with additional comments and refactored conditions for better readability.
2026-02-28 15:02:09 -05:00
Danny Avila
cde5079886
🎯 fix: Use Agents Endpoint Config for Agent Panel File Upload Validation (#11992)
* fix: Use correct endpoint for file validation in agent panel uploads

  Agent panel file uploads (FileSearch, FileContext, Code/Files) were validating against the active conversation's endpoint config instead of the agents endpoint config. This caused incorrect file size limits when the active chat used a different endpoint.

  Add endpointOverride option to useFileHandling so callers can specify the correct endpoint for validation independent of the active conversation.

* fix: Use agents endpoint config for agent panel file upload validation

Agent panel file uploads (FileSearch, FileContext, Code/Files) validated
against the active conversation's endpoint config instead of the agents
endpoint config. This caused wrong file size limits when the active chat
used a different endpoint.

Adds endpointOverride to useFileHandling so callers can specify the
correct endpoint for both validation and upload routing, independent of
the active conversation.

* test: Add unit tests for useFileHandling hook to validate endpoint overrides

Introduced comprehensive tests for the useFileHandling hook, ensuring correct behavior when using endpoint overrides for file validation and upload routing. The tests cover various scenarios, including fallback to conversation endpoints and proper handling of agent-specific configurations, enhancing the reliability of file handling in the application.
2026-02-28 15:01:51 -05:00
Danny Avila
43ff3f8473
💸 fix: Model Identifier Edge Case in Agent Transactions (#11988)
* 🔧 fix: Add skippedAgentIds tracking in initializeClient error handling

- Enhanced error handling in the initializeClient function to track agent IDs that are skipped during processing. This addition improves the ability to monitor and debug issues related to agent initialization failures.

* 🔧 fix: Update model assignment in BaseClient to use instance model

- Modified the model assignment in BaseClient to use `this.model` instead of `responseMessage.model`, clarifying that when using agents, the model refers to the agent ID rather than the model itself. This change improves code clarity and correctness in the context of agent usage.

* 🔧 test: Add tests for recordTokenUsage model assignment in BaseClient

- Introduced new test cases in BaseClient to ensure that the correct model is passed to the recordTokenUsage method, verifying that it uses this.model instead of the agent ID from responseMessage.model. This enhances the accuracy of token usage tracking in agent scenarios.
- Improved error handling in the initializeClient function to log errors when processing agents, ensuring that skipped agent IDs are tracked for better debugging.
2026-02-28 09:06:32 -05:00
Danny Avila
8b159079f5
🪙 feat: Add messageId to Transactions (#11987)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* feat: Add messageId to transactions

* chore: field order

* feat: Enhance token usage tracking by adding messageId parameter

- Updated `recordTokenUsage` method in BaseClient to accept a new `messageId` parameter for improved tracking.
- Propagated `messageId` in the AgentClient when recording usage.
- Added tests to ensure `messageId` is correctly passed and handled in various scenarios, including propagation across multiple usage entries.

* chore: Correct field order in createGeminiImageTool function

- Moved the conversationId field to the correct position in the object being passed to the recordTokenUsage method, ensuring proper parameter alignment for improved functionality.

* refactor: Update OpenAIChatCompletionController and createResponse to use responseId instead of requestId

- Replaced instances of requestId with responseId in the OpenAIChatCompletionController for improved clarity in logging and tracking.
- Updated createResponse to include responseId in the requestBody, ensuring consistency across the handling of message identifiers.

* test: Add messageId to agent client tests

- Included messageId in the agent client tests to ensure proper handling and propagation of message identifiers during transaction recording.
- This update enhances the test coverage for scenarios involving messageId, aligning with recent changes in the tracking of message identifiers.

* fix: Update OpenAIChatCompletionController to use requestId for context

- Changed the context object in OpenAIChatCompletionController to use `requestId` instead of `responseId` for improved clarity and consistency in handling request identifiers.

* chore: field order
2026-02-27 23:50:13 -05:00
Danny Avila
6169d4f70b
🚦 fix: 404 JSON Responses for Unmatched API Routes (#11976)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* feat: Implement 404 JSON response for unmatched API routes

- Added middleware to return a 404 JSON response with a message for undefined API routes.
- Updated SPA fallback to serve index.html for non-API unmatched routes.
- Ensured the error handler is positioned correctly as the last middleware in the stack.

* fix: Enhance logging in BaseClient for better token usage tracking

- Updated `getTokenCountForResponse` to log the messageId of the response for improved debugging.
- Enhanced userMessage logging to include messageId, tokenCount, and conversationId for clearer context during token count mapping.

* chore: Improve logging in processAddedConvo for better debugging

- Updated the logging structure in the processAddedConvo function to provide clearer context when processing added conversations.
- Removed redundant logging and enhanced the output to include model, agent ID, and endpoint details for improved traceability.

* chore: Enhance logging in BaseClient for improved token usage tracking

- Added debug logging in the BaseClient to track response token usage, including messageId, model, promptTokens, and completionTokens for better debugging and traceability.

* chore: Enhance logging in MemoryAgent for improved context

- Updated logging in the MemoryAgent to include userId, conversationId, messageId, and provider details for better traceability during memory processing.
- Adjusted log messages to provide clearer context when content is returned or not, aiding in debugging efforts.

* chore: Refactor logging in initializeClient for improved clarity

- Consolidated multiple debug log statements into a single message that provides a comprehensive overview of the tool context being stored for the primary agent, including the number of tools and the size of the tool registry. This enhances traceability and debugging efficiency.

* feat: Implement centralized 404 handling for unmatched API routes

- Introduced a new middleware function `apiNotFound` to standardize 404 JSON responses for undefined API routes.
- Updated the server configuration to utilize the new middleware, enhancing code clarity and maintainability.
- Added tests to ensure correct 404 responses for various non-GET methods and the `/api` root path.

* fix: Enhance logging in apiNotFound middleware for improved safety

- Updated the `apiNotFound` function to sanitize the request path by replacing problematic characters and limiting its length, ensuring safer logging of 404 errors.

* refactor: Move apiNotFound middleware to a separate file for better organization

- Extracted the `apiNotFound` function from the error middleware into its own file, enhancing code organization and maintainability.
- Updated the index file to export the new `notFound` middleware, ensuring it is included in the middleware stack.

* docs: Add comment to clarify usage of unsafeChars regex in notFound middleware

- Included a comment in the notFound middleware file to explain that the unsafeChars regex is safe to reuse with .replace() at the module scope, as it does not retain lastIndex state.
2026-02-27 22:49:54 -05:00
Marco Beretta
a17a38b8ed
🚅 docs: update Railway template link (#11966)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Publish `@librechat/client` to NPM / build-and-publish (push) Has been cancelled
Publish `librechat-data-provider` to NPM / build (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / build-and-publish (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* update railway template link

* Fix link for Deploy on Railway button in README
2026-02-26 17:24:02 -05:00
Danny Avila
b01f3ccada
🧩 fix: Redirect Stability and Build Chunking (#11965)
* 🔧 chore: Update Vite configuration to include additional package checks

- Enhanced the Vite configuration to recognize 'dnd-core' and 'flip-toolkit' alongside existing checks for 'react-dnd' and 'react-flip-toolkit' for improved handling of React interactions.
- Updated the markdown highlighting logic to also include 'lowlight' in addition to 'highlight.js' for better syntax highlighting support.

* 🔧 fix: Update AuthContextProvider to prevent infinite re-fire of useEffect

- Modified the dependency array of the useEffect hook in AuthContextProvider to an empty array, preventing unnecessary re-executions and potential infinite loops. Added an ESLint comment to clarify the decision regarding stable dependencies at mount.

* chore: import order
2026-02-26 16:43:24 -05:00
Danny Avila
09d5b1a739
📦 chore: bump minimatch due to ReDoS vulnerability, bump rimraf, rollup (#11963)
* 🔧 chore: bump minimatch due to ReDoS vulnerability

- Removed deprecated dependencies: @isaacs/balanced-match and @isaacs/brace-expansion.
- Upgraded Rollup packages from version 4.37.0 to 4.59.0 for improved performance and stability across multiple platforms.

* 🔧 chore: update Rollup version across multiple packages

- Bumped Rollup dependency from various versions to 4.34.9 in package.json and package-lock.json files for improved performance and compatibility across the project.

* 🔧 chore: update rimraf dependency to version 6.1.3 across multiple packages

- Bumped rimraf version from 6.1.2 to 6.1.3 in package.json and package-lock.json files for improved performance and compatibility.
2026-02-26 16:10:33 -05:00
Danny Avila
0568f1c1eb
🪃 fix: Prevent Recursive Login Redirect Loop (#11964)
* fix: Prevent recursive login redirect loop

    buildLoginRedirectUrl() would blindly encode the current URL into a
    redirect_to param even when already on /login, causing infinite nesting
    (/login?redirect_to=%2Flogin%3Fredirect_to%3D...). Guard at source so
    it returns plain /login when pathname starts with /login.

    Also validates redirect_to in the login error handler with isSafeRedirect
    to close an open-redirect vector, and removes a redundant /login guard
    from useAuthRedirect now handled by the centralized check.

* 🔀 fix: Handle basename-prefixed login paths and remove double URL decoding

    buildLoginRedirectUrl now uses isLoginPath() which matches /login,
    /librechat/login, and /login/2fa — covering subdirectory deployments
    where window.location.pathname includes the basename prefix.

    Remove redundant decodeURIComponent calls on URLSearchParams.get()
    results (which already returns decoded values) in getPostLoginRedirect,
    Login.tsx, and AuthContext login error handler. The extra decode could
    throw URIError on inputs containing literal percent signs.

* 🔀 fix: Tighten login path matching and add onError redirect tests

    Replace overbroad `endsWith('/login')` with a single regex
    `/(^|\/)login(\/|$)/` that matches `/login` only as a full path
    segment. Unifies `isSafeRedirect` and `buildLoginRedirectUrl` to use
    the same `LOGIN_PATH_RE` constant — no more divergent definitions.

    Add tests for the AuthContext onError redirect_to preservation logic
    (valid path preserved, open-redirect blocked, /login loop blocked),
    and a false-positive guard proving `/c/loginhistory` is not matched.

    Update JSDoc on `buildLoginRedirectUrl` to document the plain `/login`
    early-return, and add explanatory comment in AuthContext `onError`
    for why `buildLoginRedirectUrl()` cannot be used there.

* test: Add unit tests for AuthContextProvider login error handling

    Introduced a new test suite for AuthContextProvider to validate the handling of login errors and the preservation of redirect parameters. The tests cover various scenarios including valid redirect preservation, open-redirect prevention, and recursive redirect prevention. This enhances the robustness of the authentication flow and ensures proper navigation behavior during login failures.
2026-02-26 16:10:14 -05:00
Danny Avila
046e92217f
🧩 feat: OpenDocument Format File Upload and Native ODS Parsing (#11959)
*  feat: Add support for OpenDocument MIME types in file configuration

Updated the applicationMimeTypes regex to include support for OASIS OpenDocument formats, enhancing the file type recognition capabilities of the data provider.

* feat: document processing with OpenDocument support

Added support for OpenDocument Spreadsheet (ODS) MIME type in the file processing service and updated the document parser to handle ODS files. Included tests to verify correct parsing of ODS documents and updated file configuration to recognize OpenDocument formats.

* refactor: Enhance document processing to support additional Excel MIME types

Updated the document processing logic to utilize a regex for matching Excel MIME types, improving flexibility in handling various Excel file formats. Added tests to ensure correct parsing of new MIME types, including multiple Excel variants and OpenDocument formats. Adjusted file configuration to include these MIME types for better recognition in the file processing service.

* feat: Add support for additional OpenDocument MIME types in file processing

Enhanced the document processing service to support ODT, ODP, and ODG MIME types. Updated tests to verify correct routing through the OCR strategy for these new formats. Adjusted documentation to reflect changes in handled MIME types for improved clarity.
2026-02-26 14:39:49 -05:00
marbence101
3a079b980a
📌 fix: Populate userMessage.files Before First DB Save (#11939)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* fix: populate userMessage.files before first DB save

* fix: ESLint error fixed

* fix: deduplicate file-population logic and add test coverage

Extract `buildMessageFiles` helper into `packages/api/src/utils/message`
to replace three near-identical loops in BaseClient and both agent
controllers. Fixes set poisoning from undefined file_id entries, moves
file population inside the skipSaveUserMessage guard to avoid wasted
work, and adds full unit test coverage for the new behavior.

* chore: reorder import statements in openIdJwtStrategy.js for consistency

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-02-26 09:16:45 -05:00
Juri Kuehn
13df8ed67c
🪪 feat: Add OPENID_EMAIL_CLAIM for Configurable OpenID User Identifier (#11699)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* Allow setting the claim field to be used when OpenID login is configured

* fix(openid): harden getOpenIdEmail and expand test coverage

Guard against non-string claim values in getOpenIdEmail to prevent a
TypeError crash in isEmailDomainAllowed when domain restrictions are
configured. Improve warning messages to name the fallback chain
explicitly and distinguish missing vs. non-string claim values.

Fix the domain-block error log to record the resolved identifier rather
than userinfo.email, which was misleading when OPENID_EMAIL_CLAIM
resolved to a different field (e.g. upn).

Fix a latent test defect in openIdJwtStrategy.spec.js where the
~/server/services/Config mock exported getCustomConfig instead of
getAppConfig, the symbol actually consumed by openidStrategy.js.

Add refreshController tests covering the OPENID_EMAIL_CLAIM paths,
which were previously untested despite being a stated fix target.
Expand JWT strategy tests with null-payload, empty/whitespace
OPENID_EMAIL_CLAIM, migration-via-preferred_username, and call-order
assertions for the findUser lookup sequence.

* test(auth): enhance AuthController and openIdJwtStrategy tests for openidId updates

Added a new test in AuthController to verify that the openidId is updated correctly when a migration is triggered during the refresh process. Expanded the openIdJwtStrategy tests to include assertions for the updateUser function, ensuring that the correct parameters are passed when a user is found with a legacy email. This improves test coverage for OpenID-related functionality.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-02-25 22:31:03 -05:00
Vamsi Konakanchi
e978a934fc
📍 feat: Preserve Deep Link Destinations Through the Auth Redirect Flow (#10275)
* added support for url query param persistance

* refactor: authentication redirect handling

- Introduced utility functions for managing login redirects, including `persistRedirectToSession`, `buildLoginRedirectUrl`, and `getPostLoginRedirect`.
- Updated `Login` and `AuthContextProvider` components to utilize these utilities for improved redirect logic.
- Refactored `useAuthRedirect` to streamline navigation to the login page while preserving intended destinations.
- Cleaned up the `StartupLayout` to remove unnecessary redirect handling, ensuring a more straightforward navigation flow.
- Added a new `redirect.ts` file to encapsulate redirect-related logic, enhancing code organization and maintainability.

* fix: enhance safe redirect validation logic

- Updated the `isSafeRedirect` function to improve validation of redirect URLs.
- Ensured that only safe relative paths are accepted, specifically excluding paths that lead to the login page.
- Refactored the logic to streamline the checks for valid redirect targets.

* test: add unit tests for redirect utility functions

- Introduced comprehensive tests for `isSafeRedirect`, `buildLoginRedirectUrl`, `getPostLoginRedirect`, and `persistRedirectToSession` functions.
- Validated various scenarios including safe and unsafe redirects, URL encoding, and session storage behavior.
- Enhanced test coverage to ensure robust handling of redirect logic and prevent potential security issues.

* chore: streamline authentication and redirect handling

- Removed unused `useLocation` import from `AuthContextProvider` and replaced its usage with `window.location` for better clarity.
- Updated `StartupLayout` to check for pending redirects before navigating to the new chat page, ensuring users are directed appropriately based on their session state.
- Enhanced unit tests for `useAuthRedirect` to verify correct handling of redirect parameters, including encoding of the current path and query parameters.

* test: add unit tests for StartupLayout redirect behavior

- Introduced a new test suite for the StartupLayout component to validate redirect logic based on authentication status and session storage.
- Implemented tests to ensure correct navigation to the new conversation page when authenticated without pending redirects, and to prevent navigation when a redirect URL parameter or session storage redirect is present.
- Enhanced coverage for scenarios where users are not authenticated, ensuring robust handling of redirect conditions.

---------

Co-authored-by: Vamsi Konakanchi <vamsi.k@trackmind.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-02-25 22:21:19 -05:00
Danny Avila
a0f9782e60
🪣 fix: Prevent Memory Retention from AsyncLocalStorage Context Propagation (#11942)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* fix: store hide_sequential_outputs before processStream clears config

processStream now clears config.configurable after completion to break
memory retention chains. Save hide_sequential_outputs to a local
variable before calling runAgents so the post-stream filter still works.

* feat: memory diagnostics

* chore: expose garbage collection in backend inspect command

Updated the backend inspect command in package.json to include the --expose-gc flag, enabling garbage collection diagnostics for improved memory management during development.

* chore: update @librechat/agents dependency to version 3.1.52

Bumped the version of @librechat/agents in package.json and package-lock.json to ensure compatibility and access to the latest features and fixes.

* fix: clear heavy config state after processStream to prevent memory leaks

Break the reference chain from LangGraph's internal __pregel_scratchpad
through @langchain/core RunTree.extra[lc:child_config] into the
AsyncLocalStorage context captured by timers and I/O handles.

After stream completion, null out symbol-keyed scratchpad properties
(currentTaskInput), config.configurable, and callbacks. Also call
Graph.clearHeavyState() to release config, signal, content maps,
handler registry, and tool sessions.

* chore: fix imports for memory utils

* chore: add circular dependency check in API build step

Enhanced the backend review workflow to include a check for circular dependencies during the API build process. If a circular dependency is detected, an error message is displayed, and the process exits with a failure status.

* chore: update API build step to include circular dependency detection

Modified the backend review workflow to rename the API package installation step to reflect its new functionality, which now includes detection of circular dependencies during the build process.

* chore: add memory diagnostics option to .env.example

Included a commented-out configuration option for enabling memory diagnostics in the .env.example file, which logs heap and RSS snapshots every 60 seconds when activated.

* chore: remove redundant agentContexts cleanup in disposeClient function

Streamlined the disposeClient function by eliminating duplicate cleanup logic for agentContexts, ensuring efficient memory management during client disposal.

* refactor: move runOutsideTracing utility to utils and update its usage

Refactored the runOutsideTracing function by relocating it to the utils module for better organization. Updated the tool execution handler to utilize the new import, ensuring consistent tracing behavior during tool execution.

* refactor: enhance connection management and diagnostics

Added a method to ConnectionsRepository for retrieving the active connection count. Updated UserConnectionManager to utilize this new method for app connection count reporting. Refined the OAuthReconnectionTracker's getStats method to improve clarity in diagnostics. Introduced a new tracing utility in the utils module to streamline tracing context management. Additionally, added a safeguard in memory diagnostics to prevent unnecessary snapshot collection for very short intervals.

* refactor: enhance tracing utility and add memory diagnostics tests

Refactored the runOutsideTracing function to improve warning logic when the AsyncLocalStorage context is missing. Added tests for memory diagnostics and tracing utilities to ensure proper functionality and error handling. Introduced a new test suite for memory diagnostics, covering snapshot collection and garbage collection behavior.
2026-02-25 17:41:23 -05:00
Danny Avila
59bd27b4f4
🛡️ chore: Bump ESLint Tooling Deps and Resolve ajv Security Vulnerability (#11938)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* 🔧 chore: Update `@eslint/eslintrc` and related dependencies in `package-lock.json` and `package.json` to latest versions for improved stability and performance

* 🔧 chore: Update `postcss-preset-env` to version 11.2.0 in `package-lock.json` and `client/package.json`, and add `eslint` dependency in `package.json` for improved linting support
2026-02-24 21:30:28 -05:00
Danny Avila
4080e914e2
📦 chore: Bump @modelcontextprotocol/sdk from 1.26.0 to 1.27.1 (#11937) 2026-02-24 21:10:34 -05:00
Danny Avila
9a8a5d66d7
⏱️ fix: Separate MCP GET SSE Stream Timeout from POST and Suppress SDK-Internal Recovery Errors (#11936)
* fix: Separate MCP GET SSE body timeout from POST and suppress SDK-internal stream recovery

- Add a dedicated GET Agent with a configurable `sseReadTimeout` (default 5 min,
  matching the Python MCP SDK) so idle SSE streams time out independently of POST
  requests, preventing the reconnect-loop log flood described in Discussion #11230.
- Suppress "SSE stream disconnected" and "Failed to reconnect SSE stream" errors
  in setupTransportErrorHandlers — these are SDK-internal recovery events, not
  transport failures. "Maximum reconnection attempts exceeded" still escalates.
- Add optional `sseReadTimeout` to BaseOptionsSchema for per-server configuration.
- Add 6 tests: agent timeout separation, custom sseReadTimeout, SSE disconnect
  suppression (3 unit), and a real-server integration test proving the GET stream
  recovers without a full transport rebuild.

* fix: Refactor MCP connection timeouts and error handling

- Updated the `DEFAULT_SSE_READ_TIMEOUT` to use a constant for better readability.
- Introduced internal error message constants for SSE stream disconnection and reconnection failures to improve maintainability.
- Enhanced type safety in tests by ensuring the options symbol is defined before usage.
- Updated the `sseReadTimeout` in `BaseOptionsSchema` to enforce positive values, ensuring valid configurations.

* chore: Update SSE read timeout documentation format in BaseOptionsSchema

- Changed the default timeout value comment in BaseOptionsSchema to use an underscore for better readability, aligning with common formatting practices.
2026-02-24 21:05:58 -05:00
Danny Avila
44dbbd5328
a11y: Hide Collapsed Thinking Content From Screen Readers (#11927)
* fix(a11y): hide collapsed thinking content from screen readers and link toggle to controlled region

The thinking/reasoning toggle button visually collapsed content using a CSS
grid animation (gridTemplateRows: 0fr + overflow-hidden), but the content
remained in the DOM and fully accessible to screen readers, cluttering the
reading flow for assistive technology users.

- Add aria-hidden={!isExpanded} to the collapsible content region in both
  the legacy Thinking component and the modern Reasoning component, so
  screen readers skip collapsed thoughts entirely
- Add role="region" and a unique id (via useId) to each collapsible content
  div, giving it a semantic landmark for assistive technology
- Add contentId prop to the shared ThinkingButton and wire it to
  aria-controls on the toggle button, establishing an explicit relationship
  between the button and the region it expands/collapses
- aria-expanded was already present on the button; combined with
  aria-controls, screen readers can now fully convey the toggle state and
  its target

* fix(a11y): add aria-label to collapsible content regions in Thinking and Reasoning components

Enhanced accessibility by adding aria-label attributes to the collapsible content regions in both the Thinking and Reasoning components. This change ensures that screen readers can provide better context for users navigating through the content.

* fix(a11y): update roles and aria attributes in Thinking and Reasoning components

Changed role from "region" to "group" for collapsible content areas in both Thinking and Reasoning components to better align with ARIA practices. Updated aria-hidden to handle undefined values correctly and ensured contentId is passed to relevant components for improved accessibility and screen reader support.
2026-02-24 20:59:56 -05:00
Danny Avila
8c3c326440
🔌 fix: Reuse Undici Agents Per Transport and Close on Disconnect (#11935)
* fix: error handling for transient HTTP request failures in MCP connection

- Added specific handling for the "fetch failed" TypeError, indicating that the request was aborted likely due to a timeout, while the connection remains usable.
- Updated the error message to provide clearer context for users regarding the transient nature of the error.

* refactor: MCPConnection with Agent Lifecycle Management

- Introduced an array to manage undici Agents, ensuring they are reused across requests and properly closed during disconnection.
- Updated the custom fetch and SSE connection methods to utilize the new Agent management system.
- Implemented error handling for SSE 404 responses based on session presence, improving connection stability.
- Added integration tests to validate the Agent lifecycle, ensuring agents are reused and closed correctly.

* fix: enhance error handling and connection management in MCPConnection

- Updated SSE connection timeout handling to use nullish coalescing for better defaulting.
- Improved the connection closure process by ensuring agents are properly closed and errors are logged non-fatally.
- Added tests to validate handling of "fetch failed" errors, marking them as transient and providing clearer messaging for users.

* fix: update timeout handling in MCPConnection for improved defaulting

- Changed timeout handling in MCPConnection to use logical OR instead of nullish coalescing for better default value assignment.
- Ensured consistent timeout behavior for both standard and SSE connections, enhancing reliability in connection management.
2026-02-24 19:06:06 -05:00
Fahleen Arif
3d7e26382e
🖱️ feat: Native Browser Navigation Support for New Chat (#11904)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🔗 refactor: Replace navigate with Link for new chat navigation

* 🧬 fix: Ensure default action is prevented for non-left mouse clicks in NewChat component
2026-02-24 08:21:40 -05:00
Danny Avila
f3eb197675
💎 fix: Gemini Image Gen Tool Vertex AI Auth and File Storage (#11923)
* chore: saveToCloudStorage function and enhance error handling

- Removed unnecessary parameters and streamlined the logic for saving images to cloud storage.
- Introduced buffer handling for base64 image data and improved the integration with file strategy functions.
- Enhanced error handling during local image saving to ensure robustness.
- Updated the createGeminiImageTool function to reflect changes in the saveToCloudStorage implementation.

* refactor: streamline image persistence logic in GeminiImageGen

- Consolidated image saving functionality by renaming and refactoring the saveToCloudStorage function to persistGeneratedImage.
- Improved error handling and logging for image persistence operations.
- Enhanced the replaceUnwantedChars function to better sanitize input strings.
- Updated createGeminiImageTool to reflect changes in image handling and ensure consistent behavior across storage strategies.

* fix: clean up GeminiImageGen by removing unused functions and improving logging

- Removed the getSafeFormat and persistGeneratedImage functions to streamline image handling.
- Updated logging in createGeminiImageTool for clarity and consistency.
- Consolidated imports by eliminating unused dependencies, enhancing code maintainability.

* chore: update environment configuration and manifest for unused GEMINI_VERTEX_ENABLED

- Removed the Vertex AI configuration option from .env.example to simplify setup.
- Updated the manifest.json to reflect the removal of the Vertex AI dependency in the authentication field.
- Cleaned up the createGeminiImageTool function by eliminating unused fields related to Vertex AI, streamlining the code.

* fix: update loadAuthValues call in loadTools function for GeminiImageGen tool

- Modified the loadAuthValues function call to include throwError: false, preventing exceptions on authentication failures.
- Removed the unused processFileURL parameter from the tool context object, streamlining the code.

* refactor: streamline GoogleGenAI initialization in GeminiImageGen

- Removed unused file system access check for Google application credentials, simplifying the environment setup.
- Added googleAuthOptions to the GoogleGenAI instantiation, enhancing the configuration for authentication.

* fix: update Gemini API Key label and description in manifest.json

- Changed the label to indicate that the Gemini API Key is optional.
- Revised the description to clarify usage with Vertex AI and service accounts, enhancing user guidance.

* fix: enhance abort signal handling in createGeminiImageTool

- Introduced derivedSignal to manage abort events during image generation, improving responsiveness to cancellation requests.
- Added an abortHandler to log when image generation is aborted, enhancing debugging capabilities.
- Ensured proper cleanup of event listeners in the finally block to prevent memory leaks.

* fix: update authentication handling for plugins to support optional fields

- Added support for optional authentication fields in the manifest and PluginAuthForm.
- Updated the checkPluginAuth function to correctly validate plugins with optional fields.
- Enhanced tests to cover scenarios with optional authentication fields, ensuring accurate validation logic.
2026-02-24 08:21:02 -05:00
Dustin Healy
1d0a4c501f
🪨 feat: AWS Bedrock Document Uploads (#11912)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* feat: add aws bedrock upload to provider support

* chore: address copilot comments

* feat: add shared Bedrock document format types and MIME mapping

Bedrock Converse API accepts 9 document formats beyond PDF. Add
BedrockDocumentFormat union type, MIME-to-format mapping, and helpers
in data-provider so both client and backend can reference them.

* refactor: generalize Bedrock PDF validation to support all document types

Rename validateBedrockPdf to validateBedrockDocument with MIME-aware
logic: 4.5MB hard limit applies to all types, PDF header check only
runs for application/pdf. Adds test coverage for non-PDF documents.

* feat: support all Bedrock document formats in encoding pipeline

Widen file type gates to accept csv, doc, docx, xls, xlsx, html, txt,
md for Bedrock. Uses shared MIME-to-format map instead of hardcoded
'pdf'. Other providers' PDF-only paths remain unchanged.

* feat: expand Bedrock file upload UI to accept all document types

Add 'image_document_extended' upload type for Bedrock with accept
filters for all 9 supported formats. Update drag-and-drop validation
to use isBedrockDocumentType helper.

* fix: route Bedrock document types through provider pipeline
2026-02-23 22:32:44 -05:00
Danny Avila
b349f2f876
🪣 fix: Serve Fresh Presigned URLs on Agent List Cache Hits (#11902)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
* fix: serve cached presigned URLs on agent list cache hits

  On a cache hit the list endpoint was skipping the S3 refresh and
  returning whatever presigned URL was stored in MongoDB, which could be
  expired if the S3 URL TTL is shorter than the 30-minute cache window.

  refreshListAvatars now collects a urlCache map (agentId -> refreshed
  filepath) alongside its existing stats. The controller stores this map
  in the cache instead of a plain boolean and re-applies it to every
  paginated response, guaranteeing clients always receive a URL that was
  valid as of the last refresh rather than a potentially stale DB value.

* fix: improve avatar refresh cache handling and logging

Updated the avatar refresh logic to validate cached refresh data before proceeding with S3 URL updates. Enhanced logging to exclude sensitive `urlCache` details while still providing relevant statistics. Added error handling for cache invalidation during avatar updates to ensure robustness.

* fix: update avatar refresh logic to clear urlCache on no change

Modified the avatar refresh function to clear the urlCache when no new path is generated, ensuring that stale URLs are not retained. This change improves cache handling and aligns with the updated logic for avatar updates.

* fix: enhance avatar refresh logic to handle legacy cache entries

Updated the avatar refresh logic to accommodate legacy boolean cache entries, ensuring they are treated as cache misses and triggering a refresh. The cache now stores a structured `urlCache` map instead of a boolean, improving cache handling. Added tests to verify correct behavior for cache hits and misses, ensuring clients receive valid URLs based on the latest refresh.
2026-02-22 18:29:31 -05:00
Danny Avila
7ce898d6a0
📄 feat: Local Text Extraction for PDF, DOCX, and XLS/XLSX (#11900)
* feat: Added "document parser" OCR strategy

The document parser uses libraries to parse the text out of known document types.
This lets LibreChat handle some complex document types without having to use a
secondary service (like Mistral or standing up a RAG API server).

To enable the document parser, set the ocr strategy to "document_parser" in
librechat.yaml.

We now support:

- PDFs using pdfjs
- DOCX using mammoth
- XLS/XLSX using SheetJS

(The associated packages were also added to the project.)

* fix: applied Copilot code review suggestions

- Properly calculate length of text based on UTF8.

- Avoid issues with loading / blocking PDF parsing.

* fix: improved docs on parseDocument()

* chore: move to packages/api for TS support

* refactor: make document processing the default ocr strategy

- Introduced support for additional document types in the OCR strategy, including PDF, DOCX, and XLS/XLSX.
- Updated the file upload handling to dynamically select the appropriate parsing strategy based on the file type.
- Refactored the document parsing functions to use asynchronous imports for improved performance and maintainability.

* test: add unit tests for processAgentFileUpload functionality

- Introduced a new test suite for the processAgentFileUpload function in process.spec.js.
- Implemented various test cases to validate OCR strategy selection based on file types, including PDF, DOCX, XLSX, and XLS.
- Mocked dependencies to ensure isolated testing of file upload handling and strategy selection logic.
- Enhanced coverage for scenarios involving OCR capability checks and default strategy fallbacks.

* chore: update pdfjs-dist version and enhance document parsing tests

- Bumped pdfjs-dist dependency to version 5.4.624 in both api and packages/api.
- Refactored document parsing tests to use 'originalname' instead of 'filename' for file objects.
- Added a new test case for parsing XLS files to improve coverage of document types supported by the parser.
- Introduced a sample XLS file for testing purposes.

* feat: enforce text size limit and improve OCR fallback handling in processAgentFileUpload

- Added a check to ensure extracted text does not exceed the 15MB storage limit, throwing an error if it does.
- Refactored the OCR handling logic to improve fallback behavior when the configured OCR fails, ensuring a more robust document processing flow.
- Enhanced unit tests to cover scenarios for oversized text and fallback mechanisms, ensuring proper error handling and functionality.

* fix: correct OCR URL construction in performOCR function

- Updated the OCR URL construction to ensure it correctly appends '/ocr' to the base URL if not already present, improving the reliability of the OCR request.

---------

Co-authored-by: Dan Lew <daniel@mightyacorn.com>
2026-02-22 14:22:45 -05:00
Danny Avila
7692fa837e
🪣 fix: S3 path-style URL support for MinIO, R2, and custom endpoints (#11894)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* 🪣 fix: S3 path-style URL support for MinIO, R2, and custom endpoints

`extractKeyFromS3Url` now uses `AWS_BUCKET_NAME` to automatically detect and
strip the bucket prefix from path-style URLs, fixing `NoSuchKey` errors on URL
refresh for any S3-compatible provider using a custom endpoint (MinIO, Cloudflare
R2, Hetzner, Backblaze B2, etc.). No additional configuration required — the
bucket name is already a required env var for S3 to function.

`initializeS3` now passes `forcePathStyle: true` to the S3Client constructor
when `AWS_FORCE_PATH_STYLE=true` is set. Required for providers whose SSL
certificates do not support virtual-hosted-style bucket subdomains (e.g. Hetzner
Object Storage), which previously caused 401 / SignatureDoesNotMatch on upload.

Additional fixes:
- Suppress error log noise in `extractKeyFromS3Url` catch path: plain S3 keys
  no longer log as errors, only inputs that start with http(s):// do
- Fix test env var ordering so module-level constants pick up `AWS_BUCKET_NAME`
  and `S3_URL_EXPIRY_SECONDS` correctly before the module is required
- Add missing `deleteRagFile` mock and assertion in `deleteFileFromS3` tests
- Add `AWS_BUCKET_NAME` cleanup to `afterEach` to prevent cross-test pollution
- Add `initializeS3` unit tests covering endpoint, forcePathStyle, credentials,
  singleton, and IRSA code paths
- Document `AWS_FORCE_PATH_STYLE` in `.env.example`, `dotenv.mdx`, and `s3.mdx`

* 🪣 fix: Enhance S3 URL key extraction for custom endpoints

Updated `extractKeyFromS3Url` to support precise key extraction when using custom endpoints with path-style URLs. The logic now accounts for the `AWS_ENDPOINT_URL` and `AWS_FORCE_PATH_STYLE` environment variables, ensuring correct key handling for various S3-compatible providers.

Added unit tests to verify the new functionality, including scenarios for endpoints with base paths. This improves compatibility and reduces potential errors when interacting with S3-like services.
2026-02-21 18:36:48 -05:00
Danny Avila
b7bfdfa8b2
🪪 fix: Handle Delimited String Role Claims in OpenID Strategy (#11892)
* fix: handle space/comma-separated string roles claim in OpenID strategy

  When an OpenID provider returns the roles claim as a delimited string
  (e.g. "role1 role2 admin"), the previous code wrapped the entire string
  as a single array element, causing role checks to always fail even for users with the required role.

  Split string claims on whitespace and commas before comparison so that
  both array and delimited-string formats are handled correctly.

  Adds regression tests for space-separated, comma-separated, mixed, and
  non-matching delimited string cases.

* fix: enhance admin role handling in OpenID strategy

  Updated the OpenID strategy to correctly handle admin roles specified as space-separated or comma-separated strings. The logic now splits these strings into an array for accurate role checks.

  Added tests to verify that admin roles are granted or denied based on the presence of the specified admin role in the delimited string format.
2026-02-21 18:06:02 -05:00
Danny Avila
cca9d63224
🔒 refactor: graphTokenController to use federated access token for OBO assertion (#11893)
- Removed the extraction of access token from the Authorization header.
- Implemented logic to use the federated access token from the user object.
- Added error handling for missing federated access token.
- Updated related documentation in GraphTokenService to reflect changes in access token usage.
- Introduced unit tests for various scenarios in AuthController.spec.js to ensure proper functionality.
2026-02-21 18:03:39 -05:00
Danny Avila
4404319e22
📦 chore: Bump @librechat/agents to v3.1.51 (#11891) 2026-02-21 16:17:42 -05:00
github-actions[bot]
e92061671b
🌍 i18n: Update translation.json with latest translations (#11887)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-21 15:09:36 -05:00
Rene Heijdens
5d2b7fa4d5
🪣 fix: Proper Key Extraction from S3 URL (#11241)
*  feat: Enhance S3 URL handling and add comprehensive tests for CRUD operations

* 🔒 fix: Improve S3 URL key extraction with enhanced logging and additional test cases

* chore: removed some duplicate testcases and fixed incorrect apostrophes

* fix: Log error for malformed URLs

* test: Add additional test case for extracting keys from S3 URLs

* fix: Enhance S3 URL key extraction logic and improve error handling with additional test cases

* test: Add test case for stripping bucket from custom endpoint URLs with forcePathStyle enabled

* refactor: Update S3 path style handling and enhance environment configuration for S3-compatible services

* refactor: Remove S3_FORCE_PATH_STYLE dependency and streamline S3 URL key extraction logic

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-02-21 15:07:16 -05:00
Danny Avila
59717f5f50
✳️ docs: Point CLAUDE.md to AGENTS.md (#11886)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
2026-02-20 16:23:33 -05:00
Danny Avila
7a1d2969b8
🤖 feat: Gemini 3.1 Pricing and Context Window (#11884)
- Added support for the new Gemini 3.1 models, including 'gemini-3.1-pro-preview' and 'gemini-3.1-pro-preview-customtools'.
- Updated pricing logic to apply standard and premium rates based on token usage thresholds for the new models.
- Enhanced tests to validate pricing behavior for both standard and premium scenarios.
- Modified configuration files to include Gemini 3.1 models in the default model lists and token value mappings.
- Updated environment example file to reflect the new model options.
2026-02-20 16:21:32 -05:00
Danny Avila
a103ce72b4
🔍 chore: Update MeiliSearch version (#11873)
Some checks are pending
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
- Bumped MeiliSearch image version from v1.12.3 to v1.35.1 in both deploy-compose.yml and docker-compose.yml
- Updated volume paths to reflect the new version for data storage consistency.
2026-02-20 01:50:04 -05:00
Danny Avila
c3da148fa0
📝 docs: Add AGENTS.md for Project Structure and Coding Standards (#11866)
Some checks are pending
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 📝 docs: Add AGENTS.md for project structure and coding standards

- Introduced AGENTS.md to outline project workspaces, coding standards, and development commands.
- Defined workspace boundaries for backend and frontend development, emphasizing TypeScript usage.
- Established guidelines for code style, iteration performance, type safety, and import order.
- Updated CONTRIBUTING.md to reference AGENTS.md for coding standards and project conventions.
- Modified package.json to streamline build commands, consolidating frontend and backend build processes.

* chore: Update build commands and improve smart reinstall process

- Modified AGENTS.md to clarify the purpose of `npm run smart-reinstall` and other build commands, emphasizing Turborepo's role in dependency management and builds.
- Updated package.json to streamline build commands, replacing the legacy frontend build with a Turborepo-based approach for improved performance.
- Enhanced the smart reinstall script to fully delegate build processes to Turborepo, including cache management and dependency checks, ensuring a more efficient build workflow.
2026-02-19 16:33:43 -05:00
Danny Avila
9eeec6bc4f
v0.8.3-rc1 (#11856)
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Publish `@librechat/client` to NPM / build-and-publish (push) Has been cancelled
Publish `librechat-data-provider` to NPM / build (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / build-and-publish (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
* 🔧 chore: Update configuration version to 1.3.4 in librechat.example.yaml and data-provider config.ts

- Bumped the configuration version in both librechat.example.yaml and data-provider/src/config.ts to 1.3.4.
- Added new options for creating prompts and agents in the interface section of the YAML configuration.
- Updated capabilities list in the endpoints section to include 'deferred_tools'.

* 🔧 chore: Bump version to 0.8.3-rc1 across multiple packages and update related configurations

- Updated version to 0.8.3-rc1 in bun.lock, package.json, and various package.json files for frontend, backend, and data provider.
- Adjusted Dockerfile and Dockerfile.multi to reflect the new version.
- Incremented version for @librechat/api from 1.7.22 to 1.7.23 and for @librechat/client from 0.4.51 to 0.4.52.
- Updated appVersion in helm Chart.yaml to 0.8.3-rc1.
- Enhanced test configuration to align with the new version.

* 🔧 chore: Update version to 0.8.300 across multiple packages

- Bumped version to 0.8.300 in bun.lock, package-lock.json, and package.json for the data provider.
- Ensured consistency in versioning across the frontend, backend, and data provider packages.

* 🔧 chore: Bump package versions in bun.lock

- Updated version for @librechat/api from 1.7.22 to 1.7.23.
- Incremented version for @librechat/client from 0.4.51 to 0.4.52.
- Bumped version for @librechat/data-schemas from 0.0.35 to 0.0.36.
2026-02-18 20:36:03 -05:00
Danny Avila
50a48efa43
🧬 fix: Backfill Missing SHARE Permissions and Migrate Legacy SHARED_GLOBAL Fields (#11854)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* chore: Migrate legacy SHARED_GLOBAL permissions to SHARE and clean up orphaned fields

- Implemented migration logic to convert legacy SHARED_GLOBAL permissions to SHARE for PROMPTS and AGENTS, preserving user intent.
- Added cleanup process to remove orphaned SHARED_GLOBAL fields from the database after the schema change.
- Enhanced unit tests to verify migration and cleanup functionality, ensuring correct behavior for existing roles and permissions.

* fix: Enhance migration of SHARED_GLOBAL to SHARE permissions

- Updated the `updateAccessPermissions` function to ensure that SHARED_GLOBAL values are inherited into SHARE when SHARE is absent from both the database and the update payload.
- Implemented logic to prevent overwriting explicit SHARE values provided in the update, preserving user intent.
- Enhanced unit tests to cover various scenarios, including migration from SHARED_GLOBAL to SHARE and ensuring orphaned SHARED_GLOBAL fields are cleaned up appropriately.
2026-02-18 12:48:33 -05:00
Danny Avila
42718faad2
🧭 fix: Robust 404 Conversation Not Found Redirect (#11853)
* fix: route to new conversation when conversation not found

* Addressed PR feedback

* fix: Robust 404 conversation redirect handling

- Extract `isNotFoundError` utility to `utils/errors.ts` so axios stays
  contained in one place rather than leaking into route/query layers
- Add `initialConvoQuery.isError` to the useEffect dependency array so
  the redirect actually fires when the 404 response arrives after other
  deps have already settled (was the root cause of the blank screen)
- Show a warning toast so users understand why they were redirected
- Add `com_ui_conversation_not_found` i18n key

* fix: Enhance error handling in getResponseStatus function

- Update the getResponseStatus function to ensure it correctly returns the status from error objects only if the status is a number. This improves robustness in error handling by preventing potential type issues.

* fix: Improve conversation not found handling in ChatRoute

- Enhance error handling when a conversation is not found by checking additional conditions before showing a warning toast.
- Update the newConversation function to include model data and preset options, improving user experience during error scenarios.

* fix: Log error details for conversation not found in ChatRoute

- Added logging for the initial conversation query error when a conversation is not found, improving debugging capabilities and error tracking in the ChatRoute component.

---------

Co-authored-by: Dan Lew <daniel@mightyacorn.com>
2026-02-18 11:41:53 -05:00
Danny Avila
252a5cc7ca
🔗 fix: Preserve Stream State Across Reconnects to Prevent Reorder Buffer Desync (#11842)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
When all subscribers left a stream, both RedisEventTransport and
  InMemoryEventTransport deleted the entire stream state, destroying
  the allSubscribersLeftCallbacks and abortCallbacks registered by
  GenerationJobManager.createJob(). On the next subscribe/unsubscribe
  cycle, the callback that resets hasSubscriber was gone, causing
  syncReorderBuffer to be skipped on subsequent reconnects. This led
  to the reorder buffer expecting seq 0 while the publisher was at
  seq 300+, triggering a 500ms force-flush timeout and "skipping N
  missing messages" warnings.

  Fix: preserve stream state (callbacks, abort handlers) when the last
  subscriber leaves instead of deleting it. State is fully cleaned up
  by cleanup() when the job completes, aborts, or is collected by
  periodic orphan cleanup.
2026-02-18 01:57:34 -05:00
Danny Avila
5824298125
📦 chore: Bump fast-xml-parser to v5.3.6 (#11841) 2026-02-18 00:23:06 -05:00
Danny Avila
3fa94e843c
⚛️ refactor: Redis Scalability Improvements for High-Throughput Deployments (#11840)
* fix: Redis scalability improvements for high-throughput deployments

  Replace INCR+check+DECR race in concurrency middleware with atomic Lua
  scripts. The old approach allowed 3-4 concurrent requests through a
  limit of 2 at 300 req/s because another request could slip between the
  INCR returning and the DECR executing. The Lua scripts run atomically
  on the Redis server, eliminating the race window entirely.

  Add exponential backoff with jitter to all three Redis retry strategies
  (ioredis single-node, cluster, keyv). Previously all instances retried
  at the same millisecond after an outage, causing a connection storm.

  Batch the RedisJobStore cleanup loop into parallel chunks of 50. With
  1000 stale jobs, this reduces cleanup from ~20s of sequential calls to
  ~2s. Also pipeline appendChunk (xadd + expire) into a single round-trip
  and refresh TTL on every chunk instead of only the first, preventing
  TTL expiry during long-running streams.

  Propagate publish errors in RedisEventTransport.emitDone and emitError
  so callers can detect dropped completion/error events. emitChunk is left
  as swallow-and-log because its callers fire-and-forget without await.

  Add jest.config.js for the API package with babel TypeScript support and
  path alias resolution. Fix existing stream integration tests that were
  silently broken due to missing USE_REDIS_CLUSTER=false env var.

* chore: Migrate Jest configuration from jest.config.js to jest.config.mjs

Removed the old jest.config.js file and integrated the Jest configuration into jest.config.mjs, adding Babel TypeScript support and path alias resolution. This change streamlines the configuration for the API package.

* fix: Ensure Redis retry delays do not exceed maximum configured delay

Updated the delay calculation in Redis retry strategies to enforce a maximum delay defined in the configuration. This change prevents excessive delays during reconnection attempts, improving overall connection stability and performance.

* fix: Update RedisJobStore cleanup to handle job failures gracefully

Changed the cleanup process in RedisJobStore to use Promise.allSettled instead of Promise.all, allowing for individual job failures to be logged without interrupting the entire cleanup operation. This enhances error handling and provides better visibility into issues during job cleanup.
2026-02-18 00:04:33 -05:00
Danny Avila
5ea59ecb2b
🐛 fix: Normalize output_text blocks in Responses API input conversion (#11835)
* 🐛 fix: Normalize `output_text` blocks in Responses API input conversion

Treat `output_text` content blocks the same as `input_text` when
converting Responses API input to internal message format. Previously,
assistant messages containing `output_text` blocks fell through to the
default handler, producing `{ type: 'output_text' }` without a `text`
field, which caused downstream provider adapters (e.g. Bedrock) to fail
with "Unsupported content block type: output_text".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: Remove ChatModelStreamHandler from OpenAI and Responses controllers

Eliminated the ChatModelStreamHandler from both OpenAIChatCompletionController and createResponse functions to streamline event handling. This change simplifies the code by relying on existing handlers for message deltas and reasoning deltas, enhancing maintainability and reducing complexity in the agent's event processing logic.

* feat: Enhance input conversion in Responses API

Updated the `convertInputToMessages` function to handle additional content types, including `input_file` and `refusal` blocks, ensuring they are converted to appropriate message formats. Implemented null filtering for content arrays and default values for missing fields, improving robustness. Added comprehensive unit tests to validate these changes and ensure correct behavior across various input scenarios.

* fix: Forward upstream provider status codes in error responses

Updated error handling in OpenAIChatCompletionController and createResponse functions to forward upstream provider status codes (e.g., Anthropic 400s) instead of masking them as 500. This change improves error reporting by providing more accurate status codes and error types, enhancing the clarity of error responses for clients.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:34:19 -05:00
Danny Avila
3bf715e05e
♻️ refactor: On-demand MCP connections: remove proactive reconnect, default to available (#11839)
* feat: Implement reconnection staggering and backoff jitter for MCP connections

- Enhanced the reconnection logic in OAuthReconnectionManager to stagger reconnection attempts for multiple servers, reducing the risk of connection storms.
- Introduced a backoff delay with random jitter in MCPConnection to improve reconnection behavior during network issues.
- Updated the ConnectionsRepository to handle multiple server connections concurrently with a defined concurrency limit.

Added tests to ensure the new reconnection strategy works as intended.

* refactor: Update MCP server query configuration for improved data freshness

- Reduced stale time from 5 minutes to 30 seconds to ensure quicker updates on server initialization.
- Enabled refetching on window focus and mount to enhance data accuracy during user interactions.

* ♻️  refactor: On-demand MCP connections; remove proactive reconnection, default to available

  - Remove reconnectServers() from refresh controller (connection storm root cause)
  - Stop gating server selection on connection status; add to selection immediately
  - Render agent panel tools from DB cache, not live connection status
  - Proceed to cached tools on init failure (only gate on OAuth)
  - Remove unused batchToggleServers()
  - Reduce useMCPServersQuery staleTime from 5min to 30s, enable refetchOnMount/WindowFocus

* refactor: Optimize MCP tool initialization and server connection logic

- Adjusted tool initialization to only occur if no cached tools are available, improving efficiency.
- Updated comments for clarity on server connection and tool fetching processes.
- Removed unnecessary connection status checks during server selection to streamline the user experience.
2026-02-17 22:33:57 -05:00
Pavel Fediushin
dbf8cd40d3
🪹 fix: Prevent whitespace-only Chat input Submissions (#11838)
fix(input): normalize chat input text before submit

Trim input text before checking if empty to show submit button as disabled
2026-02-17 20:53:22 -05:00
Danny Avila
2ec64af551
📦 chore: Bump Dependabot Packages (#11836)
* 📦 chore: Update axios and form-data dependencies in react-query/package.json and lockfile

- Upgraded axios from version 1.12.1 to 1.13.5.
- Updated form-data from version 4.0.4 to 4.0.5.
- Adjusted follow-redirects dependency version in package-lock.json.

* 📦 chore: Update mermaid and chevrotain dependencies in package.json and package-lock.json

- Upgraded mermaid from version 11.12.2 to 11.12.3.
- Updated chevrotain and its related packages to version 11.1.1.
- Adjusted lodash-es version to 4.17.23 and langium dependency in @mermaid-js/parser to ^4.0.0.

* 📦 chore: Update langsmith dependency to version 0.4.12 in package.json and package-lock.json
2026-02-17 18:55:28 -05:00
github-actions[bot]
56624b0a57
🌍 i18n: Update translation.json with latest translations (#11831)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-17 15:51:27 -05:00
Danny Avila
0697e8cd60
🤖 feat: Claude Sonnet 4.6 support (#11829)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🤖 feat: Claude Sonnet 4.6 support

- Updated .env.example to include claude-sonnet-4-6 in the list of available models.
- Enhanced token value assignments in api/models/tx.js and packages/api/src/utils/tokens.ts to accommodate claude-sonnet-4-6.
- Added tests in packages/data-provider/specs/bedrock.spec.ts to verify support for claude-sonnet-4-6 in adaptive thinking and context-1m functionalities.
- Modified bedrock.ts to correctly parse and identify the version of claude-sonnet-4-6 for adaptive thinking checks.
- Included claude-sonnet-4-6 in sharedAnthropicModels and bedrockModels for consistent model availability.

* chore: additional Claude Sonnet 4.6 tests

- Added unit tests for Claude Sonnet 4.6 in `tokens.spec.js` to verify context length and max output tokens.
- Updated `helpers.ts` documentation to reflect adaptive thinking support for Sonnet 4.6.
- Enhanced `llm.spec.ts` with tests for context headers and adaptive thinking configurations for Claude Sonnet 4.6.
- Improved `bedrock.spec.ts` to ensure correct parsing and handling of Claude Sonnet 4.6 model variations with adaptive thinking.
2026-02-17 15:24:03 -05:00
Danny Avila
e710a12bfb
🪆 refactor: Internalize Producer Event Handling into Agent Graph Context (#11816)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🔧 refactor: Simplify Event Handling with Consumer Callbacks only

    Removed direct handling of tool calls from the ModelEndHandler and using ChatModelStreamHandler  outside of graph contexts, as are now managed within the graph execution context to maintain it as a producer of events, and the model end handler as a consumer. This change eliminates potential race conditions and streamlines the processing of model end events.

          /**
       * handleToolCalls is now called from within the graph execution context
       * (Graph.createCallModel, after attemptInvoke) rather than here in the
       * stream consumer. This eliminates the race condition where ToolNode
       * could read toolCallStepIds before this handler had populated it,
       * since the stream consumer and graph execution run concurrently.
       */

* 📦 chore: Update `@librechat/agents` to v3.1.50
2026-02-17 00:53:22 -05:00
github-actions[bot]
8dd814d9b7
🌍 i18n: Update translation.json with latest translations (#11813)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-17 00:20:21 -05:00
Danny Avila
be78f8bb86
📦 chore: Update @librechat/agents to v3.1.45 (#11815) 2026-02-16 21:03:21 -05:00
Danny Avila
b21672335f
📋 chore: Document Uncaught Exception Config and Fix Empty Text Export (#11812)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* chore: Prevent empty text parts in conversation export function

Added a check to return an empty array if the text part of the conversation is empty or consists only of whitespace, ensuring cleaner data handling in the export process.

* chore: Update .env.example to include CONTINUE_ON_UNCAUGHT_EXCEPTION variable

Added documentation for the CONTINUE_ON_UNCAUGHT_EXCEPTION environment variable, which allows the app to continue running after encountering uncaught exceptions. This change is not recommended for production environments unless necessary.
2026-02-16 16:47:07 -05:00
Danny Avila
35672e0bbb
📦 chore: @librechat/agents to v3.1.44 (#11811) 2026-02-16 16:36:32 -05:00
MyGitHub
413c2bc076
🪂 fix: Handle MongoDB Connection Errors to Prevent Process Crashes (#11809)
* fix: handle MongoDB connection errors to prevent process crashes

Add mongoose.connection.on('error') listener in connect.js to catch
connection-level errors emitted by MongoDB driver's SDAM monitoring.
Without this listener, these errors become uncaught exceptions per
Node.js EventEmitter behavior.

Also add MongoDB error patterns to the uncaughtException handler in
server/index.js as defense-in-depth, following the same pattern used
for GoogleGenerativeAI, Meilisearch, and OpenAI errors.

Fixes #11808

* style: fix prettier formatting in uncaughtException handler

* refactor: move error listener to module level

* fix: use precise MongoDB error matching in uncaughtException handler

* fix: replace process.exit(1) with graceful error logging

Instead of maintaining a growing list of error patterns that should
not crash the process, invert the default behavior: log all unhandled
errors and keep running. The existing specific handlers are preserved
for their contextual log messages.

This prevents process crashes from any transient error (MongoDB timeouts,
network issues, third-party library bugs) without needing to add new
patterns each time a new error type is encountered. Unnecessary restarts
are expensive as they trigger full Meilisearch re-syncs under load.

* fix: address review feedback

- connect.js: pass full error object to logger instead of just message
- server/index.js: add optional chaining for nullish err
- server/index.js: make crash-on-unknown-error opt-in via
  CRASH_ON_UNCAUGHT_EXCEPTION env var (defaults to graceful logging)

* fix: rename to CONTINUE_ON_UNCAUGHT_EXCEPTION, default to exit

---------

Co-authored-by: Feng Lu <feng.lu@kindredgroup.com>
2026-02-16 16:23:59 -05:00
Danny Avila
3c844c9cc6
🥠 refactor: Always set OIDC refresh token cookie to survive session expiry (#11810)
The express session cookie maxAge (SESSION_EXPIRY, default 15 min) is
shorter than the OIDC token lifetime (~1 hour). When OPENID_REUSE_TOKENS
is enabled, the refresh token was stored only in the express session
(req.session.openidTokens). After the session expired, the refresh token
was lost, causing "Refresh token not provided" on the next refresh
attempt and signing the user out. Re-login via OIDC would succeed
immediately (provider session still active), masking the root cause.

The session-only storage was introduced in #11236 to avoid HTTP/2 header
size limits from large access_token/id_token JWTs (especially Azure
Entra ID with many group claims). The refresh token is a small opaque
string and does not contribute to that problem.

Move the refreshToken cookie out of the no-session fallback branch so it
is always set alongside the session storage. The refreshController
already has the fallback logic (req.session?.openidTokens?.refreshToken
|| parsedCookies.refreshToken) but previously never had a cookie to fall
back to.

Timeline before fix:
  T=0      Login, session created (15 min maxAge), id_token valid ~1 hr
  T=15min  Session cookie expires, refresh token lost
  T=15min+ Page refresh or id_token expiry triggers refresh, fails with
           "Refresh token not provided", user redirected to /login

Timeline after fix:
  T=0      Login, session created + refreshToken cookie (7 day expiry)
  T=15min  Session cookie expires
  T=15min+ Refresh reads refreshToken from cookie fallback, succeeds,
           restores session with fresh tokens
2026-02-16 14:42:19 -05:00
Seung Hyun Myung
bddbd47f10
🪪 fix: Pass Scope in OpenID Refresh Token Grant for Azure Custom API (#11770)
* fix(auth): pass scope parameter in OpenID refresh token grant

   When using Azure Entra ID with a custom API scope (e.g., api://app-id/access_user)
   and OPENID_REUSE_TOKENS=true, the refresh token exchange fails with AADSTS90009
   because the scope parameter is not included in the refresh request.

   Azure AD v2.0 requires the scope parameter when refreshing tokens issued for
   custom API audiences. Without it, Azure interprets the request as the app
   requesting a token for itself and rejects it.

   This fix passes OPENID_SCOPE as the scope parameter to refreshTokenGrant(),
   maintaining backward compatibility (no scope sent if OPENID_SCOPE is not set).

   Fixes: refresh token 400 error with Azure custom API scopes
   Tested: Azure Entra ID + Token Reuse + SharePoint integration

* style(auth): fix ESLint multiline arguments formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 14:30:14 -05:00
Danny Avila
b06e741cb2
📦 chore: @librechat/agents to v3.1.43 (#11805)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
2026-02-15 21:35:32 -05:00
Danny Avila
2ea72a0f87
🎛️ fix: Google JSON Schema Normalization/Resolution Logic (#11804)
- Updated `resolveJsonSchemaRefs` to prevent `` and `definitions` from appearing in the resolved output, ensuring compatibility with LLM APIs.
- Improved `normalizeJsonSchema` to strip vendor extension fields (e.g., `x-*` prefixed keys) and leftover ``/`definitions` blocks, enhancing schema normalization for Google/Gemini API.
- Added comprehensive tests to validate the stripping of ``, vendor extensions, and proper normalization across various schema structures.
2026-02-15 21:31:16 -05:00
Danny Avila
12f45c76ee
🎮 feat: Bedrock Parameters for OpenAI GPT-OSS models (#11798)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
Add OpenAI as a Bedrock provider so that selecting openai.gpt-oss-*
  models in the Bedrock agent UI renders the general parameter settings
  (temperature, top_p, max_tokens) instead of a blank panel. Also add
  token context lengths (128K) for gpt-oss-20b and gpt-oss-120b.
2026-02-14 14:10:32 -05:00
MyGitHub
bf9aae0571
💎 feat: Add Redis as Optional Sub-chart Dependency in Helm Chart (#11664)
Add Bitnami Redis as an optional Helm sub-chart dependency, following the
same pattern used by MongoDB and Meilisearch. When enabled, USE_REDIS and
REDIS_URI are auto-wired into the LibreChat ConfigMap.

- Add redis dependency (Bitnami 24.1.3, Redis 8.4) to Chart.yaml
- Add redis config section to values.yaml (disabled by default)
- Auto-wire USE_REDIS and REDIS_URI in configmap-env.yaml with dig
  checks to allow user overrides via configEnv
- Bump chart version to 1.10.0

Co-authored-by: Feng Lu <feng.lu@kindredgroup.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-02-14 13:57:01 -05:00
ethanlaj
2513e0a423
🔧 feat: deleteRagFile utility for Consistent RAG API document deletion (#11493)
* 🔧 feat: Implement deleteRagFile utility for RAG API document deletion across storage strategies

* chore: import order

* chore: import order & remove unnecessary comments

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
2026-02-14 13:57:01 -05:00
Dustin Healy
a89945c24b
🌙 fix: Accessible Contrast for Theme Switcher Icons (#11795)
* fix: proper colors for contrast in theme switcher icons

* fix: use themed font colors
2026-02-14 13:57:00 -05:00
Danny Avila
b0a32b7d6d
👻 fix: Prevent Async Title Generation From Recreating Deleted Conversations (#11797)
* 🐛 fix: Prevent deleted conversations from being recreated by async title generation

  When a user deletes a chat while auto-generated title is still in progress,
  `saveConvo` with `upsert: true` recreates the deleted conversation as a ghost
  entry with only a title and no messages. This adds a `noUpsert` metadata option
  to `saveConvo` and uses it in both agent and assistant title generation paths,
  so the title save is skipped if the conversation no longer exists.

* test:  conversation creation logic with noUpsert option

  Added new tests to validate the behavior of the `saveConvo` function with the `noUpsert` option. This includes scenarios where a conversation should not be created if it doesn't exist, updating an existing conversation when `noUpsert` is true, and ensuring that upsert behavior remains the default when `noUpsert` is not provided. These changes improve the flexibility and reliability of conversation management.

* test: Clean up Conversation.spec.js by removing commented-out code

  Removed unnecessary comments from the Conversation.spec.js test file to improve readability and maintainability. This includes comments related to database verification and temporary conversation handling, streamlining the test cases for better clarity.
2026-02-14 13:57:00 -05:00
Danny Avila
10685fca9f
🗂️ refactor: Artifacts via Model Specs & Scope Badge Persistence by Spec Context (#11796)
* 🔧 refactor: Simplify MCP selection logic in useMCPSelect hook

- Removed redundant useEffect for setting ephemeral agent when MCP values change.
- Integrated ephemeral agent update directly into the MCP value change handler, improving code clarity and reducing unnecessary re-renders.
- Updated dependencies in the effect hook to ensure proper state management.

Why Effect 2 Was Added (PR #9528)

  PR #9528 was a refactor that migrated MCP state from useLocalStorage hooks to Jotai atomWithStorage. Before that PR, useLocalStorage
  handled bidirectional sync between localStorage and Recoil in one abstraction. After the migration, the two useEffect hooks were
  introduced to bridge Jotai ↔ Recoil:

  - Effect 1 (Recoil → Jotai): When ephemeralAgent.mcp changes externally, update the Jotai atom (which drives the UI dropdown)
  - Effect 2 (Jotai → Recoil): When mcpValues changes, push it back to ephemeralAgent.mcp (which is read at submission time)

  Effect 2 was needed because in that PR's design, setMCPValues only wrote to Jotai — it never touched Recoil. Effect 2 was the bridge to
   propagate user selections into the ephemeral agent.

  Why Removing It Is Correct

  All user-initiated MCP changes go through setMCPValues. The callers are in useMCPServerManager: toggleServerSelection,
  batchToggleServers, OAuth success callbacks, and access revocation. Our change puts the Recoil write directly in that callback, so all
  these paths are covered.

  All external changes go through Recoil, handled by Effect 1 (kept). Model spec application (applyModelSpecEphemeralAgent), agent
  template application after submission, and BadgeRowContext initialization all write directly to ephemeralAgentByConvoId. Effect 1
  watches ephemeralAgent?.mcp and syncs those into the Jotai atom for the UI.

  There is no code path where mcpValues changes without going through setMCPValues or Effect 1. The only other source is
  atomWithStorage's getOnInit reading from localStorage on mount — that's just restoring persisted state and is harmless (overwritten by
  Effect 1 if the ephemeral agent has values).

  Additional Benefits

  - Eliminates the race condition. Effect 2 fired on mount with Jotai's stale default ([]), overwriting ephemeralAgent.mcp that had been
  set by a model spec. Our change prevents that because the imperative sync only fires on explicit user action.
  - Eliminates infinite loop risk. The old bidirectional two-effect approach relied on isEqual/JSON.stringify checks to break cycles. The
   new unidirectional-reactive (Effect 1) + imperative (setMCPValues) approach has no such risk.
  - Effect 1's enhancements are preserved. The mcp_clear sentinel handling and configuredServers filtering (both added after PR #9528)
  continue to work correctly.

*  feat: Add artifacts support to model specifications and ephemeral agents

- Introduced `artifacts` property in the model specification and ephemeral agent types, allowing for string or boolean values.
- Updated `applyModelSpecEphemeralAgent` to handle artifacts, defaulting to 'default' if true or an empty string if not specified.
- Enhanced localStorage handling to store artifacts alongside other agent properties, improving state management for ephemeral agents.

* 🔧 refactor: Update BadgeRowContext to improve localStorage handling

- Modified the logic to only apply values from localStorage that were actually stored, preventing unnecessary overrides of the ephemeral agent.
- Simplified the setting of ephemeral agent values by directly using initialValues, enhancing code clarity and maintainability.

* 🔧 refactor: Enhance ephemeral agent handling in BadgeRowContext and model spec application

- Updated BadgeRowContext to apply localStorage values only for tools not already set in ephemeralAgent, improving state management.
- Modified useApplyModelSpecEffects to reset the ephemeral agent when no spec is provided but specs are configured, ensuring localStorage defaults are applied correctly.
- Streamlined the logic for applying model spec properties, enhancing clarity and maintainability.

* refactor: Isolate spec and non-spec tool/MCP state with environment-keyed storage

  Spec tool state (badges, MCP) and non-spec user preferences previously shared
  conversation-keyed localStorage, causing cross-pollination when switching between
  spec and non-spec models. This introduces environment-keyed storage so each
  context maintains independent persisted state.

  Key changes:
  - Spec active: no localStorage persistence — admin config always applied fresh
  - Non-spec (with specs configured): tool/MCP state persisted to __defaults__ key
  - No specs configured: zero behavior change (conversation-keyed storage)
  - Per-conversation isolation preserved for existing conversations
  - Dual-write on user interaction updates both conversation and environment keys
  - Remove mcp_clear sentinel in favor of null ephemeral agent reset

* refactor: Enhance ephemeral agent initialization and MCP handling in BadgeRowContext and useMCPSelect

- Updated BadgeRowContext to clarify the handling of localStorage values for ephemeral agents, ensuring proper initialization based on conversation state.
- Improved useMCPSelect tests to accurately reflect behavior when setting empty MCP values, ensuring the visual selection clears as expected.
- Introduced environment-keyed storage logic to maintain independent state for spec and non-spec contexts, enhancing user experience during context switching.

* test: Add comprehensive tests for useToolToggle and applyModelSpecEphemeralAgent hooks

- Introduced unit tests for the useToolToggle hook, covering dual-write behavior in non-spec mode and per-conversation isolation.
- Added tests for applyModelSpecEphemeralAgent, ensuring correct application of model specifications and user overrides from localStorage.
- Enhanced test coverage for ephemeral agent state management during conversation transitions, validating expected behaviors for both new and existing conversations.
2026-02-14 13:56:50 -05:00
Danny Avila
bf1f2f4313
🗨️ refactor: Better Whitespace handling in Chat Message rendering (#11791)
- Updated the rendering logic in the Part component to handle whitespace-only text more effectively.
- Introduced a placeholder for whitespace-only last parts during streaming to enhance user experience.
- Ensured non-last whitespace-only parts are skipped to avoid rendering empty containers, improving layout stability.
2026-02-14 09:41:10 -05:00
Danny Avila
65d1382678
📦 chore: @librechat/agents to v3.1.42 (#11790) 2026-02-14 09:19:26 -05:00
Danny Avila
f72378d389
🧩 chore: Extract Agent Client Utilities to /packages/api (#11789)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Extract 7 standalone utilities from api/server/controllers/agents/client.js
into packages/api/src/agents/client.ts for TypeScript support and to
declutter the 1400-line controller module:

- omitTitleOptions: Set of keys to exclude from title generation options
- payloadParser: Extracts model_parameters from request body for non-agent endpoints
- createTokenCounter: Factory for langchain-compatible token counting functions
- logToolError: Callback handler for agent tool execution errors
- findPrimaryAgentId: Resolves primary agent from suffixed parallel agent IDs
- createMultiAgentMapper: Message content processor that filters parallel agent
  output to primary agents and applies agent labels for handoff/multi-agent flows

Supporting changes:
- Add endpointOption and endpointType to RequestBody type (packages/api/src/types/http.ts)
  so payloadParser can access middleware-attached fields without type casts
- Add @typescript-eslint/no-unused-vars with underscore ignore patterns to the
  packages/api eslint config block, matching the convention used by client/ and
  data-provider/ blocks
- Update agent controller imports to consume the moved functions from @librechat/api
  and remove now-unused direct imports (logAxiosError, labelContentByAgent,
  getTokenCountForMessage)
2026-02-13 23:17:53 -05:00
Danny Avila
467df0f07a
🎭 feat: Override Custom Endpoint Schema with Specified Params Endpoint (#11788)
* 🔧 refactor: Simplify payload parsing and enhance getSaveOptions logic

- Removed unused bedrockInputSchema from payloadParser, streamlining the function.
- Updated payloadParser to handle optional chaining for model parameters.
- Enhanced getSaveOptions to ensure runOptions defaults to an empty object if parsing fails, improving robustness.
- Adjusted the assignment of maxContextTokens to use the instance variable for consistency.

* 🔧 fix: Update maxContextTokens assignment logic in initializeAgent function

- Enhanced the maxContextTokens assignment to allow for user-defined values, ensuring it defaults to a calculated value only when not provided or invalid. This change improves flexibility in agent initialization.

* 🧪 test: Add unit tests for initializeAgent function

- Introduced comprehensive unit tests for the initializeAgent function, focusing on maxContextTokens behavior.
- Tests cover scenarios for user-defined values, fallback calculations, and edge cases such as zero and negative values, enhancing overall test coverage and reliability of agent initialization logic.

* refactor: default params Endpoint Configuration Handling

- Integrated `getEndpointsConfig` to fetch endpoint configurations, allowing for dynamic handling of `defaultParamsEndpoint`.
- Updated `buildEndpointOption` to pass `defaultParamsEndpoint` to `parseCompactConvo`, ensuring correct parameter handling based on endpoint type.
- Added comprehensive unit tests for `buildDefaultConvo` and `cleanupPreset` to validate behavior with `defaultParamsEndpoint`, covering various scenarios and edge cases.
- Refactored related hooks and utility functions to support the new configuration structure, improving overall flexibility and maintainability.

* refactor: Centralize defaultParamsEndpoint retrieval

- Introduced `getDefaultParamsEndpoint` function to streamline the retrieval of `defaultParamsEndpoint` across various hooks and middleware.
- Updated multiple files to utilize the new function, enhancing code consistency and maintainability.
- Removed redundant logic for fetching `defaultParamsEndpoint`, simplifying the codebase.
2026-02-13 23:04:51 -05:00
Danny Avila
6cc6ee3207
📳 refactor: Optimize Model Selector (#11787)
- Introduced a new `EndpointMenuContent` component to lazily render endpoint submenu content, improving performance by deferring expensive model-list rendering until the submenu is mounted.
- Refactored `EndpointItem` to utilize the new component, simplifying the code and enhancing readability.
- Removed redundant filtering logic and model specifications handling from `EndpointItem`, centralizing it within `EndpointMenuContent` for better maintainability.
2026-02-13 22:46:14 -05:00
Danny Avila
dc489e7b25
🪟 fix: Tab Isolation for Agent Favorites + MCP Selections (#11786)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* 🔧 refactor: Implement tab-isolated storage for favorites and MCP selections

- Replaced `createStorageAtom` with `createTabIsolatedAtom` in favorites store to prevent cross-tab synchronization of favorites.
- Introduced `createTabIsolatedStorage` and `createTabIsolatedAtom` in `jotai-utils` to facilitate tab-specific state management.
- Updated MCP values atom family to utilize tab-isolated storage, ensuring independent MCP server selections across tabs.

* 🔧 fix: Update MCP selection logic to ensure active MCPs are only set when configured servers are available

- Modified the condition in `useMCPSelect` to check for both available MCPs and configured servers before setting MCP values. This change prevents potential issues when no servers are configured, enhancing the reliability of MCP selections.
2026-02-13 14:54:49 -05:00
Danny Avila
e50f59062f
🏎️ feat: Smart Reinstall with Turborepo Caching for Better DX (#11785)
* chore: Add Turborepo support and smart reinstall script

- Updated .gitignore to include Turborepo cache directory.
- Added Turbo as a dependency in package.json and package-lock.json.
- Introduced turbo.json configuration for build tasks.
- Created smart-reinstall.js script to optimize dependency installation and package builds using Turborepo caching.

* fix: Address PR review feedback for smart reinstall

  - Fix Windows compatibility in hasTurbo() by checking for .cmd/.ps1 shims
  - Remove Unix-specific shell syntax (> /dev/null 2>&1) from cache clearing
  - Split try/catch blocks so daemon stop failure doesn't block cache clear
  - Add actionable tips in error output pointing to --force and --verbose
2026-02-13 14:25:26 -05:00
Danny Avila
ccbf9dc093
🧰 fix: Convert const to enum in MCP Schemas for Gemini Compatibility (#11784)
* fix: Convert `const` to `enum` in MCP tool schemas for Gemini/Vertex AI compatibility

  Gemini/Vertex AI rejects the JSON Schema `const` keyword in function declarations
  with a 400 error. Previously, the Zod conversion layer accidentally stripped `const`,
  but after migrating to pass raw JSON schemas directly to providers, the unsupported
  keyword now reaches Gemini verbatim.

  Add `normalizeJsonSchema` to recursively convert `const: X` → `enum: [X]`, which is
  semantically equivalent per the JSON Schema spec and supported by all providers.

* fix: Update secure cookie handling in AuthService to use dynamic secure flag

Replaced the static `secure: isProduction` with a call to `shouldUseSecureCookie()` in the `setOpenIDAuthTokens` function. This change ensures that the secure cookie setting is evaluated at runtime, improving cookie handling in development environments while maintaining security in production.

* refactor: Simplify MCP tool key formatting and remove unused mocks in tests

- Updated MCP test suite to replace static tool key formatting with a dynamic delimiter from Constants, enhancing consistency and maintainability.
- Removed unused mock implementations for `@langchain/core/tools` and `@librechat/agents`, streamlining the test setup.
- Adjusted related test cases to reflect the new tool key format, ensuring all tests remain functional.

* chore: import order
2026-02-13 13:33:25 -05:00
Danny Avila
276ac8d011
🛰️ feat: Add Bedrock Parameter Settings for MoonshotAI and Z.AI Models (#11783)
- Introduced new model entries for 'moonshotai.kimi' and 'moonshotai.kimi-k2.5' in tokens.ts.
- Updated parameterSettings.ts to include configurations for MoonshotAI and ZAI providers.
- Enhanced schemas.ts by adding MoonshotAI and ZAI to the BedrockProviders enum for better integration.
2026-02-13 11:21:53 -05:00
Jón Levy
dc89e00039
🪙 refactor: Distinguish ID Tokens from Access Tokens in OIDC Federated Auth (#11711)
* fix(openid): distinguish ID tokens from access tokens in federated auth

Fix OpenID Connect token handling to properly distinguish ID tokens from access tokens. ID tokens and access tokens are now stored and propagated separately, preventing token placeholders from resolving to identical values.

- AuthService.js: Added idToken field to session storage
- openIdJwtStrategy.js: Updated to read idToken from session
- openidStrategy.js: Explicitly included id_token in federatedTokens
- Test suites: Added comprehensive test coverage for token distinction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(openid): add separate openid_id_token cookie for ID token storage

Store the OIDC ID token in its own cookie rather than relying solely on
the access token, ensuring correct token type is used for identity
verification vs API authorization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(openid): add JWT strategy cookie fallback tests

Cover the token source resolution logic in openIdJwtStrategy:
session-only, cookie-only, partial session fallback, raw Bearer
fallback, and distinct id_token/access_token from cookies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 11:07:39 -05:00
Callum Keogan
8e3b717e99
🦙 fix: Memory Agent Fails to Initialize with Ollama Provider (#11680)
Fixed an issue where memory agents would fail with 'Provider Ollama not supported'
error when using Ollama as a custom endpoint. The getCustomEndpointConfig function
was only normalizing the endpoint config name but not the endpoint parameter
during comparison.

Changes:
- Modified getCustomEndpointConfig to normalize both sides of the endpoint comparison
- Added comprehensive test coverage for getCustomEndpointConfig including:
  - Test for case-insensitive Ollama endpoint matching (main fix)
  - Tests for various edge cases and error handling

This ensures that endpoint name matching works correctly for Ollama regardless
of case sensitivity in the configuration.
2026-02-13 10:43:25 -05:00
Danny Avila
2e42378b16
🔒 fix: Secure Cookie Localhost Bypass and OpenID Token Selection in AuthService (#11782)
* 🔒 fix: Secure Cookie Localhost Bypass and OpenID Token Selection in AuthService

  Two independent bugs in `api/server/services/AuthService.js` cause complete
  authentication failure when using `OPENID_REUSE_TOKENS=true` with Microsoft
  Entra ID (or Auth0) on `http://localhost` with `NODE_ENV=production`:

  Bug 1: `secure: isProduction` prevents auth cookies on localhost

  PR #11518 introduced `shouldUseSecureCookie()` in `socialLogins.js` to handle
  the case where `NODE_ENV=production` but the server runs on `http://localhost`.
  However, `AuthService.js` was not updated — it still used `secure: isProduction`
  in 6 cookie locations across `setAuthTokens()` and `setOpenIDAuthTokens()`.

  The `token_provider` cookie being dropped is critical: without it,
  `requireJwtAuth` middleware defaults to the `jwt` strategy instead of
  `openidJwt`, causing all authenticated requests to return 401.

  Bug 2: `setOpenIDAuthTokens()` returns `access_token` instead of `id_token`

  The `openIdJwtStrategy` validates the Bearer token via JWKS. For Entra ID
  without `OPENID_AUDIENCE`, the `access_token` is a Microsoft Graph API token
  (opaque or signed for a different audience), which fails JWKS validation.

  The `id_token` is always a standard JWT signed by the IdP's JWKS keys with
  the app's `client_id` as audience — which is what the strategy expects.
  This is the same root cause as issue #8796 (Auth0 encrypted access tokens).

  Changes:

  - Consolidate `shouldUseSecureCookie()` into `packages/api/src/oauth/csrf.ts`
    as a shared, typed utility exported from `@librechat/api`, replacing the
    duplicate definitions in `AuthService.js` and `socialLogins.js`
  - Move `isProduction` check inside the function body so it is evaluated at
    call time rather than module load time
  - Fix `packages/api/src/oauth/csrf.ts` which also used bare
    `secure: isProduction` for CSRF and session cookies (same localhost bug)
  - Return `tokenset.id_token || tokenset.access_token` from
    `setOpenIDAuthTokens()` so JWKS validation works with standard OIDC
    providers; falls back to `access_token` for backward compatibility
  - Add 15 tests for `shouldUseSecureCookie()` covering production/dev modes,
    localhost variants, edge cases, and a documented IPv6 bracket limitation
  - Add 13 tests for `setOpenIDAuthTokens()` covering token selection,
    session storage, cookie secure flag delegation, and edge cases

  Refs: #8796, #11518, #11236, #9931

* chore: Adjust Import Order and Type Definitions in AgentPanel Component

- Reordered imports in `AgentPanel.tsx` for better organization and clarity.
- Updated type imports to ensure proper usage of `FieldNamesMarkedBoolean` and `TranslationKeys`.
- Removed redundant imports to streamline the codebase.
2026-02-13 10:35:51 -05:00
Ganesh Bhat
3888dfa489
feat: Expose enableServiceLinks in Helm Deployment Templates (#11741)
* 🐳 feat: Expose enableServiceLinks in Helm Deployment templates (#11740)

Allow users to disable Kubernetes service link injection via enableServiceLinks
in both LibreChat and RAG API Helm charts. This prevents pod startup failures
caused by "argument list too long" errors in namespaces with many services.

* Update helm/librechat/templates/deployment.yaml



* Update helm/librechat-rag-api/templates/rag-deployment.yaml


* set enableServiceLinks default to true

---------

Co-authored-by: Ganesh Bhat <ganesh.bhat@fullscript.com>
2026-02-13 10:27:51 -05:00
Danny Avila
e142ab72da
🔒 fix: Prevent Race Condition in RedisJobStore (#11764)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* 🔧 fix: Optimize job update logic in RedisJobStore

- Refactored the updateJob method to use a Lua script for atomic updates, ensuring that jobs are only updated if they exist in Redis.
- Removed redundant existence check and streamlined the serialization process for better performance and clarity.

* 🔧 test: Add race condition tests for RedisJobStore

- Introduced tests to verify behavior of updateJob after deleteJob, ensuring no job hash is recreated post-deletion.
- Added checks for orphan keys when concurrent deleteJob and updateJob operations occur, enhancing reliability in job management.

* 🔧 test: Refactor Redis client readiness checks in violationCache tests

- Introduced a new helper function `waitForRedisClients` to streamline the readiness checks for Redis clients in the violationCache integration tests.
- Removed redundant Redis client readiness checks from individual test cases, improving code clarity and maintainability.

* 🔧 fix: Update RedisJobStore to use hset instead of hmset

- Replaced instances of `hmset` with `hset` in the RedisJobStore implementation to align with the latest Redis command updates.
- Updated Lua script in the eval method to reflect the change, ensuring consistent job handling in both cluster and non-cluster modes.
2026-02-12 18:47:57 -05:00
Danny Avila
b8c31e7314
🔱 chore: Harden API Routes Against IDOR and DoS Attacks (#11760)
* 🔧 feat: Update user key handling in keys route and add comprehensive tests

- Enhanced the PUT /api/keys route to destructure request body for better clarity and maintainability.
- Introduced a new test suite for keys route, covering key update, deletion, and retrieval functionalities, ensuring robust validation and IDOR prevention.
- Added tests to verify handling of extraneous fields and missing optional parameters in requests.

* 🔧 fix: Enhance conversation deletion route with parameter validation

- Updated the DELETE /api/convos route to handle cases where the request body is empty or the 'arg' parameter is null/undefined, returning a 400 status with an appropriate error message for DoS prevention.
- Added corresponding tests to ensure proper validation and error handling for these scenarios, enhancing the robustness of the API.

* 🔧 fix: Improve request body validation in keys and convos routes

- Updated the DELETE /api/convos and PUT /api/keys routes to validate the request body, returning a 400 status for null or invalid bodies to enhance security and prevent potential DoS attacks.
- Added corresponding tests to ensure proper error handling for these scenarios, improving the robustness of the API.
2026-02-12 18:08:24 -05:00
Andrei Blizorukov
793ddbce9f
🔎 fix: Include Legacy Documents With Undefined _meiliIndex in Search Sync (#11745)
* fix: document with undefined _meiliIndex not synced

missing property _meiliIndex is not being synced into meilisearch

* fix: updated comments to reflect changes to fix_meiliSearch property usage
2026-02-12 18:05:53 -05:00
Danny Avila
e3a60ba532
📦 chore: @librechat/agents to v3.1.41 (#11759) 2026-02-12 17:43:43 -05:00
Danny Avila
7067c35787
🏁 fix: Resolve Content Aggregation Race Condition in Agent Event Handlers (#11757)
* 🔧 refactor: Consolidate aggregateContent calls in agent handlers

- Moved aggregateContent function calls to the beginning of the event handling functions in the agent callbacks to ensure consistent data aggregation before processing events. This change improves code clarity and maintains the intended functionality without redundancy.

* 🔧 chore: Update @librechat/agents to version 3.1.40 in package.json and package-lock.json across multiple packages

* 🔧 fix: Increase default recursion limit in AgentClient from 25 to 50 for improved processing capability
2026-02-12 15:42:22 -05:00
Danny Avila
599f4a11f1
🛡️ fix: Secure MCP/Actions OAuth Flows, Resolve Race Condition & Tool Cache Cleanup (#11756)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* 🔧 fix: Update OAuth error message for clarity

- Changed the default error message in the OAuth error route from 'Unknown error' to 'Unknown OAuth error' to provide clearer context during authentication failures.

* 🔒 feat: Enhance OAuth flow with CSRF protection and session management

- Implemented CSRF protection for OAuth flows by introducing `generateOAuthCsrfToken`, `setOAuthCsrfCookie`, and `validateOAuthCsrf` functions.
- Added session management for OAuth with `setOAuthSession` and `validateOAuthSession` middleware.
- Updated routes to bind CSRF tokens for MCP and action OAuth flows, ensuring secure authentication.
- Enhanced tests to validate CSRF handling and session management in OAuth processes.

* 🔧 refactor: Invalidate cached tools after user plugin disconnection

- Added a call to `invalidateCachedTools` in the `updateUserPluginsController` to ensure that cached tools are refreshed when a user disconnects from an MCP server after a plugin authentication update. This change improves the accuracy of tool data for users.

* chore: imports order

* fix: domain separator regex usage in ToolService

- Moved the declaration of `domainSeparatorRegex` to avoid redundancy in the `loadActionToolsForExecution` function, improving code clarity and performance.

* chore: OAuth flow error handling and CSRF token generation

- Enhanced the OAuth callback route to validate the flow ID format, ensuring proper error handling for invalid states.
- Updated the CSRF token generation function to require a JWT secret, throwing an error if not provided, which improves security and clarity in token generation.
- Adjusted tests to reflect changes in flow ID handling and ensure robust validation across various scenarios.
2026-02-12 14:22:05 -05:00
github-actions[bot]
72a30cd9c4
🌍 i18n: Update translation.json with latest translations (#11739)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-11 22:56:06 -05:00
Dustin Healy
cc7f61096b
💡 fix: System Theme Picker Selection (#11220)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* fix: theme picker selection

* refactor: remove problematic Jotai use and replace with React state and localStorage implementation

* chore: address comments from Copilot + LibreChat Agent assisted reviewers

* chore: remove unnecessary edit

* chore: remove space
2026-02-11 22:46:41 -05:00
Danny Avila
5b67e48fe1
🗃️ refactor: Separate Tool Cache Namespace for Blue/Green Deployments (#11738)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* 🔧 refactor: Introduce TOOL_CACHE for isolated caching of tools

- Added TOOL_CACHE key to CacheKeys enum for managing tool-related cache.
- Updated various services and controllers to utilize TOOL_CACHE instead of CONFIG_STORE for better separation of concerns in caching logic.
- Enhanced .env.example with comments on using in-memory cache for blue/green deployments.

* 🔧 refactor: Update cache configuration for in-memory storage handling

- Enhanced the handling of `FORCED_IN_MEMORY_CACHE_NAMESPACES` in `cacheConfig.ts` to default to `CONFIG_STORE` and `APP_CONFIG`, ensuring safer blue/green deployments.
- Updated `.env.example` with clearer comments regarding the usage of in-memory cache namespaces.
- Improved unit tests to validate the new default behavior and handling of empty strings for cache namespaces.
2026-02-11 22:20:43 -05:00
ethanlaj
c7531dd029
🕵️‍♂️ fix: Handle 404 errors on agent queries for favorites (#11587) 2026-02-11 22:12:05 -05:00
WhammyLeaf
417405a974
🏢 fix: Handle Group Overage for Azure Entra Authentication (#11557)
small fix

add tests

reorder

Update api/strategies/openidStrategy.spec.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

Update api/strategies/openidStrategy.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

some fixes

and fix

fix

more fixes

fix
2026-02-11 22:11:05 -05:00
Danny Avila
924be3b647
🛡️ fix: Implement TOCTOU-Safe SSRF Protection for Actions and MCP (#11722)
* refactor: better SSRF Protection in Action and Tool Services

- Added `createSSRFSafeAgents` function to create HTTP/HTTPS agents that block connections to private/reserved IP addresses, enhancing security against SSRF attacks.
- Updated `createActionTool` to accept a `useSSRFProtection` parameter, allowing the use of SSRF-safe agents during tool execution.
- Modified `processRequiredActions` and `loadAgentTools` to utilize the new SSRF protection feature based on allowed domains configuration.
- Introduced `resolveHostnameSSRF` function to validate resolved IPs against private ranges, preventing potential SSRF vulnerabilities.
- Enhanced tests for domain resolution and private IP detection to ensure robust SSRF protection mechanisms are in place.

* feat: Implement SSRF protection in MCP connections

- Added `createSSRFSafeUndiciConnect` function to provide SSRF-safe DNS lookup options for undici agents.
- Updated `MCPConnection`, `MCPConnectionFactory`, and `ConnectionsRepository` to include `useSSRFProtection` parameter, enabling SSRF protection based on server configuration.
- Enhanced `MCPManager` and `UserConnectionManager` to utilize SSRF protection when establishing connections.
- Updated tests to validate the integration of SSRF protection across various components, ensuring robust security measures are in place.

* refactor: WS MCPConnection with SSRF protection and async transport construction

- Added `resolveHostnameSSRF` to validate WebSocket hostnames against private IP addresses, enhancing SSRF protection.
- Updated `constructTransport` method to be asynchronous, ensuring proper handling of SSRF checks before establishing connections.
- Improved error handling for WebSocket transport to prevent connections to potentially unsafe addresses.

* test: Enhance ActionRequest tests for SSRF-safe agent passthrough

- Added tests to verify that httpAgent and httpsAgent are correctly passed to axios.create when provided in ActionRequest.
- Included scenarios to ensure agents are not included when no options are specified.
- Enhanced coverage for POST requests to confirm agent passthrough functionality.
- Improved overall test robustness for SSRF protection in ActionRequest execution.
2026-02-11 22:09:58 -05:00
Marco Beretta
d6b6f191f7
style(MCP): Enhance dialog accessibility and styling consistency (#11585)
* style: update input IDs in BasicInfoSection for consistency and improve accessibility

* style: add border-destructive variable for improved design consistency

* style: update error border color for title input in BasicInfoSection

* style: update delete confirmation dialog title and description for MCP Server

* style: add text-destructive variable for improved design consistency

* style: update error message and border color for URL and trust fields for consistency

* style: reorder imports and update error message styling for consistency across sections

* style: enhance MCPServerDialog with copy link functionality and UI improvements

* style: enhance MCPServerDialog with improved accessibility and loading indicators

* style: bump @librechat/client to 0.4.51 and enhance OGDialogTemplate for improved selection handling

* a11y: enhance accessibility and error handling in MCPServerDialog sections

* style: enhance MCPServerDialog accessibility and improve resource name handling

* style: improve accessibility in MCPServerDialog and AuthSection, update translation for delete confirmation

* style: update aria-invalid attributes to use string values for improved accessibility in form sections

* style: enhance accessibility in AuthSection by updating aria attributes and adding error messages

* style: remove unnecessary aria-hidden attributes from Spinner components in MCPServerDialog

* style: simplify legacy selection check in OGDialogTemplate
2026-02-11 22:08:40 -05:00
Danny Avila
299efc2ccb
📦 chore: Bump @librechat/agents & axios, Bedrock Prompt Caching fix (#11723)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Publish `librechat-data-provider` to NPM / build (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* 🔧 chore: Update @librechat/agents to version 3.1.39 in package.json and package-lock.json

* 🔧 chore: Update axios to version 1.13.5 in package.json and package-lock.json across multiple packages
2026-02-10 20:03:17 -05:00
Danny Avila
4ddaab68a1
🔧 fix: Update z-index for ImagePreview modal components (#11714)
- Increased z-index values for the DialogPrimitive overlay and content in ImagePreview.tsx to ensure proper stacking order and visibility of modal elements. This change enhances the user experience by preventing modal content from being obscured by other UI elements.
2026-02-10 15:08:17 -05:00
Sean DMR
8da3c38780
🪟 fix: Update Link Target to Open in Separate Tabs (#11669)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
`_new` is not a recognized keyword for the `target` attribute. While
browsers treat it as a named window, `_blank` is the standard value
for opening links in a new tab/window.
2026-02-10 13:28:18 -05:00
Danny Avila
e646a3615e
🌊 fix: Prevent Truncations When Redis Resumable Streams Are Enabled (#11710)
* fix: prevent truncated responses when Redis resumable streams are enabled

Race condition in RedisEventTransport.subscribe() caused early events
(seq 0+) to be lost. The Redis SUBSCRIBE command was fired as
fire-and-forget, but GenerationJobManager immediately set
hasSubscriber=true, disabling the earlyEventBuffer. Events published
during the gap between subscribe() returning and the Redis subscription
actually taking effect were neither buffered nor received — they were
silently dropped by Pub/Sub.

This manifested as "timeout waiting for seq 0, force-flushing N messages"
warnings followed by truncated or missing response text in the UI.

The fix:

- IEventTransport.subscribe() now returns an optional `ready` promise
  that resolves once the transport can actually receive messages
- RedisEventTransport returns the Redis SUBSCRIBE acknowledgment as the
  `ready` promise instead of firing it as fire-and-forget
- GenerationJobManager.subscribe() awaits `ready` before setting
  hasSubscriber=true, keeping the earlyEventBuffer active during the
  subscription window so no events are lost
- GenerationJobManager.emitChunk() early-returns after buffering when no
  subscriber is connected, avoiding wasteful Redis PUBLISHes that nobody
  would receive

Adds 5 regression tests covering the race condition for both in-memory
and Redis transports, verifying that events emitted before subscribe are
buffered and replayed, that the ready promise contract is correct for
both transport implementations, and that no events are lost across the
subscribe boundary.

* refactor: Update import paths in GenerationJobManager integration tests

- Refactored import statements in the GenerationJobManager integration test file to use absolute paths instead of relative paths, improving code readability and maintainability.
- Removed redundant imports and ensured consistent usage of the updated import structure across the test cases.

* chore: Remove redundant await from GenerationJobManager initialization in tests

- Updated multiple test cases to call GenerationJobManager.initialize() without awaiting, improving test performance and clarity.
- Ensured consistent initialization across various scenarios in the CollectedUsage and AbortJob test suites.

* refactor: Enhance GenerationJobManager integration tests and RedisEventTransport cleanup

- Updated GenerationJobManager integration tests to utilize dynamic Redis clients and removed unnecessary awaits from initialization calls, improving test performance.
- Refactored RedisEventTransport's destroy method to safely disconnect the subscriber, enhancing resource management and preventing potential errors during cleanup.

* feat: Enhance GenerationJobManager and RedisEventTransport for improved event handling

- Added a resetSequence method to IEventTransport and implemented it in RedisEventTransport to manage publish sequence counters effectively.
- Updated GenerationJobManager to utilize the new resetSequence method, ensuring proper event handling during stream operations.
- Introduced integration tests for GenerationJobManager to validate cross-replica event publishing and subscriber readiness in Redis, enhancing test coverage and reliability.

* test: Add integration tests for GenerationJobManager sequence reset and error recovery with Redis

- Introduced new tests to validate the behavior of GenerationJobManager during sequence resets, ensuring no stale events are received after a reset.
- Added tests to confirm that the sequence is not reset when a second subscriber joins mid-stream, maintaining event integrity.
- Implemented a test for resubscription after a Redis subscribe failure, verifying that events can still be received post-error.
- Enhanced overall test coverage for Redis-related functionalities in GenerationJobManager.

* fix: Update GenerationJobManager and RedisEventTransport for improved event synchronization

- Replaced the resetSequence method with syncReorderBuffer in GenerationJobManager to enhance cross-replica event handling without resetting the publisher sequence.
- Added a new syncReorderBuffer method in RedisEventTransport to advance the subscriber reorder buffer safely, ensuring no data loss during subscriber transitions.
- Introduced a new integration test to validate that local subscribers joining do not cause data loss for cross-replica subscribers, enhancing the reliability of event delivery.
- Updated existing tests to reflect changes in event handling logic, improving overall test coverage and robustness.

* fix: Clear flushTimeout in RedisEventTransport to prevent potential memory leaks

- Added logic to clear the flushTimeout in the reorderBuffer when resetting the sequence counters, ensuring proper resource management and preventing memory leaks during state transitions in RedisEventTransport.
2026-02-10 13:16:29 -05:00
Danny Avila
9054ca9c15
🆔 fix: Atomic File Dedupe, Bedrock Tokens Fix, and Allowed MIME Types (#11675)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* feat: Add support for Apache Parquet MIME types

- Introduced 'application/x-parquet' to the full MIME types list and code interpreter MIME types list.
- Updated application MIME types regex to include 'x-parquet' and 'vnd.apache.parquet'.
- Added mapping for '.parquet' files to 'application/x-parquet' in code type mapping, enhancing file format support.

* feat: Implement atomic file claiming for code execution outputs

- Added a new `claimCodeFile` function to atomically claim a file_id for code execution outputs, preventing duplicates by using a compound key of filename and conversationId.
- Updated `processCodeOutput` to utilize the new claiming mechanism, ensuring that concurrent calls for the same filename converge on a single record.
- Refactored related tests to validate the new atomic claiming behavior and its impact on file usage tracking and versioning.

* fix: Update image file handling to use cache-busting filepath

- Modified the `processCodeOutput` function to generate a cache-busting filepath for updated image files, improving browser caching behavior.
- Adjusted related tests to reflect the change from versioned filenames to cache-busted filepaths, ensuring accurate validation of image updates.

* fix: Update step handler to prevent undefined content for non-tool call types

- Modified the condition in useStepHandler to ensure that undefined content is only assigned for specific content types, enhancing the robustness of content handling.

* fix: Update bedrockOutputParser to handle maxTokens for adaptive models

- Modified the bedrockOutputParser logic to ensure that maxTokens is not set for adaptive models when neither maxTokens nor maxOutputTokens are provided, improving the handling of adaptive thinking configurations.
- Updated related tests to reflect these changes, ensuring accurate validation of the output for adaptive models.

* chore: Update @librechat/agents to version 3.1.38 in package.json and package-lock.json

* fix: Enhance file claiming and error handling in code processing

- Updated the `processCodeOutput` function to use a consistent file ID for claiming files, preventing duplicates and improving concurrency handling.
- Refactored the `createFileMethods` to include error handling for failed file claims, ensuring robust behavior when claiming files for conversations.
- These changes enhance the reliability of file management in the application.

* fix: Update adaptive thinking test for Opus 4.6 model

- Modified the test for configuring adaptive thinking to reflect that no default maxTokens should be set for the Opus 4.6 model.
- Updated assertions to ensure that maxTokens is undefined, aligning with the expected behavior for adaptive models.
2026-02-07 13:26:18 -05:00
Danny Avila
a771d70b10
🎬 fix: Code Session Context In Event Driven Mode (#11673)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* fix: Update parseTextParts to handle undefined content parts

- Modified the parseTextParts function to accept an array of content parts that may include undefined values.
- Implemented optional chaining to safely check for the type of each part, preventing potential runtime errors when accessing properties of undefined elements.

* refactor: Tool Call Configuration with Session Context

- Added support for including session ID and injected files in the tool call configuration when a code session context is present.
- Improved handling of tool call configurations to accommodate additional context data, enhancing the functionality of the tool execution handler.

* chore: Update @librechat/agents to version 3.1.37 in package.json and package-lock.json

* test: Add unit tests for createToolExecuteHandler

- Introduced a new test suite for the createToolExecuteHandler function, validating the handling of session context in tool calls.
- Added tests to ensure correct passing of session IDs and injected files based on the presence of codeSessionContext.
- Included scenarios for handling multiple tool calls and ensuring non-code execution tools are unaffected by session context.

* test: Update createToolExecuteHandler tests for session context handling

- Renamed test to clarify that it checks for the absence of session context in non-code-execution tools.
- Updated assertions to ensure that session_id and _injected_files are undefined when non-code-execution tools are invoked, enhancing test accuracy.
2026-02-07 03:09:55 -05:00
github-actions[bot]
968e97b4d2
🌍 i18n: Update translation.json with latest translations (#11672)
* 🌍 i18n: Update translation.json with latest translations

* Update reasoning description for Claude models

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-02-07 02:52:42 -05:00
Danny Avila
41e2348d47
🤖 feat: Claude Opus 4.6 - 1M Context, Premium Pricing, Adaptive Thinking (#11670)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* feat: Implement new features for Claude Opus 4.6 model

- Added support for tiered pricing based on input token count for the Claude Opus 4.6 model.
- Updated token value calculations to include inputTokenCount for accurate pricing.
- Enhanced transaction handling to apply premium rates when input tokens exceed defined thresholds.
- Introduced comprehensive tests to validate pricing logic for both standard and premium rates across various scenarios.
- Updated related utility functions and models to accommodate new pricing structure.

This change improves the flexibility and accuracy of token pricing for the Claude Opus 4.6 model, ensuring users are charged appropriately based on their usage.

* feat: Add effort field to conversation and preset schemas

- Introduced a new optional `effort` field of type `String` in both the `IPreset` and `IConversation` interfaces.
- Updated the `conversationPreset` schema to include the `effort` field, enhancing the data structure for better context management.

* chore: Clean up unused variable and comments in initialize function

* chore: update dependencies and SDK versions

- Updated @anthropic-ai/sdk to version 0.73.0 in package.json and overrides.
- Updated @anthropic-ai/vertex-sdk to version 0.14.3 in packages/api/package.json.
- Updated @librechat/agents to version 3.1.34 in packages/api/package.json.
- Refactored imports in packages/api/src/endpoints/anthropic/vertex.ts for consistency.

* chore: remove postcss-loader from dependencies

* feat: Bedrock model support for adaptive thinking configuration

- Updated .env.example to include new Bedrock model IDs for Claude Opus 4.6.
- Refactored bedrockInputParser to support adaptive thinking for Opus models, allowing for dynamic thinking configurations.
- Introduced a new function to check model compatibility with adaptive thinking.
- Added an optional `effort` field to the input schemas and updated related configurations.
- Enhanced tests to validate the new adaptive thinking logic and model configurations.

* feat: Add tests for Opus 4.6 adaptive thinking configuration

* feat: Update model references for Opus 4.6 by removing version suffix

* feat: Update @librechat/agents to version 3.1.35 in package.json and package-lock.json

* chore: @librechat/agents to version 3.1.36 in package.json and package-lock.json

* feat: Normalize inputTokenCount for spendTokens and enhance transaction handling

- Introduced normalization for promptTokens to ensure inputTokenCount does not go negative.
- Updated transaction logic to reflect normalized inputTokenCount in pricing calculations.
- Added comprehensive tests to validate the new normalization logic and its impact on transaction rates for both standard and premium models.
- Refactored related functions to improve clarity and maintainability of token value calculations.

* chore: Simplify adaptive thinking configuration in helpers.ts

- Removed unnecessary type casting for the thinking property in updatedOptions.
- Ensured that adaptive thinking is directly assigned when conditions are met, improving code clarity.

* refactor: Replace hard-coded token values with dynamic retrieval from maxTokensMap in model tests

* fix: Ensure non-negative token values in spendTokens calculations

- Updated token value retrieval to use Math.max for prompt and completion tokens, preventing negative values.
- Enhanced clarity in token calculations for both prompt and completion transactions.

* test: Add test for normalization of negative structured token values in spendStructuredTokens

- Implemented a test to ensure that negative structured token values are normalized to zero during token spending.
- Verified that the transaction rates remain consistent with the expected standard values after normalization.

* refactor: Bedrock model support for adaptive thinking and context handling

- Added tests for various alternate naming conventions of Claude models to validate adaptive thinking and context support.
- Refactored `supportsAdaptiveThinking` and `supportsContext1m` functions to utilize new parsing methods for model version extraction.
- Updated `bedrockInputParser` to handle effort configurations more effectively and strip unnecessary fields for non-adaptive models.
- Improved handling of anthropic model configurations in the input parser.

* fix: Improve token value retrieval in getMultiplier function

- Updated the token value retrieval logic to use optional chaining for better safety against undefined values.
- Added a test case to ensure that the function returns the default rate when the provided valueKey does not exist in tokenValues.
2026-02-06 18:35:36 -05:00
github-actions[bot]
1d5f2eb04b
🌍 i18n: Update translation.json with latest translations (#11655)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-06 09:00:25 -05:00
Danny Avila
e1f02611a0
📦 chore: Update @librechat/agents to v3.1.33 (#11665) 2026-02-06 08:59:48 -05:00
Danny Avila
feb72ad2dc
🔄 refactor: Sequential Event Ordering in Redis Streaming Mode (#11650)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* chore: linting image context file

* refactor: Event Emission with Async Handling for Redis Ordering

- Updated emitEvent and related functions to be async, ensuring proper event ordering in Redis mode.
- Refactored multiple handlers to await emitEvent calls, improving reliability for streaming deltas.
- Enhanced GenerationJobManager to await chunk emissions, critical for maintaining sequential event delivery.
- Added tests to verify that events are delivered in strict order when using Redis, addressing previous issues with out-of-order messages.

* refactor: Clear Pending Buffers and Timeouts in RedisEventTransport

- Enhanced the cleanup process in RedisEventTransport by ensuring that pending messages and flush timeouts are cleared when the last subscriber unsubscribes.
- Updated the destroy method to also clear pending messages and flush timeouts for all streams, improving resource management and preventing memory leaks.

* refactor: Update Event Emission to Async for Improved Ordering

- Refactored GenerationJobManager and RedisEventTransport to make emitDone and emitError methods async, ensuring proper event ordering in Redis mode.
- Updated all relevant calls to await these methods, enhancing reliability in event delivery.
- Adjusted tests to verify that events are processed in the correct sequence, addressing previous issues with out-of-order messages.

* refactor: Adjust RedisEventTransport for 0-Indexed Sequence Handling

- Updated sequence handling in RedisEventTransport to be 0-indexed, ensuring consistency across event emissions and buffer management.
- Modified integration tests to reflect the new sequence logic, improving the accuracy of event processing and delivery order.
- Enhanced comments for clarity on sequence management and terminal event handling.

* chore: Add Redis dump file to .gitignore

- Included dump.rdb in .gitignore to prevent accidental commits of Redis database dumps, enhancing repository cleanliness and security.

* test: Increase wait times in RedisEventTransport integration tests for CI stability

- Adjusted wait times for subscription establishment and event propagation from 100ms and 200ms to 500ms to improve reliability in CI environments.
- Enhanced code readability by formatting promise resolution lines for better clarity.
2026-02-05 17:57:33 +01:00
Dustin Healy
46624798b6
🗣 fix: Add Various State Change Announcements (#11495)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* fix: Agent Builder Reset button announcements

* fix: special variables announcements

* fix: model select announcements

* fix: prompt deletion announcement

* refactor: encapsulate model display name logic

* chore: address comments

* chore: re-order i18n strings
2026-02-05 16:42:15 +01:00
Marco Beretta
fcb363403a
🌍 i18n: Support for Icelandic, Lithuanian, Nynorsk and Slovak (#11649) 2026-02-05 16:39:20 +01:00
Chiranjeevisantosh Madugundi
754d921b51
🧽 chore: Remove deprecated Claude models from Default List (#11639) 2026-02-05 15:07:45 +01:00
Danny Avila
8cf5ae7e79
🛡️ fix: Preserve CREATE/SHARE/SHARE_PUBLIC Permissions with Boolean Config (#11647)
* 🔧 refactor: Update permissions handling in updateInterfacePermissions function

- Removed explicit SHARE and SHARE_PUBLIC permissions for PROMPTS when prompts are true, simplifying the permission logic.
- Adjusted the permissions structure to conditionally include SHARE and SHARE_PUBLIC based on the type of interface configuration, enhancing maintainability and clarity in permission management.
- Updated related tests to reflect the changes in permission handling for consistency and accuracy.

* 🔧 refactor: Enhance permission configuration in updateInterfacePermissions

- Introduced a new `create` property in the permission configuration object to improve flexibility in permission management.
- Updated helper functions to accommodate the new `create` property, ensuring backward compatibility with existing boolean configurations.
- Adjusted default values for prompts and agents to include the new `create` property, enhancing the overall permission structure.

* 🧪 test: Add regression tests for SHARE/SHARE_PUBLIC permission handling

- Introduced tests to ensure existing SHARE and SHARE_PUBLIC values are preserved when using boolean configuration for agents.
- Added validation to confirm that SHARE and SHARE_PUBLIC are included in the update payload when using object configuration, enhancing the accuracy of permission management.
- These tests address potential regressions and improve the robustness of the permission handling logic in the updateInterfacePermissions function.

* fix: accessing undefined regex

- Moved the creation of the domainSeparatorRegex to the beginning of the loadToolDefinitionsWrapper function for improved clarity and performance.
- Removed redundant regex initialization within the function's loop, enhancing code efficiency and maintainability.

* 🧪 test: Enhance regression tests for SHARE/SHARE_PUBLIC permission handling

- Added a new test to ensure that SHARE and SHARE_PUBLIC permissions are preserved when using object configuration without explicit share/public keys.
- Updated existing tests to validate the inclusion of SHARE and SHARE_PUBLIC in the update payload when using object configuration, improving the robustness of permission management.
- Adjusted the updateInterfacePermissions function to conditionally include SHARE and SHARE_PUBLIC based on the presence of share/public keys in the configuration, enhancing clarity and maintainability.

* 🔧 refactor: Update permission handling in updateInterfacePermissions

- Simplified the logic for including CREATE, SHARE, and SHARE_PUBLIC permissions in the update payload based on the presence of corresponding keys in the configuration object.
- Adjusted tests to reflect the changes, ensuring that only the USE permission is updated when existing permissions are present, preserving the database values for CREATE, SHARE, and SHARE_PUBLIC.
- Enhanced clarity in comments to better explain the permission management logic.
2026-02-05 15:06:53 +01:00
Danny Avila
24625f5693
🧩 refactor: Tool Context Builders for Web Search & Image Gen (#11644)
* fix: Web Search + Image Gen Tool Context

- Added `buildWebSearchContext` function to create a structured context for web search tools, including citation format instructions.
- Updated `loadTools` and `loadToolDefinitionsWrapper` functions to utilize the new web search context, improving tool initialization and response handling.
- Introduced logic to handle image editing tools with `buildImageToolContext`, enhancing the overall tool management capabilities.
- Refactored imports in `ToolService.js` to include the new context builders for better organization and maintainability.

* fix: Trim critical output escape sequence instructions in web toolkit

- Updated the critical output escape sequence instructions in the web toolkit to include a `.trim()` method, ensuring that unnecessary whitespace is removed from the output. This change enhances the consistency and reliability of the generated output.
2026-02-05 14:10:19 +01:00
Danny Avila
1ba5bf87b0
📬 feat: Implement Delta Buffering System for Out-of-Order SSE Events (#11643)
*  test: Add MCP tool definitions tests for server name variants

- Introduced new test cases for loading MCP tools with underscored and hyphenated server names, ensuring correct functionality and handling of tool definitions.
- Validated that the tool definitions are loaded accurately based on different server name formats, enhancing test coverage for the MCP tool integration.
- Included assertions to verify the expected behavior and properties of the loaded tools, improving reliability and maintainability of the tests.

* refactor: useStepHandler to support additional delta events and buffer management

- Added support for Agents.ReasoningDeltaEvent and Agents.RunStepDeltaEvent in the TStepEvent type.
- Introduced a pendingDeltaBuffer to store deltas that arrive before their corresponding run step, ensuring they are processed in the correct order.
- Updated event handling to buffer deltas when no corresponding run step is found, improving the reliability of message processing.
- Cleared the pendingDeltaBuffer during cleanup to prevent memory leaks.
2026-02-05 14:00:54 +01:00
Danny Avila
c8e4257342
📦 chore: Update @modelcontextprotocol/sdk to v1.26.0 (#11636)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
2026-02-05 09:09:04 +01:00
Jannik Maierhöfer
61f54e0565
🪢 docs: add Langfuse to .env.example (#11632) 2026-02-05 08:50:39 +01:00
Danny Avila
b87b8ceaa6
📦 chore: Update @librechat/agents to v3.1.32 (#11630)
- Bumped the version of @librechat/agents to 3.1.32 across multiple package.json and package-lock.json files to ensure compatibility and access to the latest features.
- This update enhances the functionality and stability of the application by integrating the latest improvements from the @librechat/agents package.
2026-02-05 08:44:33 +01:00
Danny Avila
5dc5799fc0
✈️ refactor: Single-Flight Deduplication for MCP Server Configs and Optimize Redis Batch Fetching (#11628)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* fix: Implement single-flight deduplication for getAllServerConfigs and optimize Redis getAll method

- Added a pending promises map in MCPServersRegistry to handle concurrent calls to getAllServerConfigs, ensuring that multiple requests for the same userId are deduplicated.
- Introduced a new fetchAllServerConfigs method to streamline the fetching process and improve performance.
- Enhanced the getAll method in ServerConfigsCacheRedis to utilize MGET for batch fetching, significantly reducing Redis roundtrips and improving efficiency.
- Added comprehensive tests for deduplication and performance optimizations, ensuring consistent results across concurrent calls and validating the new implementation.

* refactor: Enhance logging in ServerConfigsCacheRedis for getAll method

- Added debug logging to track the execution time and key retrieval in the getAll method of ServerConfigsCacheRedis.
- Improved import organization by consolidating related imports for better clarity and maintainability.

* test: Update MCPServersRegistry and ServerConfigsCacheRedis tests for call count assertions

- Modified MCPServersRegistry integration tests to assert specific call counts for cache retrieval, ensuring accurate tracking of Redis interactions.
- Refactored ServerConfigsCacheRedis integration tests to rename the test suite for clarity and improved focus on parallel fetching optimizations.
- Enhanced the getAll method in ServerConfigsCacheRedis to utilize batching for improved performance during key retrieval.

* chore: Simplify key extraction in ServerConfigsCacheRedis

- Streamlined the key extraction logic in the getAll method of ServerConfigsCacheRedis by consolidating the mapping function into a single line, enhancing code readability and maintainability.
2026-02-04 16:25:22 +01:00
hellojaccc
db84ec681a
🎭 chore: Padding to Maskable Icon for Android adaptive icons (#11626)
* Add files via upload

add more padding to maskable icon

* add more padding to maskable icon

Added more padding to maskable icon, since it's broken on one ui 8 phone.
2026-02-04 15:51:51 +01:00
Danny Avila
e89e514fcb
📱 fix: Mention Touch UX and MCP Tool UI Consistency (#11627)
* refactor: Reorganize imports in MCPTools component

- Updated import statements in MCPTools.tsx for improved clarity and maintainability.
- Moved `useAgentPanelContext` import above others and adjusted the order of `PermissionTypes` and `Permissions` imports to enhance readability.

* chore: imports

* refactor: Update MCPToolItem component props and styles

- Added new props: onToggleDefer, onToggleSelect, and onToggleProgrammatic for improved functionality.
- Adjusted class names for DropdownMenuLabel and text spans to enhance visual consistency and clarity.
- Increased width of DropdownMenuContent for better layout.

* refactor: Update DropdownMenu styles for improved visual consistency

- Changed background color of DropdownMenuContent and DropdownMenuSubContent from secondary to primary for better alignment with design standards.
- Updated text color to ensure readability against the new background, enhancing overall user experience.

* refactor: Update Mention component styles and interaction handling

- Increased ROW_HEIGHT in Mention and PromptsCommand components for improved layout consistency.
- Enhanced MentionItem component with touch event handling to improve mobile interaction experience.
- Updated button styles to ensure better visual alignment and responsiveness.

* refactor: Enhance MentionItem component event handling and button attributes

- Updated the onClick prop type in MentionItem to support both mouse and touch events, improving mobile interaction.
- Added a button type attribute for better accessibility and compliance with HTML standards.
- Refined event handling to ensure consistent behavior across different input methods.

* refactor: Add button type attribute to MentionItem for improved accessibility

- Added a type="button" attribute to the MentionItem component to enhance accessibility and compliance with HTML standards.
- This change ensures better interaction behavior across different input methods.

* refactor: Simplify MentionItem event handling for improved clarity

- Removed touch event handling from the MentionItem component, streamlining the onClick prop to only accept mouse events.
- This change simplifies the interaction logic, enhancing maintainability while retaining functionality for mouse interactions.
2026-02-04 15:22:32 +01:00
Dustin Healy
2cef9368ea
👆 fix: Web Search and Code Interpreter Toggle Cursor Hover Behavior (#11614) 2026-02-04 13:57:25 +01:00
Danny Avila
6274268411
📦 chore: Update @librechat/agents to v3.1.31 & Apply NPM Audit (#11623)
* 📦 chore: Update @librechat/agents to v3.1.31

- Bumped the version of @librechat/agents across multiple package.json and package-lock.json files to ensure compatibility and access to the latest features.
- This update enhances the functionality and stability of the application by integrating the latest improvements from the @librechat/agents package.

* 📦 chore: npm audit fix - Update @isaacs/brace-expansion to v5.0.1 in package-lock.json

- Bumped the version of @isaacs/brace-expansion to 5.0.1 to incorporate the latest improvements and fixes.
- This update ensures compatibility with the latest features and enhances the stability of the application.
2026-02-04 11:51:42 +01:00
Danny Avila
5eb0a3ad90
⚠️ chore: Remove Deprecated forcePrompt setting (#11622)
- Removed `forcePrompt` parameter from various configuration files including `librechat.example.yaml`, `initialize.js`, `values.yaml`, and `initialize.ts`.
    - This change simplifies the configuration by eliminating unused options, enhancing clarity and maintainability across the codebase.
2026-02-04 11:02:27 +01:00
Danny Avila
f34052c6bb
🌙 feat: Moonshot Provider Support (#11621)
*  feat: Add Moonshot Provider Support

- Updated the `isKnownCustomProvider` function to include `Providers.MOONSHOT` in the list of recognized custom providers.
- Enhanced the `providerConfigMap` to initialize `MOONSHOT` with the custom initialization function.
- Introduced `MoonshotIcon` component for visual representation in the UI, integrated into the `UnknownIcon` component.
- Updated various files across the API and client to support the new `MOONSHOT` provider, including configuration and response handling.

This update expands the capabilities of the application by integrating support for the Moonshot provider, enhancing both backend and frontend functionalities.

*  feat: Add Moonshot/Kimi Model Pricing and Tests

- Introduced new pricing configurations for Moonshot and Kimi models in `tx.js`, including various model variations and their respective prompt and completion values.
- Expanded unit tests in `tx.spec.js` and `tokens.spec.js` to validate pricing and token limits for the newly added Moonshot/Kimi models, ensuring accurate calculations and handling of model variations.
- Updated utility functions to support the new model structures and ensure compatibility with existing functionalities.

This update enhances the pricing model capabilities and improves test coverage for the Moonshot/Kimi integration.

*  feat: Enhance Token Pricing Documentation and Configuration

- Added comprehensive documentation for token pricing configuration in `tx.js` and `tokens.ts`, emphasizing the importance of key ordering for pattern matching.
- Clarified the process for defining base and specific patterns to ensure accurate pricing retrieval based on model names.
- Improved code comments to guide future additions of model families, enhancing maintainability and understanding of the pricing structure.

This update improves the clarity and usability of the token pricing configuration, facilitating better integration and future enhancements.

* chore: import order

* chore: linting
2026-02-04 10:53:57 +01:00
Dustin Healy
56a1b28293
🔓 docs: Comment Out MCP Permissions in librechat.example.yaml (#11620) 2026-02-04 10:13:16 +01:00
Joseph Licata
5cf50dd15e
🔦 fix: Tool resource files not visible in event-driven mode (#11610)
* fix: Execute code files not visible in event-driven mode

Fixes regression from #11588 where primeResources became non-mutating
but callers weren't updated to use returned values.

Changes:
- Add tool_resources to InitializedAgent type and return object
- Prime execute_code files in loadToolDefinitionsWrapper
- Pass tool_resources to loadToolDefinitionsWrapper
- Capture and return toolContextMap from loadToolsForExecution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: Reorganize imports and enhance tool loading logic in ToolService.js

- Moved domainSeparatorRegex declaration to a more appropriate location for clarity.
- Reorganized import statements for better readability and maintainability.
- Removed unused variables and streamlined the loadToolsForExecution function by eliminating the regularToolContextMap, improving performance and code clarity.
- Updated loadActionToolsForExecution to ensure consistent handling of domain separator regex.

This refactor improves the overall structure and efficiency of the ToolService module.

* fix: file search tool priming in loadToolDefinitionsWrapper

- Added functionality to prime file search tools within the loadToolDefinitionsWrapper function, enhancing the tool context map for event-driven mode.
- Implemented error handling for the file search priming process to improve robustness and logging.
- Updated the tool context map to include the newly primed file search tool, ensuring it is available for subsequent operations.

This enhancement improves the tool loading capabilities by incorporating file search tools, facilitating better integration and functionality in the application.

* chore: import order

* refactor: Update agent initialization structure for improved clarity and functionality

- Rearranged properties in the InitializedAgent object to enhance readability and maintainability.
- Moved toolRegistry to the correct position and ensured tools and attachments are set appropriately.
- This refactor improves the overall structure of the agent initialization process, facilitating better integration and future enhancements.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-02-04 10:11:47 +01:00
Danny Avila
9f23dd38e7
📦 chore: Update package dependencies and versions (#11606)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
- Bumped versions of several AWS SDK packages in package-lock.json and package.json to ensure compatibility and access to the latest features.
- Updated @librechat/agents to version 3.1.30 across multiple package.json files for improved functionality.
- Added fast-xml-parser dependency in package.json for enhanced XML parsing capabilities.
- Adjusted various AWS SDK dependencies to their latest versions, improving performance and security.

This update ensures that the project utilizes the most recent and stable versions of its dependencies, enhancing overall reliability and functionality.
2026-02-02 16:11:57 +01:00
Danny Avila
3ffc0c74bf
🎯 feat: Add Programmatic Tool Calling UI for MCP Tools (#11604)
* feat: MCP Tool Functionality with Tool Options Management

- Introduced `MCPToolItem` component for better handling of individual tool options, including selection, deferral, and programmatic invocation.
- Added `useMCPToolOptions` hook to manage tool options state, enabling deferred loading and programmatic calling for tools.
- Updated `MCPTool` component to integrate new tool options management, improving user interaction with tool selection and settings.
- Enhanced localization support for new tool options in translation files.

This update streamlines the management of MCP tools, allowing for more flexible configurations and improved user experience.

* feat: MCP Tool UI for Programmatic Tools

- Added support for programmatic tools in the MCPTool and MCPToolItem components, allowing for conditional rendering based on the availability of programmatic capabilities.
- Updated the useAgentCapabilities hook to include programmaticToolsEnabled, enhancing the capability checks for agents.
- Enhanced unit tests for useAgentCapabilities to validate the new programmatic tools functionality.
- Improved localization for programmatic tool descriptions, ensuring clarity in user interactions.

This update improves the flexibility and usability of the MCP Tool, enabling users to leverage programmatic tools effectively.

* fix: Update localization for MCP Tool UI

- Removed outdated descriptions for programmatic tool interactions in the translation file.
- Enhanced clarity in user-facing text for tool options, ensuring accurate representation of functionality.

This update improves the user experience by providing clearer instructions and descriptions for programmatic tools in the MCP Tool UI.

* chore: ESLint fix

* feat: Add unit tests for useMCPToolOptions hook

- Introduced comprehensive tests for the useMCPToolOptions hook, covering functionalities such as tool deferral and programmatic calling.
- Implemented tests for toggling tool options, ensuring correct state management and preservation of existing configurations.
- Enhanced mock implementations for useFormContext and useWatch to facilitate testing scenarios.

This update improves test coverage and reliability for the MCP Tool options management, ensuring robust validation of expected behaviors.

* fix: Adjust gap spacing in MCPToolItem component

- Updated the gap spacing in the MCPToolItem component from 1 to 1.5 for improved layout consistency.
- This change enhances the visual alignment of icons and text within the component, contributing to a better user interface experience.

* fix: Comment out programmatic tools in default agent capabilities

- Commented out the inclusion of programmatic_tools in the defaultAgentCapabilities array, as it requires the latest Code Interpreter API.
- This change ensures compatibility and prevents potential issues until the necessary API updates are integrated.
2026-02-02 14:37:17 +01:00
Danny Avila
40c5804ed6
🗑️ chore: Remove Dev Artifacts for Deferred Tools Capability (#11601)
* chore: remove TOOL_CLASSIFICATION_AGENT_IDS env var blocking deferred tools

The TOOL_CLASSIFICATION_AGENT_IDS environment variable was gating
Tool Search creation even when agents had deferred tools configured
via the UI (agent tool_options). This caused agents with all MCP
tools set to defer_loading to have no tools available, since the
Tool Search tool wasn't being created.

- Remove isAgentAllowedForClassification function and its usage
- Remove early return that blocked classification features
- Update JSDoc comments to reflect current behavior
- Remove related tests from classification.spec.ts

Agent-level deferred_tools configuration now works correctly
without requiring env var configuration.

* chore: streamline classification tests and remove unused functions

- Removed deprecated tests related to environment variable configurations for tool classification.
- Simplified the classification.spec.ts file by retaining only relevant tests for the current functionality.
- Updated imports and adjusted test cases to reflect the changes in the classification module.
- Enhanced clarity in the classification utility functions by removing unnecessary comments and code.

* refactor: update ToolService to use AgentConstants for tool identification

- Replaced direct references to Constants with AgentConstants in ToolService.js for better consistency and maintainability.
- Enhanced logging in loadToolsForExecution and initializeClient to include toolRegistry size, improving debugging capabilities.
- Updated import statements in run.ts to include Constants, ensuring proper tool name checks during execution.

* chore: reorganize imports and enhance classification tests

- Updated import statements in classification.spec.ts for better clarity and organization.
- Reintroduced the getServerNameFromTool function to improve tool classification logic.
- Removed unused imports and functions to streamline the test file, enhancing maintainability.

* feat: enhance tool registry creation with additional properties

- Added toolType property to tool definitions in buildToolRegistryFromAgentOptions for improved classification.
- Included serverName assignment in tool definitions to enhance tool identification and management.
2026-02-01 22:33:12 -05:00
Danny Avila
9a38af5875
📉 feat: Add Token Usage Tracking for Agents API Routes (#11600)
* feat: Implement token usage tracking for OpenAI and Responses controllers

- Added functionality to record token usage against user balances in OpenAIChatCompletionController and createResponse functions.
- Introduced new utility functions for managing token spending and structured token usage.
- Enhanced error handling for token recording to improve logging and debugging capabilities.
- Updated imports to include new usage tracking methods and configurations.

* test: Add unit tests for recordCollectedUsage function in usage.spec.ts

- Introduced comprehensive tests for the recordCollectedUsage function, covering various scenarios including handling empty and null collectedUsage, single and multiple usage entries, and sequential and parallel execution cases.
- Enhanced token handling tests to ensure correct calculations for both OpenAI and Anthropic formats, including cache token management.
- Improved overall test coverage for usage tracking functionality, ensuring robust validation of expected behaviors and outcomes.

* test: Add unit tests for OpenAI and Responses API controllers

- Introduced comprehensive unit tests for the OpenAIChatCompletionController and createResponse functions, focusing on the correct invocation of recordCollectedUsage for token spending.
- Enhanced tests to validate the passing of balance and transactions configuration to the recordCollectedUsage function.
- Ensured proper dependency injection of spendTokens and spendStructuredTokens in the usage recording process.
- Improved overall test coverage for token usage tracking, ensuring robust validation of expected behaviors and outcomes.
2026-02-01 21:36:51 -05:00
Danny Avila
d13037881a
🔐 fix: MCP OAuth Tool Discovery and Event Emission (#11599)
* fix: MCP OAuth tool discovery and event emission in event-driven mode

- Add discoverServerTools method to MCPManager for tool discovery when OAuth is required
- Fix OAuth event emission to send both ON_RUN_STEP and ON_RUN_STEP_DELTA events
- Fix hasSubscriber flag reset in GenerationJobManager for proper event buffering
- Add ToolDiscoveryOptions and ToolDiscoveryResult types
- Update reinitMCPServer to use new discovery method and propagate OAuth URLs

* refactor: Update ToolService and MCP modules for improved functionality

- Reintroduced Constants in ToolService for better reference management.
- Enhanced loadToolDefinitionsWrapper to handle both response and streamId scenarios.
- Updated MCP module to correct type definitions for oauthStart parameter.
- Improved MCPConnectionFactory to ensure proper disconnection handling during tool discovery.
- Adjusted tests to reflect changes in mock implementations and ensure accurate behavior during OAuth handling.

* fix: Refine OAuth handling in MCPConnectionFactory and related tests

- Updated the OAuth URL assignment logic in reinitMCPServer to prevent overwriting existing URLs.
- Enhanced error logging to provide clearer messages when tool discovery fails.
- Adjusted tests to reflect changes in OAuth handling, ensuring accurate detection of OAuth requirements without generating URLs in discovery mode.

* refactor: Clean up OAuth URL assignment in reinitMCPServer

- Removed redundant OAuth URL assignment logic in the reinitMCPServer function to streamline the tool discovery process.
- Enhanced error logging for tool discovery failures, improving clarity in debugging and monitoring.

* fix: Update response handling in ToolService for event-driven mode

- Changed the condition in loadToolDefinitionsWrapper to check for writableEnded instead of headersSent, ensuring proper event emission when the response is still writable.
- This adjustment enhances the reliability of event handling during tool execution, particularly in streaming scenarios.
2026-02-01 19:37:04 -05:00
Danny Avila
5af1342dbb
🦥 refactor: Event-Driven Lazy Tool Loading (#11588)
* refactor: json schema tools with lazy loading

- Added LocalToolExecutor class for lazy loading and caching of tools during execution.
- Introduced ToolExecutionContext and ToolExecutor interfaces for better type management.
- Created utility functions to generate tool proxies with JSON schema support.
- Added ExtendedJsonSchema type for enhanced schema definitions.
- Updated existing toolkits to utilize the new schema and executor functionalities.
- Introduced a comprehensive tool definitions registry for managing various tool schemas.

chore: update @librechat/agents to version 3.1.2

refactor: enhance tool loading optimization and classification

- Improved the loadAgentToolsOptimized function to utilize a proxy pattern for all tools, enabling deferred execution and reducing overhead.
- Introduced caching for tool instances and refined tool classification logic to streamline tool management.
- Updated the handling of MCP tools to improve logging and error reporting for missing tools in the cache.
- Enhanced the structure of tool definitions to support better classification and integration with existing tools.

refactor: modularize tool loading and enhance optimization

- Moved the loadAgentToolsOptimized function to a new service file for better organization and maintainability.
- Updated the ToolService to utilize the new service for optimized tool loading, improving code clarity.
- Removed legacy tool loading methods and streamlined the tool loading process to enhance performance and reduce complexity.
- Introduced feature flag handling for optimized tool loading, allowing for easier toggling of this functionality.

refactor: replace loadAgentToolsWithFlag with loadAgentTools in tool loader

refactor: enhance MCP tool loading with proxy creation and classification

refactor: optimize MCP tool loading by grouping tools by server

- Introduced a Map to group cached tools by server name, improving the organization of tool data.
- Updated the createMCPProxyTool function to accept server name directly, enhancing clarity.
- Refactored the logic for handling MCP tools, streamlining the process of creating proxy tools for classification.

refactor: enhance MCP tool loading and proxy creation

- Added functionality to retrieve MCP server tools and reinitialize servers if necessary, improving tool availability.
- Updated the tool loading logic to utilize a Map for organizing tools by server, enhancing clarity and performance.
- Refactored the createToolProxy function to ensure a default response format, streamlining tool creation.

refactor: update createToolProxy to ensure consistent response format

- Modified the createToolProxy function to await the executor's execution and validate the result format.
- Ensured that the function returns a default response structure when the result is not an array of two elements, enhancing reliability in tool proxy creation.

refactor: ToolExecutionContext with toolCall property

- Added toolCall property to ToolExecutionContext interface for improved context handling during tool execution.
- Updated LocalToolExecutor to include toolCall in the runnable configuration, allowing for more flexible tool invocation.
- Modified createToolProxy to pass toolCall from the configuration, ensuring consistent context across tool executions.

refactor: enhance event-driven tool execution and logging

- Introduced ToolExecuteOptions for improved handling of event-driven tool execution, allowing for parallel execution of tool calls.
- Updated getDefaultHandlers to include support for ON_TOOL_EXECUTE events, enhancing the flexibility of tool invocation.
- Added detailed logging in LocalToolExecutor to track tool loading and execution metrics, improving observability and debugging capabilities.
- Refactored initializeClient to integrate event-driven tool loading, ensuring compatibility with the new execution model.

chore: update @librechat/agents to version 3.1.21

refactor: remove legacy tool loading and executor components

- Eliminated the loadAgentToolsWithFlag function, simplifying the tool loading process by directly using loadAgentTools.
- Removed the LocalToolExecutor and related executor components to streamline the tool execution architecture.
- Updated ToolService and related files to reflect the removal of deprecated features, enhancing code clarity and maintainability.

refactor: enhance tool classification and definitions handling

- Updated the loadAgentTools function to return toolDefinitions alongside toolRegistry, improving the structure of tool data returned to clients.
- Removed the convertRegistryToDefinitions function from the initialize.js file, simplifying the initialization process.
- Adjusted the buildToolClassification function to ensure toolDefinitions are built and returned simultaneously with the toolRegistry, enhancing efficiency in tool management.
- Updated type definitions in initialize.ts to include toolDefinitions, ensuring consistency across the codebase.

refactor: implement event-driven tool execution handler

- Introduced createToolExecuteHandler function to streamline the handling of ON_TOOL_EXECUTE events, allowing for parallel execution of tool calls.
- Updated getDefaultHandlers to utilize the new handler, simplifying the event-driven architecture.
- Added handlers.ts file to encapsulate tool execution logic, improving code organization and maintainability.
- Enhanced OpenAI handlers to integrate the new tool execution capabilities, ensuring consistent event handling across the application.

refactor: integrate event-driven tool execution options

- Added toolExecuteOptions to support event-driven tool execution in OpenAI and responses controllers, enhancing flexibility in tool handling.
- Updated handlers to utilize createToolExecuteHandler, allowing for streamlined execution of tools during agent interactions.
- Refactored service dependencies to include toolExecuteOptions, ensuring consistent integration across the application.

refactor: enhance tool loading with definitionsOnly parameter

- Updated createToolLoader and loadAgentTools functions to include a definitionsOnly parameter, allowing for the retrieval of only serializable tool definitions in event-driven mode.
- Adjusted related interfaces and documentation to reflect the new parameter, improving clarity and flexibility in tool management.
- Ensured compatibility across various components by integrating the definitionsOnly option in the initialization process.

refactor: improve agent tool presence check in initialization

- Added a check for tool presence using a new hasAgentTools variable, which evaluates both structuredTools and toolDefinitions.
- Updated the conditional logic in the agent initialization process to utilize the hasAgentTools variable, enhancing clarity and maintainability in tool management.

refactor: enhance agent tool extraction to support tool definitions

- Updated the extractMCPServers function to handle both tool instances and serializable tool definitions, improving flexibility in agent tool management.
- Added a new property toolDefinitions to the AgentWithTools type for better integration of event-driven mode.
- Enhanced documentation to clarify the function's capabilities in extracting unique MCP server names from both tools and tool definitions.

refactor: enhance tool classification and registry building

- Added serverName property to ToolDefinition for improved tool identification.
- Introduced buildToolRegistry function to streamline the creation of tool registries based on MCP tool definitions and agent options.
- Updated buildToolClassification to utilize the new registry building logic, ensuring basic definitions are returned even when advanced classification features are not allowed.
- Enhanced documentation and logging for clarity in tool classification processes.

refactor: update @librechat/agents dependency to version 3.1.22

fix: expose loadTools function in ToolService

- Added loadTools function to the exported module in ToolService.js, enhancing the accessibility of tool loading functionality.

chore: remove configurable options from tool execute options in OpenAI controller

refactor: enhance tool loading mechanism to utilize agent-specific context

chore: update @librechat/agents dependency to version 3.1.23

fix: simplify result handling in createToolExecuteHandler

* refactor: loadToolDefinitions for efficient tool loading in event-driven mode

* refactor: replace legacy tool loading with loadToolsForExecution in OpenAI and responses controllers

- Updated OpenAIChatCompletionController and createResponse functions to utilize loadToolsForExecution for improved tool loading.
- Removed deprecated loadToolsLegacy references, streamlining the tool execution process.
- Enhanced tool loading options to include agent-specific context and configurations.

* refactor: enhance tool loading and execution handling

- Introduced loadActionToolsForExecution function to streamline loading of action tools, improving organization and maintainability.
- Updated loadToolsForExecution to handle both regular and action tools, optimizing the tool loading process.
- Added detailed logging for missing tools in createToolExecuteHandler, enhancing error visibility.
- Refactored tool definitions to normalize action tool names, improving consistency in tool management.

* refactor: enhance built-in tool definitions loading

- Updated loadToolDefinitions to include descriptions and parameters from the tool registry for built-in tools, improving the clarity and usability of tool definitions.
- Integrated getToolDefinition to streamline the retrieval of tool metadata, enhancing the overall tool management process.

* feat: add action tool definitions loading to tool service

- Introduced getActionToolDefinitions function to load action tool definitions based on agent ID and tool names, enhancing the tool loading process.
- Updated loadToolDefinitions to integrate action tool definitions, allowing for better management and retrieval of action-specific tools.
- Added comprehensive tests for action tool definitions to ensure correct loading and parameter handling, improving overall reliability and functionality.

* chore: update @librechat/agents dependency to version 3.1.26

* refactor: add toolEndCallback to handle tool execution results

* fix: tool definitions and execution handling

- Introduced native tools (execute_code, file_search, web_search) to the tool service, allowing for better integration and management of these tools.
- Updated isBuiltInTool function to include native tools in the built-in check, improving tool recognition.
- Added comprehensive tests for loading parameters of native tools, ensuring correct functionality and parameter handling.
- Enhanced tool definitions registry to include new agent tool definitions, streamlining tool retrieval and management.

* refactor: enhance tool loading and execution context

- Added toolRegistry to the context for OpenAIChatCompletionController and createResponse functions, improving tool management.
- Updated loadToolsForExecution to utilize toolRegistry for better integration of programmatic tools and tool search functionalities.
- Enhanced the initialization process to include toolRegistry in agent context, streamlining tool access and configuration.
- Refactored tool classification logic to support event-driven execution, ensuring compatibility with new tool definitions.

* chore: add request duration logging to OpenAI and Responses controllers

- Introduced logging for request start and completion times in OpenAIChatCompletionController and createResponse functions.
- Calculated and logged the duration of each request, enhancing observability and performance tracking.
- Improved debugging capabilities by providing detailed logs for both streaming and non-streaming responses.

* chore: update @librechat/agents dependency to version 3.1.27

* refactor: implement buildToolSet function for tool management

- Introduced buildToolSet function to streamline the creation of tool sets from agent configurations, enhancing tool management across various controllers.
- Updated AgentClient, OpenAIChatCompletionController, and createResponse functions to utilize buildToolSet, improving consistency in tool handling.
- Added comprehensive tests for buildToolSet to ensure correct functionality and edge case handling, enhancing overall reliability.

* refactor: update import paths for ToolExecuteOptions and createToolExecuteHandler

* fix: update GoogleSearch.js description for maximum search results

- Changed the default maximum number of search results from 10 to 5 in the Google Search JSON schema description, ensuring accurate documentation of the expected behavior.

* chore: remove deprecated Browser tool and associated assets

- Deleted the Browser tool definition from manifest.json, which included its name, plugin key, description, and authentication configuration.
- Removed the web-browser.svg asset as it is no longer needed following the removal of the Browser tool.

* fix: ensure tool definitions are valid before processing

- Added a check to verify the existence of tool definitions in the registry before accessing their properties, preventing potential runtime errors.
- Updated the loading logic for built-in tool definitions to ensure that only valid definitions are pushed to the built-in tool definitions array.

* fix: extend ExtendedJsonSchema to support 'null' type and nullable enums

- Updated the ExtendedJsonSchema type to include 'null' as a valid type option.
- Modified the enum property to accept an array of values that can include strings, numbers, booleans, and null, enhancing schema flexibility.

* test: add comprehensive tests for tool definitions loading and registry behavior

- Implemented tests to verify the handling of built-in tools without registry definitions, ensuring they are skipped correctly.
- Added tests to confirm that built-in tools include descriptions and parameters in the registry.
- Enhanced tests for action tools, checking for proper inclusion of metadata and handling of tools without parameters in the registry.

* test: add tests for mixed-type and number enum schema handling

- Introduced tests to validate the parsing of mixed-type enum values, including strings, numbers, booleans, and null.
- Added tests for number enum schema values to ensure correct parsing of numeric inputs, enhancing schema validation coverage.

* fix: update mock implementation for @librechat/agents

- Changed the mock for @librechat/agents to spread the actual module's properties, ensuring that all necessary functionalities are preserved in tests.
- This adjustment enhances the accuracy of the tests by reflecting the real structure of the module.

* fix: change max_results type in GoogleSearch schema from number to integer

- Updated the type of max_results in the Google Search JSON schema to 'integer' for better type accuracy and validation consistency.

* fix: update max_results description and type in GoogleSearch schema

- Changed the type of max_results from 'number' to 'integer' for improved type accuracy.
- Updated the description to reflect the new default maximum number of search results, changing it from 10 to 5.

* refactor: remove unused code and improve tool registry handling

- Eliminated outdated comments and conditional logic related to event-driven mode in the ToolService.
- Enhanced the handling of the tool registry by ensuring it is configurable for better integration during tool execution.

* feat: add definitionsOnly option to buildToolClassification for event-driven mode

- Introduced a new parameter, definitionsOnly, to the BuildToolClassificationParams interface to enable a mode that skips tool instance creation.
- Updated the buildToolClassification function to conditionally add tool definitions without instantiating tools when definitionsOnly is true.
- Modified the loadToolDefinitions function to pass definitionsOnly as true, ensuring compatibility with the new feature.

* test: add unit tests for buildToolClassification with definitionsOnly option

- Implemented tests to verify the behavior of buildToolClassification when definitionsOnly is set to true or false.
- Ensured that tool instances are not created when definitionsOnly is true, while still adding necessary tool definitions.
- Confirmed that loadAuthValues is called appropriately based on the definitionsOnly parameter, enhancing test coverage for this new feature.
2026-02-01 08:50:57 -05:00
Danny Avila
6279ea8dd7
🛸 feat: Remote Agent Access with External API Support (#11503)
* 🪪 feat: Microsoft Graph Access Token Placeholder for MCP Servers (#10867)

* feat: MCP Graph Token env var

* Addressing copilot remarks

* Addressed Copilot review remarks

* Fixed graphtokenservice mock in MCP test suite

* fix: remove unnecessary type check and cast in resolveGraphTokensInRecord

* ci: add Graph Token integration tests in MCPManager

* refactor: update user type definitions to use Partial<IUser> in multiple functions

* test: enhance MCP tests for graph token processing and user placeholder resolution

- Added comprehensive tests to validate the interaction between preProcessGraphTokens and processMCPEnv.
- Ensured correct resolution of graph tokens and user placeholders in various configurations.
- Mocked OIDC utilities to facilitate testing of token extraction and validation.
- Verified that original options remain unchanged after processing.

* chore: import order

* chore: imports

---------

Co-authored-by: Danny Avila <danny@librechat.ai>

* WIP: OpenAI-compatible API for LibreChat agents

- Added OpenAIChatCompletionController for handling chat completions.
- Introduced ListModelsController and GetModelController for listing and retrieving agent details.
- Created routes for OpenAI API endpoints, including /v1/chat/completions and /v1/models.
- Developed event handlers for streaming responses in OpenAI format.
- Implemented request validation and error handling for API interactions.
- Integrated content aggregation and response formatting to align with OpenAI specifications.

This commit establishes a foundational API for interacting with LibreChat agents in a manner compatible with OpenAI's chat completion interface.

* refactor: OpenAI-spec content aggregation for improved performance and clarity

* fix: OpenAI chat completion controller with safe user handling for correct tool loading

* refactor: Remove conversation ID from OpenAI response context and related handlers

* refactor: OpenAI chat completion handling with streaming support

- Introduced a lightweight tracker for streaming responses, allowing for efficient tracking of emitted content and usage metadata.
- Updated the OpenAIChatCompletionController to utilize the new tracker, improving the handling of streaming and non-streaming responses.
- Refactored event handlers to accommodate the new streaming logic, ensuring proper management of tool calls and content aggregation.
- Adjusted response handling to streamline error reporting during streaming sessions.

* WIP: Open Responses API with core service, types, and handlers

- Added Open Responses API module with comprehensive types and enums.
- Implemented core service for processing requests, including validation and input conversion.
- Developed event handlers for streaming responses and non-streaming aggregation.
- Established response building logic and error handling mechanisms.
- Created detailed types for input and output content, ensuring compliance with Open Responses specification.

* feat: Implement response storage and retrieval in Open Responses API

- Added functionality to save user input messages and assistant responses to the database when the `store` flag is set to true.
- Introduced a new endpoint to retrieve stored responses by ID, allowing users to access previous interactions.
- Enhanced the response creation process to include database operations for conversation and message storage.
- Implemented tests to validate the storage and retrieval of responses, ensuring correct behavior for both existing and non-existent response IDs.

* refactor: Open Responses API with additional token tracking and validation

- Added support for tracking cached tokens in response usage, improving token management.
- Updated response structure to include new properties for top log probabilities and detailed usage metrics.
- Enhanced tests to validate the presence and types of new properties in API responses, ensuring compliance with updated specifications.
- Refactored response handling to accommodate new fields and improve overall clarity and performance.

* refactor: Update reasoning event handlers and types for consistency

- Renamed reasoning text events to simplify naming conventions, changing `emitReasoningTextDelta` to `emitReasoningDelta` and `emitReasoningTextDone` to `emitReasoningDone`.
- Updated event types in the API to reflect the new naming, ensuring consistency across the codebase.
- Added `logprobs` property to output events for enhanced tracking of log probabilities.

* feat: Add validation for streaming events in Open Responses API tests

* feat: Implement response.created event in Open Responses API

- Added emitResponseCreated function to emit the response.created event as the first event in the streaming sequence, adhering to the Open Responses specification.
- Updated createResponse function to emit response.created followed by response.in_progress.
- Enhanced tests to validate the order of emitted events, ensuring response.created is triggered before response.in_progress.

* feat: Responses API with attachment event handling

- Introduced `createResponsesToolEndCallback` to handle attachment events in the Responses API, emitting `librechat:attachment` events as per the Open Responses extension specification.
- Updated the `createResponse` function to utilize the new callback for processing tool outputs and emitting attachments during streaming.
- Added helper functions for writing attachment events and defined types for attachment data, ensuring compatibility with the Open Responses protocol.
- Enhanced tests to validate the integration of attachment events within the Responses API workflow.

* WIP: remote agent auth

* fix: Improve loading state handling in AgentApiKeys component

- Updated the rendering logic to conditionally display loading spinner and API keys based on the loading state.
- Removed unnecessary imports and streamlined the component for better readability.

* refactor: Update API key access handling in routes

- Replaced `checkAccess` with `generateCheckAccess` for improved access control.
- Consolidated access checks into a single `checkApiKeyAccess` function, enhancing code readability and maintainability.
- Streamlined route definitions for creating, listing, retrieving, and deleting API keys.

* fix: Add permission handling for REMOTE_AGENT resource type

* feat: Enhance permission handling for REMOTE_AGENT resources

- Updated the deleteAgent and deleteUserAgents functions to handle permissions for both AGENT and REMOTE_AGENT resource types.
- Introduced new functions to enrich REMOTE_AGENT principals and backfill permissions for AGENT owners.
- Modified createAgentHandler and duplicateAgentHandler to grant permissions for REMOTE_AGENT alongside AGENT.
- Added utility functions for retrieving effective permissions for REMOTE_AGENT resources, ensuring consistent access control across the application.

* refactor: Rename and update roles for remote agent access

- Changed role name from API User to Editor in translation files for clarity.
- Updated default editor role ID from REMOTE_AGENT_USER to REMOTE_AGENT_EDITOR in resource configurations.
- Adjusted role localization to reflect the new Editor role.
- Modified access permissions to align with the updated role definitions across the application.

* feat: Introduce remote agent permissions and update access handling

- Added support for REMOTE_AGENTS in permission schemas, including use, create, share, and share_public permissions.
- Updated the interface configuration to include remote agent settings.
- Modified middleware and API key access checks to align with the new remote agent permission structure.
- Enhanced role defaults to incorporate remote agent permissions, ensuring consistent access control across the application.

* refactor: Update AgentApiKeys component and permissions handling

- Refactored the AgentApiKeys component to improve structure and readability, including the introduction of ApiKeysContent for better separation of concerns.
- Updated CreateKeyDialog to accept an onKeyCreated callback, enhancing its functionality.
- Adjusted permission checks in Data component to use REMOTE_AGENTS and USE permissions, aligning with recent permission schema changes.
- Enhanced loading state handling and dialog management for a smoother user experience.

* refactor: Update remote agent access checks in API routes

- Replaced existing access checks with `generateCheckAccess` for remote agents in the API keys and agents routes.
- Introduced specific permission checks for creating, listing, retrieving, and deleting API keys, enhancing access control.
- Improved code structure by consolidating permission handling for remote agents across multiple routes.

* fix: Correct query parameters in ApiKeysContent component

- Updated the useGetAgentApiKeysQuery call to include an object for the enabled parameter, ensuring proper functionality when the component is open.
- This change improves the handling of API key retrieval based on the component's open state.

* feat: Implement remote agents permissions and update API routes

- Added new API route for updating remote agents permissions, enhancing role management capabilities.
- Introduced remote agents permissions handling in the AgentApiKeys component, including a dedicated settings dialog.
- Updated localization files to include new remote agents permission labels for better user experience.
- Refactored data provider to support remote agents permissions updates, ensuring consistent access control across the application.

* feat: Add remote agents permissions to role schema and interface

- Introduced new permissions for REMOTE_AGENTS in the role schema, including USE, CREATE, SHARE, and SHARE_PUBLIC.
- Updated the IRole interface to reflect the new remote agents permissions structure, enhancing role management capabilities.

* feat: Add remote agents settings button to API keys dialog

* feat: Update AgentFooter to include remote agent sharing permissions

- Refactored access checks to incorporate permissions for sharing remote agents.
- Enhanced conditional rendering logic to allow sharing by users with remote agent permissions.
- Improved loading state handling for remote agent permissions, ensuring a smoother user experience.

* refactor: Update API key creation access check and localization strings

- Replaced the access check for creating API keys to use the existing remote agents access check.
- Updated localization strings to correct the descriptions for remote agent permissions, ensuring clarity in user interface.

* fix: resource permission mapping to include remote agents

- Changed the resourceToPermissionMap to use a Partial<Record> for better flexibility.
- Added mapping for REMOTE_AGENT permissions, enhancing the sharing capabilities for remote agents.

* feat: Implement remote access checks for agent models

- Enhanced ListModelsController and GetModelController to include checks for user permissions on remote agents.
- Integrated findAccessibleResources to filter agents based on VIEW permission for REMOTE_AGENT.
- Updated response handling to ensure users can only access agents they have permissions for, improving security and access control.

* fix: Update user parameter type in processUserPlaceholders function

- Changed the user parameter type in the processUserPlaceholders function from Partial<Partial<IUser>> to Partial<IUser> for improved type clarity and consistency.

* refactor: Simplify integration test structure by removing conditional describe

- Replaced conditional describeWithApiKey with a standard describe for all integration tests in responses.spec.js.
- This change enhances test clarity and ensures all tests are executed consistently, regardless of the SKIP_INTEGRATION_TESTS flag.

* test: Update AgentFooter tests to reflect new grant access dialog ID

- Changed test IDs for the grant access dialog in AgentFooter tests to include the resource type, ensuring accurate identification in the test cases.
- This update improves test clarity and aligns with recent changes in the component's implementation.

* test: Enhance integration tests for Open Responses API

- Updated integration tests in responses.spec.js to utilize an authRequest helper for consistent authorization handling across all test cases.
- Introduced a test user and API key creation to improve test setup and ensure proper permission checks for remote agents.
- Added checks for existing access roles and created necessary roles if they do not exist, enhancing test reliability and coverage.

* feat: Extend accessRole schema to include remoteAgent resource type

- Updated the accessRole schema to add 'remoteAgent' to the resourceType enum, enhancing the flexibility of role assignments and permissions management.

* test: refactored test setup to create a minimal Express app for responses routes, enhancing test structure and maintainability.

* test: Enhance abort.spec.js by mocking additional modules for improved test isolation

- Updated the test setup in abort.spec.js to include actual implementations of '@librechat/data-schemas' and '@librechat/api' while maintaining mock functionality.
- This change improves test reliability and ensures that the tests are more representative of the actual module behavior.

* refactor: Update conversation ID generation to use UUID

- Replaced the nanoid with uuidv4 for generating conversation IDs in the createResponse function, enhancing uniqueness and consistency in ID generation.

* test: Add remote agent access roles to AccessRole model tests

- Included additional access roles for remote agents (REMOTE_AGENT_EDITOR, REMOTE_AGENT_OWNER, REMOTE_AGENT_VIEWER) in the AccessRole model tests to ensure comprehensive coverage of role assignments and permissions management.

* chore: Add deletion of user agent API keys in user deletion process

- Updated the user deletion process in UserController and delete-user.js to include the removal of user agent API keys, ensuring comprehensive cleanup of user data upon account deletion.

* test: Add remote agents permissions to permissions.spec.ts

- Enhanced the permissions tests by including comprehensive permission settings for remote agents across various scenarios, ensuring accurate validation of access controls for remote agent roles.

* chore: Update remote agents translations for clarity and consistency

- Removed outdated remote agents translation entries and added revised entries to improve clarity on API key creation and sharing permissions for remote agents. This enhances user understanding of the available functionalities.

* feat: Add indexing and TTL for agent API keys

- Introduced an index on the `key` field for improved query performance.
- Added a TTL index on the `expiresAt` field to enable automatic cleanup of expired API keys, ensuring efficient management of stored keys.

* chore: Update API route documentation for clarity

- Revised comments in the agents route file to clarify the handling of API key authentication.
- Removed outdated endpoint listings to streamline the documentation and focus on current functionality.

---------

Co-authored-by: Max Sanna <max@maxsanna.com>
2026-01-28 17:44:33 -05:00
Max Sanna
dd4bbd38fc
🪪 feat: Microsoft Graph Access Token Placeholder for MCP Servers (#10867)
* feat: MCP Graph Token env var

* Addressing copilot remarks

* Addressed Copilot review remarks

* Fixed graphtokenservice mock in MCP test suite

* fix: remove unnecessary type check and cast in resolveGraphTokensInRecord

* ci: add Graph Token integration tests in MCPManager

* refactor: update user type definitions to use Partial<IUser> in multiple functions

* test: enhance MCP tests for graph token processing and user placeholder resolution

- Added comprehensive tests to validate the interaction between preProcessGraphTokens and processMCPEnv.
- Ensured correct resolution of graph tokens and user placeholders in various configurations.
- Mocked OIDC utilities to facilitate testing of token extraction and validation.
- Verified that original options remain unchanged after processing.

* chore: import order

* chore: imports

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-01-28 17:44:33 -05:00
Danny Avila
ed61b7f967
📦 chore: Bump @librechat/agents to v3.1.0 2026-01-28 17:44:33 -05:00
Dustin Healy
bb220f1af9
👤 feat: AWS Bedrock Custom Inference Profiles (#11308)
* feat: add support for inferenceProfiles mapping

* fix: remove friendly name since api requires actual model id for validation alongside inference profile

* docs: more generic description in docs

* chore: address comments

* chore: update peer dependency versions in package.json

- Bump @aws-sdk/client-bedrock-runtime from ^3.941.0 to ^3.970.0
- Update @librechat/agents from ^3.0.78 to ^3.0.79

* fix: update @librechat/agents dependency to version 3.0.80

* test: add unit tests for inference profile configuration in initializeBedrock function

- Introduced tests to validate the applicationInferenceProfile setting based on model configuration.
- Ensured correct handling of environment variables and fallback scenarios for inference profile ARNs.
- Added cases for empty inferenceProfiles and absence of bedrock config to confirm expected behavior.

* fix: update bedrock endpoint schema reference in config

- Changed the bedrock endpoint reference from baseEndpointSchema to bedrockEndpointSchema for improved clarity and accuracy in configuration.

* test: add unit tests for Bedrock endpoint configuration

- Introduced tests to validate the configuration of Bedrock endpoints with models and inference profiles.
- Added scenarios for both complete and minimal configurations to ensure expected behavior.
- Enhanced coverage for the handling of inference profiles without a models array.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-01-28 17:44:32 -05:00
Danny Avila
75c02a1a18
🗂️ feat: Better Persistence for Code Execution Files Between Sessions (#11362)
* refactor: process code output files for re-use (WIP)

* feat: file attachment handling with additional metadata for downloads

* refactor: Update directory path logic for local file saving based on basePath

* refactor: file attachment handling to support TFile type and improve data merging logic

* feat: thread filtering of code-generated files

- Introduced parentMessageId parameter in addedConvo and initialize functions to enhance thread management.
- Updated related methods to utilize parentMessageId for retrieving messages and filtering code-generated files by conversation threads.
- Enhanced type definitions to include parentMessageId in relevant interfaces for better clarity and usage.

* chore: imports/params ordering

* feat: update file model to use messageId for filtering and processing

- Changed references from 'message' to 'messageId' in file-related methods for consistency.
- Added messageId field to the file schema and updated related types.
- Enhanced file processing logic to accommodate the new messageId structure.

* feat: enhance file retrieval methods to support user-uploaded execute_code files

- Added a new method `getUserCodeFiles` to retrieve user-uploaded execute_code files, excluding code-generated files.
- Updated existing file retrieval methods to improve filtering logic and handle edge cases.
- Enhanced thread data extraction to collect both message IDs and file IDs efficiently.
- Integrated `getUserCodeFiles` into relevant endpoints for better file management in conversations.

* chore: update @librechat/agents package version to 3.0.78 in package-lock.json and related package.json files

* refactor: file processing and retrieval logic

- Added a fallback mechanism for download URLs when files exceed size limits or cannot be processed locally.
- Implemented a deduplication strategy for code-generated files based on conversationId and filename to optimize storage.
- Updated file retrieval methods to ensure proper filtering by messageIds, preventing orphaned files from being included.
- Introduced comprehensive tests for new thread data extraction functionality, covering edge cases and performance considerations.

* fix: improve file retrieval tests and handling of optional properties

- Updated tests to safely access optional properties using non-null assertions.
- Modified test descriptions for clarity regarding the exclusion of execute_code files.
- Ensured that the retrieval logic correctly reflects the expected outcomes for file queries.

* test: add comprehensive unit tests for processCodeOutput functionality

- Introduced a new test suite for the processCodeOutput function, covering various scenarios including file retrieval, creation, and processing for both image and non-image files.
- Implemented mocks for dependencies such as axios, logger, and file models to isolate tests and ensure reliable outcomes.
- Validated behavior for existing files, new file creation, and error handling, including size limits and fallback mechanisms.
- Enhanced test coverage for metadata handling and usage increment logic, ensuring robust verification of file processing outcomes.

* test: enhance file size limit enforcement in processCodeOutput tests

- Introduced a configurable file size limit for tests to improve flexibility and coverage.
- Mocked the `librechat-data-provider` to allow dynamic adjustment of file size limits during tests.
- Updated the file size limit enforcement test to validate behavior when files exceed specified limits, ensuring proper fallback to download URLs.
- Reset file size limit after tests to maintain isolation for subsequent test cases.
2026-01-28 17:44:32 -05:00
Danny Avila
c18dc0d894
🌏 fix: Update UI text for com_ui_analyzing 2026-01-28 17:44:31 -05:00
Danny Avila
b6af884dd2
🔐 feat: Admin Auth. Routes with Secure Cross-Origin Token Exchange (#11297)
* feat: implement admin authentication with OpenID & Local Auth proxy support

* feat: implement admin OAuth exchange flow with caching support

- Added caching for admin OAuth exchange codes with a short TTL.
- Introduced new endpoints for generating and exchanging admin OAuth codes.
- Updated relevant controllers and routes to handle admin panel redirects and token exchanges.
- Enhanced logging for better traceability of OAuth operations.

* refactor: enhance OpenID strategy mock to support multiple verify callbacks

- Updated the OpenID strategy mock to store and retrieve verify callbacks by strategy name.
- Improved backward compatibility by maintaining a method to get the last registered callback.
- Adjusted tests to utilize the new callback retrieval methods, ensuring clarity in the verification process for the 'openid' strategy.

* refactor: reorder import statements for better organization

* refactor: admin OAuth flow with improved URL handling and validation

- Added a utility function to retrieve the admin panel URL, defaulting to a local development URL if not set in the environment.
- Updated the OAuth exchange endpoint to include validation for the authorization code format.
- Refactored the admin panel redirect logic to handle URL parsing more robustly, ensuring accurate origin comparisons.
- Removed redundant local URL definitions from the codebase for better maintainability.

* refactor: remove deprecated requireAdmin middleware and migrate to TypeScript

- Deleted the old requireAdmin middleware file and its references in the middleware index.
- Introduced a new TypeScript version of the requireAdmin middleware with enhanced error handling and logging.
- Updated routes to utilize the new requireAdmin middleware, ensuring consistent access control for admin routes.

* feat: add requireAdmin middleware for admin role verification

- Introduced requireAdmin middleware to enforce admin role checks for authenticated users.
- Implemented comprehensive error handling and logging for unauthorized access attempts.
- Added unit tests to validate middleware functionality and ensure proper behavior for different user roles.
- Updated middleware index to include the new requireAdmin export.
2026-01-28 17:44:31 -05:00
Danny Avila
3d98194a99
🦥 feat: Add Deferred Tools as Agents Capability (#11295) 2026-01-28 17:44:30 -05:00
Danny Avila
7c9c7e530b
⏲️ feat: Defer Loading MCP Tools (#11270)
* WIP: code ptc

* refactor: tool classification and calling logic

* 🔧 fix: Update @librechat/agents dependency to version 3.0.68

* chore: import order and correct renamed tool name for tool search

* refactor: streamline tool classification logic for local and programmatic tools

* feat: add per-tool configuration options for agents, including deferred loading and allowed callers

- Introduced `tool_options` in agent forms to manage tool behavior.
- Updated tool classification logic to prioritize agent-level configurations.
- Enhanced UI components to support tool deferral functionality.
- Added localization strings for new tool options and actions.

* feat: enhance agent schema with per-tool options for configuration

- Added `tool_options` schema to support per-tool configurations, including `defer_loading` and `allowed_callers`.
- Updated agent data model to incorporate new tool options, ensuring flexibility in tool behavior management.
- Modified type definitions to reflect the new `tool_options` structure for agents.

* feat: add tool_options parameter to loadTools and initializeAgent for enhanced agent configuration

* chore: update @librechat/agents dependency to version 3.0.71 and enhance agent tool loading logic

- Updated the @librechat/agents package to version 3.0.71 across multiple files.
- Added support for handling deferred loading of tools in agent initialization and execution processes.
- Improved the extraction of discovered tools from message history to optimize tool loading behavior.

* chore: update @librechat/agents dependency to version 3.0.72

* chore: update @librechat/agents dependency to version 3.0.75

* refactor: simplify tool defer loading logic in MCPTool component

- Removed local state management for deferred tools, relying on form state instead.
- Updated related functions to directly use form values for checking and toggling defer loading.
- Cleaned up code by eliminating unnecessary optimistic updates and local state dependencies.

* chore: remove deprecated localization strings for tool deferral in translation.json

- Eliminated unused strings related to deferred loading descriptions in the English translation file.
- Streamlined localization to reflect recent changes in tool loading logic.

* refactor: improve tool defer loading handling in MCPTool component

- Enhanced the logic for managing deferred loading of tools by simplifying the update process for tool options.
- Ensured that the state reflects the correct loading behavior based on the new deferred loading conditions.
- Cleaned up the code to remove unnecessary complexity in handling tool options.

* refactor: update agent mocks in callbacks test to use actual implementations

- Modified the agent mocks in the callbacks test to include actual implementations from the @librechat/agents module.
- This change enhances the accuracy of the tests by ensuring they reflect the real behavior of the agent functions.
2026-01-28 17:44:30 -05:00
Danny Avila
efbc088642
📦 chore: Bump chart version to 1.9.7
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Publish `@librechat/client` to NPM / build-and-publish (push) Has been cancelled
Publish `librechat-data-provider` to NPM / build (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / build-and-publish (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2026-01-28 17:44:07 -05:00
Danny Avila
6960bd3cc3
v0.8.2 (#11547)
* chore: Update version from v0.8.2-rc3 to v0.8.2 across multiple files

* chore: Update package versions for @librechat/api to 1.7.22 and @librechat/client to 0.4.51

* chore: Bump version of librechat-data-provider from 0.8.230 to 0.8.231

* chore: Bump version of @librechat/data-schemas to 0.0.35

* chore: bump config version to 1.3.2

* chore: bump config version to 1.3.3

* docs: Update README to include new features for resumable streams and enhanced platform capabilities
2026-01-28 17:18:33 -05:00
github-actions[bot]
e162bd13ef
🌍 i18n: Update translation.json with latest translations (#11552)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-28 12:42:06 -05:00
Dustin Healy
13cea97c9b
🔗 feat: More Accessible Link Behaviors and Minor UI Improvements (#11549)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* fix: accessibility issues with links and link descriptions + minor ui tweaks

* fix: link accessibility in archived chats table

* fix: remove open in new tab behavior for other footer links

* chore: remove unused translation string

* style: formatting

* refactor: rename searchState to searchStore for clarity

* chore: Reorganize imports and state variables in SharedLinks

* chore: re-organize imports/hooks

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-01-28 12:15:43 -05:00
Danny Avila
95a234fb83
🧹 refactor: Remove context-1m header filtering from Vertex Endpoint (#11551)
* Removed the filtering logic for 'context-1m' headers in the filterVertexHeaders function, streamlining header processing.
2026-01-28 11:59:20 -05:00
github-actions[bot]
ddf85b3470
🌍 i18n: Update translation.json with latest translations (#11546)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-28 09:47:24 -05:00
Danny Avila
25fe4069a4
📦 chore: Bump @modelcontextprotocol/sdk to v1.25.3 (#11545) 2026-01-28 09:10:40 -05:00
Max Sanna
8c6277a281
🍪 refactor: Secure Cookie Setting for Localhost OAuth Sessions (#11518)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
* fix: Added check for secure cookies when running in production mode on localhost

* Applied copilot's suggestions
2026-01-26 11:28:50 -05:00
mohamed magdy
5310529ee0
🪤 refactor: Reset Interaction State When Mouse Leaves Conversation Item (#11402)
* fix: reset hasInteracted state when mouse leaves conversation item

* fix lint

* refactor: update state setter types in ConvoOptions and DeleteButton components for Fixing types issue

* fix: Add blur handler and focus-aware popover close for Conversation a11y
2026-01-26 10:07:27 -05:00
Danny Avila
0b4deac953
🧩 fix: Missing Memory Agent Assignment for Matching IDs (#11514)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* fix: `useMemory` in AgentClient for PrelimAgent Assignment

* Updated the useMemory method in AgentClient to handle prelimAgent assignment based on memory configuration.
* Added logic to return early if prelimAgent is undefined, improving flow control.
* Introduced comprehensive unit tests to validate behavior for various memory configurations, including scenarios for matching and differing agent IDs, as well as handling of ephemeral agents.
* Mocked necessary dependencies in tests to ensure isolation and reliability of the new functionality.

* fix: Update temperature handling for Bedrock and Anthropic providers in memory management

* fix: Replace hardcoded provider strings with constants in memory agent tests

* fix: Replace hardcoded provider string with constant in allowedProviders for AgentClient

* fix: memory agent tests to use actual Providers and GraphEvents constants
2026-01-25 12:08:52 -05:00
Andrei Blizorukov
6a49861861
🔧 refactor: Offset when deleting documents during MeiliSearch cleanup (#11488)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* 🔧 fix: adjust offset when deleting documents during MeiliSearch cleanup

This could cause the cleanup process to skip documents in subsequent batches, potentially leaving orphaned entries in MeiliSearch that no longer exist in MongoDB

* 🔧 fix: checking results count instead of total
2026-01-24 11:11:29 -05:00
Danny Avila
8be0047a80
🔒 fix: Access Check for User-Specific Job Metadata in Streaming Endpoint (#11487)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* Implemented a check to ensure that only the user associated with a job can access its chat stream, returning a 403 Unauthorized response for mismatched user IDs.
* This enhancement improves security by preventing unauthorized access to user-specific job data.
2026-01-23 09:06:48 -05:00
Danny Avila
ee44c6344d
🔒 feat: Sanitize Placeholders in User-provided MCP Server Config (#11486)
* 🔒 feat: Sanitize Placeholders in User-provider MCP Server Config Headers

* Implemented sanitization for dangerous credential placeholders in headers to prevent credential exfiltration when MCP servers are shared.
* Added tests to verify that dangerous placeholders are stripped from headers during both add and update operations, while safe placeholders are preserved.
* Refactored ServerConfigsDB to include a new sanitizeCredentialPlaceholders function for header processing.

* ci: tests for preserving credential placeholders in YAML configs

* Introduced new tests to ensure that LIBRECHAT_OPENID and LIBRECHAT_USER placeholders are preserved in admin configuration headers when added to the cache.
* Validated that the expected placeholders remain intact during retrieval, enhancing the integrity of configuration management.
2026-01-23 09:06:29 -05:00
Dustin Healy
18a0e8a8b0
🎯 feat: High Contrast Focus Outlines for Account Settings Menu Items (#11451)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Publish `@librechat/data-schemas` to NPM / build-and-publish (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* fix: focus outlines for Settings popup menu items

* refactor: tighten scope onto account settings only
2026-01-22 19:38:31 -05:00
Dustin Healy
0cf7bb2d0e
🔊 fix: Conversation Search Result Announcement (#11449)
* fix: search result announcements

* chore: address Copilot comments

* chore: no nested ternary statements allowed

* chore: memoize results announcement
2026-01-22 19:36:46 -05:00
Danny Avila
cfd5c793a9
🧑‍🏫 fix: Multi-Agent Instructions Handling (#11484)
* 🧑‍🏫 fix: Multi-Agent Instructions Handling

* Refactored AgentClient to streamline the process of building messages by applying shared run context and agent-specific instructions.
* Introduced new utility functions in context.ts for extracting MCP server names, fetching MCP instructions, and building combined agent instructions.
* Updated the Agent type to make instructions optional, allowing for more flexible agent configurations.
* Improved the handling of context application to agents, ensuring that all relevant information is correctly integrated before execution.

* chore: Update EphemeralAgent Type in Context

* Enhanced the context.ts file by importing the TEphemeralAgent type from librechat-data-provider.
* Updated the applyContextToAgent function to use TEphemeralAgent for the ephemeralAgent parameter, improving type safety and clarity in agent context handling.

* ci: Update Agent Instructions in Tests for Clarity

* Revised test assertions in AgentClient to clarify the source of agent instructions, ensuring they are explicitly referenced as coming from agent configuration rather than build options.
* Updated comments in tests to enhance understanding of the expected behavior regarding base agent instructions and their handling in various scenarios.

* ci: Unit Tests for Agent Context Utilities

* Introduced comprehensive unit tests for agent context utilities, including functions for extracting MCP servers, fetching MCP instructions, and building agent instructions.
* Enhanced test coverage to ensure correct behavior across various scenarios, including handling of empty tools, mixed tool types, and error cases.
* Improved type definitions for AgentWithTools to clarify the structure and requirements for agent context operations.
2026-01-22 19:36:06 -05:00
Danny Avila
7204e74390
📦 chore: bump lodash version to ^4.17.23 (#11476)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* chore: bump lodash version to ^4.17.23 across all packages

* chore: add diff module version 4.0.4 and remove outdated dependency
2026-01-22 09:46:08 -05:00
Danny Avila
7f59a1815c
🔧 fix: Agent Deletion Logic to Update User Favorites (#11466)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🔧 fix: Agent Deletion Logic to Update User Favorites

* Added functionality to remove agents from user favorites when an agent is deleted.
* Implemented updates in the deleteAgent and deleteUserAgents functions to ensure user favorites are correctly modified.
* Added comprehensive tests to verify that agents are removed from user favorites across multiple scenarios, ensuring data integrity and user experience.

* 🔧 test: Enhance deleteUserAgents Functionality Tests

* Added comprehensive tests for the deleteUserAgents function to ensure it correctly removes agents from user favorites across various scenarios.
* Verified that user favorites are updated appropriately when agents are deleted, including cases where agents are shared among multiple users and when users have no favorites.
* Ensured that existing agents remain unaffected when no agents are associated with the author being deleted.

* 🔧 refactor: Remove Deprecated getListAgents Functionality

* Removed the deprecated getListAgents function from the Agent model, encouraging the use of getListAgentsByAccess for ACL-aware agent listing.
* Updated related tests in Agent.spec.js to eliminate references to getListAgents, ensuring code cleanliness and maintainability.
* Adjusted imports and exports accordingly to reflect the removal of the deprecated function.
2026-01-21 15:01:04 -05:00
github-actions[bot]
74cc001e40
🌍 i18n: Update translation.json with latest translations (#11465)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-21 14:07:07 -05:00
Shahryar Tayeb
639a60cf19
📬 fix: Email Verification Handling in Create-User Command (#11408)
* fix:  email verification handling in create-user command

* set emailVerified to true when the input is 'y'

* normalize email verification input and set emailVerified to true by default
2026-01-21 14:03:49 -05:00
kenzaelk98
191cd3983c
🛂 fix: Encode Non-ASCII Characters in MCP Server Headers (#11432)
Fixes ByteString conversion errors when user names contain Unicode
characters > 255 (e.g., ć, đ, ł, š, ž) in MCP server headers.

- Add encodeHeaderValue() function to Base64 encode extended Unicode
- Update processUserPlaceholders() to encode name/username/email in headers
- Update processSingleValue() with isHeader parameter
- Apply encoding in processMCPEnv() and resolveHeaders()

Tested locally with MCP server using user name 'Đorđe' (contains đ=272).
Headers are correctly encoded as base64, preventing ByteString errors.

Co-authored-by: kenzaelk98 <kenzaelk98@leoninestudios.com>
Co-authored-by: heptapod <164861708+leondape@users.noreply.github.com>
2026-01-21 14:00:25 -05:00
Danny Avila
11210d8b98
🏁 fix: Message Race Condition if Cancelled Early (#11462)
* 🔧 fix: Prevent race conditions in message saving during abort scenarios

* Added logic to save partial responses before returning from the abort endpoint to ensure parentMessageId exists in the database.
* Updated the ResumableAgentController to save response messages before emitting final events, preventing orphaned parentMessageIds.
* Enhanced handling of unfinished responses to improve stability and data integrity in agent interactions.

* 🔧 fix: logging and job replacement handling in ResumableAgentController

* Added detailed logging for job creation and final event emissions to improve traceability.
* Implemented logic to check for job replacement before emitting events, preventing stale requests from affecting newer jobs.
* Updated abort handling to log additional context about the abort result, enhancing debugging capabilities.

* refactor: abort handling and token spending logic in AgentStream

* Added authorization check for abort attempts to prevent unauthorized access.
* Improved response message saving logic to ensure valid message IDs are stored.
* Implemented token spending for aborted requests to prevent double-spending across parallel agents.
* Enhanced logging for better traceability of token spending operations during abort scenarios.

* refactor: remove TODO comments for token spending in abort handling

* Removed outdated TODO comments regarding token spending for aborted requests in the abort endpoint.
* This change streamlines the code and clarifies the current implementation status.

*  test: Add comprehensive tests for job replacement and abort handling

* Introduced unit tests for job replacement detection in ResumableAgentController, covering job creation timestamp tracking, stale job detection, and response message saving order.
* Added tests for the agent abort endpoint, ensuring proper authorization checks, early abort handling, and partial response saving.
* Enhanced logging and error handling in tests to improve traceability and robustness of the abort functionality.
2026-01-21 13:57:12 -05:00
Dustin Healy
dea246934e
😶‍🌫 feat: Better Blur on Collapsed Chat Input (#11464) 2026-01-21 13:54:20 -05:00
Dustin Healy
9d612715a5
️ feat: Accessible Model Selection Icons and Announcements (#11454)
* feat: more accessible model selection ui and announcements

* chore: formatting
2026-01-21 13:53:10 -05:00
Dustin Healy
e2ec3f18c9
↕️ fix: Add aria-expanded Attribute to ConvoOptions (#11452) 2026-01-21 13:52:08 -05:00
Dustin Healy
12ec64b988
🔖 fix: Announce Bookmark Selection State (#11450)
* fix: bookmarks announce selection state

* chore: address Copilot comments
2026-01-21 13:49:50 -05:00
Dustin Healy
828c2b2048
📏 fix: Dropdown Menu Z-Index Adjustments (#11441) 2026-01-21 13:46:02 -05:00
Dustin Healy
e608c652e5
✂️ fix: Clipped Focus Outlines in Conversation Panel (#11438)
* fix: focus outline clipping in Conversations panel

* chore: address Copilot comments
2026-01-21 13:44:20 -05:00
github-actions[bot]
24e182d20e
🌍 i18n: Update translation.json with latest translations (#11439)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-21 09:25:02 -05:00
Danny Avila
c5113a75a0
🔧 fix: Add hasAgentAccess to dependencies in useNewConvo hook (#11427)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* Updated the dependency array in the useNewConvo hook to include hasAgentAccess for improved state management and functionality.
2026-01-20 14:45:27 -05:00
Dustin Healy
f09eec8462
feat: Zod Email Validation at Login (#11434) 2026-01-20 14:45:07 -05:00
Danny Avila
36c5a88c4e
💰 fix: Multi-Agent Token Spending & Prevent Double-Spend (#11433)
* 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.
2026-01-20 14:43:19 -05:00
Dustin Healy
32e6f3b8e5
📢 fix: Alert for Agent Builder Name Invalidation (#11430) 2026-01-20 14:41:28 -05:00
Danny Avila
e509ba5be0
🪄 fix: Code Block handling in Artifact Updates (#11417)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* Improved detection of code blocks to support both language identifiers and plain code fences.
* Updated tests to cover various scenarios, including edge cases with different language identifiers and multiline content.
* Ensured proper handling of code blocks with trailing whitespace and complex syntax.
2026-01-20 08:45:43 -05:00
Andrei Blizorukov
4a1d2b0d94
📊 fix: MeiliSearch Sync Threshold & Document Count Accuracy (#11406)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🔧 fix: meilisearch incorrect count of total documents & performance improvement

Temporary documents were counted & removed 2 redundant heavy calls to the database, use known information instead

🔧 fix: respect MEILI_SYNC_THRESHOLD value

Do not sync with meili if threshold was not reached

* refactor: reformat lint

* fix: forces update if meili index settingsUpdated
2026-01-19 16:32:57 -05:00
Dustin Healy
9134471143
🔎 fix: Focus Credential Inputs in Agent Tools (#11394)
* fix: focus transfer on add tool in Add Tools dialog

* fix: focus transfer to inputs on add mcp server in Add MCP Server Tools dialog

* chore: add comments disabling ESLint autfocus and documenting the purpose of the override

* chore: remove stray newline
2026-01-19 12:02:24 -05:00
Danny Avila
277fbd10cb
🔒 fix: Session Expiry Management for OpenID/SAML (#11407)
- Added session cookie options for OpenID and SAML configurations, including maxAge and secure attributes based on the environment.
    - Introduced DEFAULT_SESSION_EXPIRY from data-schemas for better session handling.
2026-01-19 12:01:43 -05:00
Danny Avila
b70528f59a
📦 fix: @librechat/agents v3.0.776 for Anthropic Message Coercion Fix (pt. 2) (#11396)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
2026-01-18 18:05:43 -05:00
Danny Avila
66d4540217
📦 fix: @librechat/agents v3.0.775 for Anthropic Message Coercion Fix (#11393) 2026-01-18 12:12:56 -05:00
Danny Avila
5037617131
🎨 fix: Layering for Right-hand Side Panel (#11392)
* 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.
2026-01-18 11:59:26 -05:00
Danny Avila
922cdafe81
v0.8.2-rc3 (#11384)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Publish `@librechat/client` to NPM / build-and-publish (push) Has been cancelled
Publish `librechat-data-provider` to NPM / build (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / build-and-publish (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
* 🔧 chore: Update version to v0.8.2-rc3 across multiple files

* 🔧 chore: Update package versions for api, client, data-provider, and data-schemas
2026-01-17 17:05:12 -05:00
Danny Avila
c11245f74b
🫙 fix: Cache Control Immutability for Multi-Agents (#11383)
* 🔧 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
2026-01-17 16:48:43 -05:00
Danny Avila
f7893d9507
🔧 fix: Update Z-index values for Navigation and Mask layers (#11375)
Some checks failed
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
* 🔧 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.
2026-01-16 17:45:18 -05:00
Andrei Blizorukov
02d75b24a4
🛠️ fix: improved retry logic during meili sync & improved batching (#11373)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🛠️ 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
2026-01-16 10:30:00 -05:00
Danny Avila
c378e777ef
🪵 refactor: Preserve Job Error State for Late Stream Subscribers (#11372)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Publish `@librechat/data-schemas` to NPM / build-and-publish (push) Has been cancelled
* 🪵 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.
2026-01-15 23:02:03 -05:00
Danny Avila
81f4af55b5
🪨 feat: Anthropic Beta Support for Bedrock (#11371)
* 🪨 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.
2026-01-15 22:48:48 -05:00
github-actions[bot]
476882455e
🌍 i18n: Update translation.json with latest translations (#11370)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-15 22:23:23 -05:00
Danny Avila
bb0fa3b7f7
📦 chore: Cleanup Unused Packages (#11369)
* 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
2026-01-15 21:24:49 -05:00
Danny Avila
9562f9297a
🪨 fix: Bedrock Provider Support for Memory Agent (#11353)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 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.
2026-01-14 22:02:57 -05:00
Danny Avila
b5e4c763af
🔀 refactor: Endpoint Check for File Uploads in Images Route (#11352)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
- Changed the endpoint check from `isAgentsEndpoint` to `isAssistantsEndpoint` to adjust the logic for processing file uploads.
- Reordered the import statements for better organization.
2026-01-14 14:07:58 -05:00
github-actions[bot]
39a227a59f
🌍 i18n: Update translation.json with latest translations (#11342)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-14 10:39:46 -05:00
Danny Avila
8d74fcd44a
📦 chore: npm audit fix (#11346)
- 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.
2026-01-14 10:38:01 -05:00
Danny Avila
9d5e80d7a3
🛠️ fix: UI/UX for Known Server-sent Errors (#11343)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
2026-01-13 14:13:06 -05:00
David Newman
a95fea19bb
🌅 fix: Agent Avatar S3 URL Refresh Pagination and Persistence (#11323)
* Refresh all S3 avatars for this user's accessible agent set, not the first page

* Cleaner debug messages

* Log errors as errors

* refactor: avatar refresh logic to process agents in batches and improve error handling. Introduced new utility functions for refreshing S3 avatars and updating agent records. Updated tests to cover various scenarios including cache hits, user ownership checks, and error handling. Added constants for maximum refresh limits.

* refactor: update avatar refresh logic to allow users with VIEW access to refresh avatars for all accessible agents. Removed checks for agent ownership and author presence, and updated related tests to reflect new behavior.

* chore: Remove YouTube toolkit due to #11331

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-01-13 13:01:11 -05:00
Andrei Blizorukov
10f591ab1c
📊 refactor: Use Estimated Document Count for Meilisearch Sync (#11329)
* 🔧 refactor: use approximate number of documents to improve performance

* 🔧 refactor: unittests for approximate document count in meilisearch sync

* refactor:  limits persentage based on approximate total count & one more test case
2026-01-13 11:49:02 -05:00
heptapod
774f1f2cc2
🗑️ chore: Remove YouTube API integration (#11331)
* 🗑️ refactor: Remove YouTube API integration and related configurations as it's broken and should be integrated via MCP instead. Currently there seems not to be a single MCP out there with working get_transcript methods for months. API seems to have changed and there are no maintainers on these projects. We will work out something soon an MCP solution

- Deleted YouTube API key and related configurations from .env.example.
- Removed YouTube tools and their references from the API client, including the manifest and structured files.
- Updated package.json to remove YouTube-related dependencies.
- Cleaned up toolkit exports by removing YouTube toolkit references.

* chore: revert package removal to properly remove packages

* 🗑️ refactor: Remove YouTube API and related dependencies due to integration issues

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-01-13 11:44:57 -05:00
Artyom Bogachenko
5617bf71be
🧭 fix: Correct Subpath Routing for SSE and Favorites Endpoints (#11339)
Co-authored-by: Artyom Bogachenco <a.bogachenko@easyreport.ai>
2026-01-13 10:53:14 -05:00
Danny Avila
2a50c372ef
🪙 refactor: Collected Usage & Anthropic Prompt Caching (#11319)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🔧 refactor: Improve token calculation in AgentClient.recordCollectedUsage

- Updated the token calculation logic to sum output tokens directly from all entries, addressing issues with negative values in parallel execution scenarios.
- Added comments for clarity on the usage of input tokens and output tokens.
- Introduced a new test file for comprehensive testing of the recordCollectedUsage function, covering various execution scenarios including sequential and parallel processing, cache token handling, and model fallback logic.

* 🔧 refactor: Anthropic `promptCache` handling in LLM configuration

* 🔧 test: Add comprehensive test for cache token handling in recordCollectedUsage

- Introduced a new test case to validate the handling of cache tokens across multiple tool calls in the recordCollectedUsage function.
- Ensured correct calculations for input and output tokens, including scenarios with cache creation and reading.
- Verified the expected interactions with token spending methods to enhance the robustness of the token management logic.
2026-01-12 23:02:08 -05:00
github-actions[bot]
1329e16d3a
🌍 i18n: Update translation.json with latest translations (#11317)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-12 23:01:02 -05:00
Danny Avila
f8774983a0
🪪 fix: Misleading MCP Server Lookup Method Name (#11315)
* 🔧 fix: MCP server ID resolver in access permissions (#11315)

- Replaced `findMCPServerById` with `findMCPServerByObjectId` in access permissions route and corresponding tests for improved clarity and consistency in resource identification.

* 🔧 refactor: Update MCP server resource access methods to use server name

- Replaced instances of `findMCPServerById` with `findMCPServerByServerName` across middleware, database, and test files for improved clarity and consistency in resource identification.
- Updated related comments and test cases to reflect the change in method usage.

* chore: Increase timeout for Redis update in GenerationJobManager integration tests

- Updated the timeout duration from 50ms to 200ms in the GenerationJobManager integration tests to ensure reliable verification of final event data in Redis after emitting the done event.
2026-01-12 21:04:25 -05:00
Danny Avila
a8fa85b8e2
📜 fix: Layout/Overflow handling in Share View (#11314)
- Updated MessagesView to include min-height and overflow-hidden for better layout management.
- Adjusted ShareView to ensure proper height and overflow handling, enhancing the overall user experience.
2026-01-12 20:11:34 -05:00
Danny Avila
28270bec58
🌵 chore: Remove deprecated 'prompt-caching' Anthropic header (#11313) 2026-01-12 19:12:36 -05:00
Danny Avila
90521bfb4e
🧹 fix: MCP Panel Regressions after UI refactor (#11312)
* fix: Revoke OAuth and Vars. Config Regressions in MCP Panel

- Introduced a new Trash2 icon button in MCPCardActions for revoking OAuth access on connected OAuth servers.
- Updated MCPServerCard to handle the revoke action, allowing users to revoke OAuth for specific servers.
- Enhanced user experience by ensuring the revoke option is available regardless of the server's connection state.

* refactor: Reorganize Revoke Button Logic in MCPCardActions and Update Toast Messages

- Moved the Revoke button for OAuth servers to a new position in MCPCardActions for improved visibility.
- Updated the success message logic in useMCPServerManager to differentiate between uninstall and variable update actions, enhancing user feedback.

* i18n: Add new translation for MCP server access revocation message

* refactor: Centralize Deselection Logic in updateUserPluginsMutation

- Updated the success handler in useUpdateUserPluginsMutation to manage deselection of MCP server values when revoking access, improving code clarity and reducing redundancy.
- Simplified message assignment logic for user feedback during plugin updates.
2026-01-12 19:01:45 -05:00
Joseph Licata
fc6f127b21
🌉 fix: Add Proxy Support to Gemini Image Gen Tool (#11302)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
*  feat: Add proxy support for Google APIs in GeminiImageGen

- Implemented a proxy wrapper for globalThis.fetch to route requests to googleapis.com through a specified proxy.
- Added tests to verify the proxy configuration behavior, ensuring correct dispatcher application for Google API calls and preserving existing options.

Co-authored-by: [Your Name] <your.email@example.com>

* chore: remove comment

---------

Co-authored-by: [Your Name] <your.email@example.com>
Co-authored-by: Danny Avila <danacordially@gmail.com>
2026-01-12 09:51:48 -05:00
Danny Avila
cdffdd2926
🏞️ fix: Gemini Image Filenames and Add Tool Cache Safety (#11306)
* 🔧 fix: Handle undefined tool definitions in agent and assistant creation (#11295)

* Updated the tool fetching logic in createAgentHandler, createAssistant, and patchAssistant functions to use nullish coalescing, ensuring that an empty object is returned if no tools are available. This change improves robustness against undefined values in tool definitions across multiple controller files.

* Adjusted the ToolService to maintain consistency in tool definition handling.

* 🔧 fix: Update filename generation in createToolEndCallback function

* Modified the filename generation logic to remove the tool_call_id from the filename, simplifying the naming convention for saved images. This change enhances clarity and consistency in the generated filenames.
2026-01-12 09:01:23 -05:00
github-actions[bot]
2958fcd0c5
🌍 i18n: Update translation.json with latest translations (#11294)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-10 16:34:09 -05:00
Karthikeyan N
200377947e
🌙 feat: Add Moonshot Kimi K2 Bedrock Support (#11288)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* feat(bedrock): add Moonshot Kimi K2 Thinking model support

- Add Moonshot provider to BedrockProviders enum
- Add Moonshot-specific parameter settings with 16384 default max tokens
- Add conditional for anthropic_beta to only apply to Anthropic models
- Kimi K2 Thinking model: moonshot.kimi-k2-thinking (256K context)

* Delete add-kimi-bedrock.md

* Remove comment on anthropic_beta condition

Remove comment about adding anthropic_beta for Anthropic models.

* chore: enum order

* feat(bedrock): add tests to ensure anthropic_beta is not added to Moonshot Kimi K2 and DeepSeek models

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-01-10 14:26:19 -05:00
Danny Avila
76e17ba701
🔧 refactor: Permission handling for Resource Sharing (#11283)
* 🔧 refactor: permission handling for public sharing

- Updated permission keys from SHARED_GLOBAL to SHARE across various files for consistency.
- Added public access configuration in librechat.example.yaml.
- Adjusted related tests and components to reflect the new permission structure.

* chore: Update default SHARE permission to false

* fix: Update SHARE permissions in tests and implementation

- Added SHARE permission handling for user and admin roles in permissions.spec.ts and permissions.ts.
- Updated expected permissions in tests to reflect new SHARE permission values for various permission types.

* fix: Handle undefined values in PeoplePickerAdminSettings component

- Updated the checked and value props of the Switch component to handle undefined values gracefully by defaulting to false. This ensures consistent behavior when the field value is not set.

* feat: Add CREATE permission handling for prompts and agents

- Introduced CREATE permission for user and admin roles in permissions.spec.ts and permissions.ts.
- Updated expected permissions in tests to include CREATE permission for various permission types.

* 🔧 refactor: Enhance permission handling for sharing dialog usability

* refactor: public sharing permissions for resources

- Added middleware to check SHARE_PUBLIC permissions for agents, prompts, and MCP servers.
- Updated interface configuration in librechat.example.yaml to include public sharing options.
- Enhanced components and hooks to support public sharing functionality.
- Adjusted tests to validate new permission handling for public sharing across various resource types.

* refactor: update Share2Icon styling in GenericGrantAccessDialog

* refactor: update Share2Icon size in GenericGrantAccessDialog for consistency

* refactor: improve layout and styling of Share2Icon in GenericGrantAccessDialog

* refactor: update Share2Icon size in GenericGrantAccessDialog for improved consistency

* chore: remove redundant public sharing option from People Picker

* refactor: add SHARE_PUBLIC permission handling in updateInterfacePermissions tests
2026-01-10 14:02:56 -05:00
Scott Finlay
083251508e
⏭️ fix: Skip Title Generation for Temporary Chats (#11282)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
* Not generating titles for temporary chats

* Minor linter fix to prettify debug line

* Adding a test for skipping title generation for temporary chats
2026-01-09 14:34:30 -05:00
Danny Avila
7d38047bc2
📦 chore: Update react-router to v6.30.3 and @remix-run/router to v1.23.2 (#11273)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2026-01-08 19:20:08 -05:00
Danny Avila
87c817a5eb
🔧 fix: Invalidate Query for MCP tools on Chat Creation (#11272) (#11272) 2026-01-08 18:57:28 -05:00
github-actions[bot]
f2e4cd5026
🌍 i18n: Update translation.json with latest translations (#11259)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-08 11:00:52 -05:00
Dustin Healy
6680ccf63b
🔧 fix: Model List Query Data in Agent Builder Panel (#11260)
* fix: don't populate query with initial data for getModels query hook to avoid caching issue when opening model list in agent builder after hard refresh / switching to Agent Marketplace view

* fix: reduce scope of change
2026-01-08 11:00:28 -05:00
Danny Avila
c30afb8b68
🚏 chore: Remove Resumable Stream Toggle (#11258)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🚏 chore: Remove Resumable Stream Toggle

- Removed the `useResumableStreamToggle` hook and its associated logic from the ChatView component.
- Updated Conversations and useAdaptiveSSE hooks to determine resumable stream status based on the endpoint type.
- Cleaned up settings by removing the `resumableStreams` state from the store and its related localization strings.

* 🔧 refactor: Simplify Active Jobs Logic in Conversations Component

- Removed the endpoint type checks and associated logic for resumable streams in the Conversations component.
- Updated the `useActiveJobs` hook call to no longer depend on resumable stream status, streamlining the data fetching process.
2026-01-07 20:37:35 -05:00
780 changed files with 86426 additions and 18230 deletions

View file

@ -47,6 +47,10 @@ TRUST_PROXY=1
# password policies.
# MIN_PASSWORD_LENGTH=8
# When enabled, the app will continue running after encountering uncaught exceptions
# instead of exiting the process. Not recommended for production unless necessary.
# CONTINUE_ON_UNCAUGHT_EXCEPTION=false
#===============#
# JSON Logging #
#===============#
@ -61,6 +65,9 @@ CONSOLE_JSON=false
DEBUG_LOGGING=true
DEBUG_CONSOLE=false
# Enable memory diagnostics (logs heap/RSS snapshots every 60s, auto-enabled with --inspect)
# MEM_DIAG=true
#=============#
# Permissions #
#=============#
@ -87,6 +94,16 @@ NODE_MAX_OLD_SPACE_SIZE=6144
# CONFIG_PATH="/alternative/path/to/librechat.yaml"
#==================#
# Langfuse Tracing #
#==================#
# Get Langfuse API keys for your project from the project settings page: https://cloud.langfuse.com
# LANGFUSE_PUBLIC_KEY=
# LANGFUSE_SECRET_KEY=
# LANGFUSE_BASE_URL=
#===================================================#
# Endpoints #
#===================================================#
@ -121,7 +138,7 @@ PROXY=
#============#
ANTHROPIC_API_KEY=user_provided
# ANTHROPIC_MODELS=claude-opus-4-20250514,claude-sonnet-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307
# ANTHROPIC_MODELS=claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-sonnet-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307
# ANTHROPIC_REVERSE_PROXY=
# Set to true to use Anthropic models through Google Vertex AI instead of direct API
@ -156,7 +173,8 @@ ANTHROPIC_API_KEY=user_provided
# BEDROCK_AWS_SESSION_TOKEN=someSessionToken
# Note: This example list is not meant to be exhaustive. If omitted, all known, supported model IDs will be included for you.
# BEDROCK_AWS_MODELS=anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0
# BEDROCK_AWS_MODELS=anthropic.claude-sonnet-4-6,anthropic.claude-opus-4-6-v1,anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0
# Cross-region inference model IDs: us.anthropic.claude-sonnet-4-6,us.anthropic.claude-opus-4-6-v1,global.anthropic.claude-opus-4-6-v1
# See all Bedrock model IDs here: https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html#model-ids-arns
@ -178,10 +196,10 @@ GOOGLE_KEY=user_provided
# GOOGLE_AUTH_HEADER=true
# Gemini API (AI Studio)
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite
# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite
# Vertex AI
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
@ -228,10 +246,6 @@ GOOGLE_KEY=user_provided
# Option A: Use dedicated Gemini API key for image generation
# GEMINI_API_KEY=your-gemini-api-key
# Option B: Use Vertex AI (no API key needed, uses service account)
# Set this to enable Vertex AI and allow tool without requiring API keys
# GEMINI_VERTEX_ENABLED=true
# Vertex AI model for image generation (defaults to gemini-2.5-flash-image)
# GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
@ -331,10 +345,6 @@ FLUX_API_BASE_URL=https://api.us1.bfl.ai
GOOGLE_SEARCH_API_KEY=
GOOGLE_CSE_ID=
# YOUTUBE
#-----------------
YOUTUBE_API_KEY=
# Stable Diffusion
#-----------------
SD_WEBUI_URL=http://host.docker.internal:7860
@ -503,6 +513,9 @@ OPENID_ADMIN_ROLE_TOKEN_KIND=
OPENID_USERNAME_CLAIM=
# Set to determine which user info property returned from OpenID Provider to store as the User's name
OPENID_NAME_CLAIM=
# Set to determine which user info claim to use as the email/identifier for user matching (e.g., "upn" for Entra ID)
# When not set, defaults to: email -> preferred_username -> upn
OPENID_EMAIL_CLAIM=
# Optional audience parameter for OpenID authorization requests
OPENID_AUDIENCE=
@ -647,6 +660,9 @@ AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
AWS_BUCKET_NAME=
# Required for path-style S3-compatible providers (MinIO, Hetzner, Backblaze B2, etc.)
# that don't support virtual-hosted-style URLs (bucket.endpoint). Not needed for AWS S3.
# AWS_FORCE_PATH_STYLE=false
#========================#
# Azure Blob Storage #
@ -661,7 +677,8 @@ AZURE_CONTAINER_NAME=files
#========================#
ALLOW_SHARED_LINKS=true
ALLOW_SHARED_LINKS_PUBLIC=true
# Allows unauthenticated access to shared links. Defaults to false (auth required) if not set.
ALLOW_SHARED_LINKS_PUBLIC=false
#==============================#
# Static File Cache Control #
@ -741,8 +758,10 @@ HELP_AND_FAQ_URL=https://librechat.ai
# REDIS_PING_INTERVAL=300
# Force specific cache namespaces to use in-memory storage even when Redis is enabled
# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
# Comma-separated list of CacheKeys
# Defaults to CONFIG_STORE,APP_CONFIG so YAML-derived config stays per-container (safe for blue/green deployments)
# Set to empty string to force all namespaces through Redis: FORCED_IN_MEMORY_CACHE_NAMESPACES=
# FORCED_IN_MEMORY_CACHE_NAMESPACES=CONFIG_STORE,APP_CONFIG
# Leader Election Configuration (for multi-instance deployments with Redis)
# Duration in seconds that the leader lease is valid before it expires (default: 25)
@ -831,3 +850,24 @@ OPENWEATHER_API_KEY=
# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
# MCP_SKIP_CODE_CHALLENGE_CHECK=false
# Circuit breaker: max connect/disconnect cycles before tripping (per server)
# MCP_CB_MAX_CYCLES=7
# Circuit breaker: sliding window (ms) for counting cycles
# MCP_CB_CYCLE_WINDOW_MS=45000
# Circuit breaker: cooldown (ms) after the cycle breaker trips
# MCP_CB_CYCLE_COOLDOWN_MS=15000
# Circuit breaker: max consecutive failed connection rounds before backoff
# MCP_CB_MAX_FAILED_ROUNDS=3
# Circuit breaker: sliding window (ms) for counting failed rounds
# MCP_CB_FAILED_WINDOW_MS=120000
# Circuit breaker: base backoff (ms) after failed round threshold is reached
# MCP_CB_BASE_BACKOFF_MS=30000
# Circuit breaker: max backoff cap (ms) for exponential backoff
# MCP_CB_MAX_BACKOFF_MS=300000

View file

@ -26,18 +26,14 @@ Project maintainers have the right and responsibility to remove, edit, or reject
## 1. Development Setup
1. Use Node.JS 20.x.
2. Install typescript globally: `npm i -g typescript`.
3. Run `npm ci` to install dependencies.
4. Build the data provider: `npm run build:data-provider`.
5. Build data schemas: `npm run build:data-schemas`.
6. Build API methods: `npm run build:api`.
7. Setup and run unit tests:
1. Use Node.js v20.19.0+ or ^22.12.0 or >= 23.0.0.
2. Run `npm run smart-reinstall` to install dependencies (uses Turborepo). Use `npm run reinstall` for a clean install, or `npm ci` for a fresh lockfile-based install.
3. Build all compiled code: `npm run build`.
4. Setup and run unit tests:
- Copy `.env.test`: `cp api/test/.env.test.example api/test/.env.test`.
- Run backend unit tests: `npm run test:api`.
- Run frontend unit tests: `npm run test:client`.
8. Setup and run integration tests:
- Build client: `cd client && npm run build`.
5. Setup and run integration tests:
- Create `.env`: `cp .env.example .env`.
- Install [MongoDB Community Edition](https://www.mongodb.com/docs/manual/administration/install-community/), ensure that `mongosh` connects to your local instance.
- Run: `npx install playwright`, then `npx playwright install`.
@ -48,11 +44,11 @@ Project maintainers have the right and responsibility to remove, edit, or reject
## 2. Development Notes
1. Before starting work, make sure your main branch has the latest commits with `npm run update`.
3. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning.
2. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning.
3. After your changes, reinstall packages in your current branch using `npm run reinstall` and ensure everything still works.
- Restart the ESLint server ("ESLint: Restart ESLint Server" in VS Code command bar) and your IDE after reinstalling or updating.
4. Clear web app localStorage and cookies before and after changes.
5. For frontend changes, compile typescript before and after changes to check for introduced errors: `cd client && npm run build`.
5. To check for introduced errors, build all compiled code: `npm run build`.
6. Run backend unit tests: `npm run test:api`.
7. Run frontend unit tests: `npm run test:client`.
8. Run integration tests: `npm run e2e`.
@ -118,50 +114,45 @@ Apply the following naming conventions to branches, labels, and other Git-relate
- **JS/TS:** Directories and file names: Descriptive and camelCase. First letter uppercased for React files (e.g., `helperFunction.ts, ReactComponent.tsx`).
- **Docs:** Directories and file names: Descriptive and snake_case (e.g., `config_files.md`).
## 7. TypeScript Conversion
## 7. Coding Standards
For detailed coding conventions, workspace boundaries, and architecture guidance, refer to the [`AGENTS.md`](../AGENTS.md) file at the project root. It covers code style, type safety, import ordering, iteration/performance expectations, frontend rules, testing, and development commands.
## 8. TypeScript Conversion
1. **Original State**: The project was initially developed entirely in JavaScript (JS).
2. **Frontend Transition**:
- We are in the process of transitioning the frontend from JS to TypeScript (TS).
- The transition is nearing completion.
- This conversion is feasible due to React's capability to intermix JS and TS prior to code compilation. It's standard practice to compile/bundle the code in such scenarios.
2. **Frontend**: Fully transitioned to TypeScript.
3. **Backend Considerations**:
- Transitioning the backend to TypeScript would be a more intricate process, especially for an established Express.js server.
- **Options for Transition**:
- **Single Phase Overhaul**: This involves converting the entire backend to TypeScript in one go. It's the most straightforward approach but can be disruptive, especially for larger codebases.
- **Incremental Transition**: Convert parts of the backend progressively. This can be done by:
- Maintaining a separate directory for TypeScript files.
- Gradually migrating and testing individual modules or routes.
- Using a build tool like `tsc` to compile TypeScript files independently until the entire transition is complete.
- **Compilation Considerations**:
- Introducing a compilation step for the server is an option. This would involve using tools like `ts-node` for development and `tsc` for production builds.
- However, this is not a conventional approach for Express.js servers and could introduce added complexity, especially in terms of build and deployment processes.
- **Current Stance**: At present, this backend transition is of lower priority and might not be pursued.
3. **Backend**:
- The legacy Express.js server remains in `/api` as JavaScript.
- All new backend code is written in TypeScript under `/packages/api`, which is compiled and consumed by `/api`.
- Shared database logic lives in `/packages/data-schemas` (TypeScript).
- Shared frontend/backend API types and services live in `/packages/data-provider` (TypeScript).
- Minimize direct changes to `/api`; prefer adding TypeScript code to `/packages/api` and importing it.
## 8. Module Import Conventions
## 9. Module Import Conventions
- `npm` packages first,
- from longest line (top) to shortest (bottom)
Imports are organized into three sections (in order):
- Followed by typescript types (pertains to data-provider and client workspaces)
- longest line (top) to shortest (bottom)
- types from package come first
1. **Package imports** — sorted from shortest to longest line length.
- `react` is always the first import.
- Multi-line (stacked) imports count their total character length across all lines for sorting.
- Lastly, local imports
- longest line (top) to shortest (bottom)
- imports with alias `~` treated the same as relative import with respect to line length
2. **`import type` imports** — sorted from longest to shortest line length.
- Package type imports come first, then local type imports.
- Line length sorting resets between the package and local sub-groups.
3. **Local/project imports** — sorted from longest to shortest line length.
- Multi-line (stacked) imports count their total character length across all lines for sorting.
- Imports with alias `~` are treated the same as relative imports with respect to line length.
- Consolidate value imports from the same module as much as possible.
- Always use standalone `import type { ... }` for type imports; never use inline `type` keyword inside value imports (e.g., `import { Foo, type Bar }` is wrong).
**Note:** ESLint will automatically enforce these import conventions when you run `npm run lint --fix` or through pre-commit hooks.
---
Please ensure that you adapt this summary to fit the specific context and nuances of your project.
For the full set of coding standards, see [`AGENTS.md`](../AGENTS.md).
---

View file

@ -9,48 +9,145 @@ on:
paths:
- 'api/**'
- 'packages/**'
env:
NODE_ENV: CI
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
jobs:
tests_Backend:
name: Run Backend unit tests
timeout-minutes: 60
build:
name: Build packages
runs-on: ubuntu-latest
env:
MONGO_URI: ${{ secrets.MONGO_URI }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
CREDS_KEY: ${{ secrets.CREDS_KEY }}
CREDS_IV: ${{ secrets.CREDS_IV }}
BAN_VIOLATIONS: ${{ secrets.BAN_VIOLATIONS }}
BAN_DURATION: ${{ secrets.BAN_DURATION }}
BAN_INTERVAL: ${{ secrets.BAN_INTERVAL }}
NODE_ENV: CI
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.x
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
api/node_modules
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Install Data Provider Package
- name: Restore data-provider build cache
id: cache-data-provider
uses: actions/cache@v4
with:
path: packages/data-provider/dist
key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
- name: Build data-provider
if: steps.cache-data-provider.outputs.cache-hit != 'true'
run: npm run build:data-provider
- name: Install Data Schemas Package
- name: Restore data-schemas build cache
id: cache-data-schemas
uses: actions/cache@v4
with:
path: packages/data-schemas/dist
key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
- name: Build data-schemas
if: steps.cache-data-schemas.outputs.cache-hit != 'true'
run: npm run build:data-schemas
- name: Install API Package
- name: Restore api build cache
id: cache-api
uses: actions/cache@v4
with:
path: packages/api/dist
key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/server-rollup.config.js', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json') }}
- name: Build api
if: steps.cache-api.outputs.cache-hit != 'true'
run: npm run build:api
- name: Create empty auth.json file
run: |
mkdir -p api/data
echo '{}' > api/data/auth.json
- name: Upload data-provider build
uses: actions/upload-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
retention-days: 2
- name: Check for Circular dependency in rollup
- name: Upload data-schemas build
uses: actions/upload-artifact@v4
with:
name: build-data-schemas
path: packages/data-schemas/dist
retention-days: 2
- name: Upload api build
uses: actions/upload-artifact@v4
with:
name: build-api
path: packages/api/dist
retention-days: 2
circular-deps:
name: Circular dependency checks
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
api/node_modules
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Download data-provider build
uses: actions/download-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download data-schemas build
uses: actions/download-artifact@v4
with:
name: build-data-schemas
path: packages/data-schemas/dist
- name: Rebuild @librechat/api and check for circular dependencies
run: |
output=$(npm run build:api 2>&1)
echo "$output"
if echo "$output" | grep -q "Circular depend"; then
echo "Error: Circular dependency detected in @librechat/api!"
exit 1
fi
- name: Detect circular dependencies in rollup
working-directory: ./packages/data-provider
run: |
output=$(npm run rollup:api)
@ -60,17 +157,201 @@ jobs:
exit 1
fi
test-api:
name: 'Tests: api'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
env:
MONGO_URI: ${{ secrets.MONGO_URI }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
CREDS_KEY: ${{ secrets.CREDS_KEY }}
CREDS_IV: ${{ secrets.CREDS_IV }}
BAN_VIOLATIONS: ${{ secrets.BAN_VIOLATIONS }}
BAN_DURATION: ${{ secrets.BAN_DURATION }}
BAN_INTERVAL: ${{ secrets.BAN_INTERVAL }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
api/node_modules
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Download data-provider build
uses: actions/download-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download data-schemas build
uses: actions/download-artifact@v4
with:
name: build-data-schemas
path: packages/data-schemas/dist
- name: Download api build
uses: actions/download-artifact@v4
with:
name: build-api
path: packages/api/dist
- name: Create empty auth.json file
run: |
mkdir -p api/data
echo '{}' > api/data/auth.json
- name: Prepare .env.test file
run: cp api/test/.env.test.example api/test/.env.test
- name: Run unit tests
run: cd api && npm run test:ci
- name: Run librechat-data-provider unit tests
test-data-provider:
name: 'Tests: data-provider'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
api/node_modules
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Download data-provider build
uses: actions/download-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Run unit tests
run: cd packages/data-provider && npm run test:ci
- name: Run @librechat/data-schemas unit tests
test-data-schemas:
name: 'Tests: data-schemas'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
api/node_modules
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Download data-provider build
uses: actions/download-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download data-schemas build
uses: actions/download-artifact@v4
with:
name: build-data-schemas
path: packages/data-schemas/dist
- name: Run unit tests
run: cd packages/data-schemas && npm run test:ci
- name: Run @librechat/api unit tests
test-packages-api:
name: 'Tests: @librechat/api'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
api/node_modules
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Download data-provider build
uses: actions/download-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download data-schemas build
uses: actions/download-artifact@v4
with:
name: build-data-schemas
path: packages/data-schemas/dist
- name: Download api build
uses: actions/download-artifact@v4
with:
name: build-api
path: packages/api/dist
- name: Run unit tests
run: cd packages/api && npm run test:ci

View file

@ -2,7 +2,7 @@ name: Frontend Unit Tests
on:
pull_request:
branches:
branches:
- main
- dev
- dev-staging
@ -11,51 +11,200 @@ on:
- 'client/**'
- 'packages/data-provider/**'
env:
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
jobs:
tests_frontend_ubuntu:
name: Run frontend unit tests on Ubuntu
timeout-minutes: 60
build:
name: Build packages
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.x
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
client/node_modules
packages/client/node_modules
packages/data-provider/node_modules
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Build Client
run: npm run frontend:ci
- name: Restore data-provider build cache
id: cache-data-provider
uses: actions/cache@v4
with:
path: packages/data-provider/dist
key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
- name: Build data-provider
if: steps.cache-data-provider.outputs.cache-hit != 'true'
run: npm run build:data-provider
- name: Restore client-package build cache
id: cache-client-package
uses: actions/cache@v4
with:
path: packages/client/dist
key: build-client-package-${{ runner.os }}-${{ hashFiles('packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/rollup.config.js', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
- name: Build client-package
if: steps.cache-client-package.outputs.cache-hit != 'true'
run: npm run build:client-package
- name: Upload data-provider build
uses: actions/upload-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
retention-days: 2
- name: Upload client-package build
uses: actions/upload-artifact@v4
with:
name: build-client-package
path: packages/client/dist
retention-days: 2
test-ubuntu:
name: 'Tests: Ubuntu'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
client/node_modules
packages/client/node_modules
packages/data-provider/node_modules
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Download data-provider build
uses: actions/download-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download client-package build
uses: actions/download-artifact@v4
with:
name: build-client-package
path: packages/client/dist
- name: Run unit tests
run: npm run test:ci --verbose
working-directory: client
tests_frontend_windows:
name: Run frontend unit tests on Windows
timeout-minutes: 60
test-windows:
name: 'Tests: Windows'
needs: build
runs-on: windows-latest
env:
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.x
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
client/node_modules
packages/client/node_modules
packages/data-provider/node_modules
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Build Client
run: npm run frontend:ci
- name: Download data-provider build
uses: actions/download-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download client-package build
uses: actions/download-artifact@v4
with:
name: build-client-package
path: packages/client/dist
- name: Run unit tests
run: npm run test:ci --verbose
working-directory: client
working-directory: client
build-verify:
name: Vite build verification
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
client/node_modules
packages/client/node_modules
packages/data-provider/node_modules
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Download data-provider build
uses: actions/download-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download client-package build
uses: actions/download-artifact@v4
with:
name: build-client-package
path: packages/client/dist
- name: Build client
run: cd client && npm run build:ci

4
.gitignore vendored
View file

@ -15,6 +15,7 @@ pids
# CI/CD data
test-image*
dump.rdb
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
@ -29,6 +30,9 @@ coverage
config/translations/stores/*
client/src/localization/languages/*_missing_keys.json
# Turborepo
.turbo
# Compiled Dirs (http://nodejs.org/api/addons.html)
build/
dist/

166
AGENTS.md Normal file
View file

@ -0,0 +1,166 @@
# LibreChat
## Project Overview
LibreChat is a monorepo with the following key workspaces:
| Workspace | Language | Side | Dependency | Purpose |
|---|---|---|---|---|
| `/api` | JS (legacy) | Backend | `packages/api`, `packages/data-schemas`, `packages/data-provider`, `@librechat/agents` | Express server — minimize changes here |
| `/packages/api` | **TypeScript** | Backend | `packages/data-schemas`, `packages/data-provider` | New backend code lives here (TS only, consumed by `/api`) |
| `/packages/data-schemas` | TypeScript | Backend | `packages/data-provider` | Database models/schemas, shareable across backend projects |
| `/packages/data-provider` | TypeScript | Shared | — | Shared API types, endpoints, data-service — used by both frontend and backend |
| `/client` | TypeScript/React | Frontend | `packages/data-provider`, `packages/client` | Frontend SPA |
| `/packages/client` | TypeScript | Frontend | `packages/data-provider` | Shared frontend utilities |
The source code for `@librechat/agents` (major backend dependency, same team) is at `/home/danny/agentus`.
---
## Workspace Boundaries
- **All new backend code must be TypeScript** in `/packages/api`.
- Keep `/api` changes to the absolute minimum (thin JS wrappers calling into `/packages/api`).
- Database-specific shared logic goes in `/packages/data-schemas`.
- Frontend/backend shared API logic (endpoints, types, data-service) goes in `/packages/data-provider`.
- Build data-provider from project root: `npm run build:data-provider`.
---
## Code Style
### Structure and Clarity
- **Never-nesting**: early returns, flat code, minimal indentation. Break complex operations into well-named helpers.
- **Functional first**: pure functions, immutable data, `map`/`filter`/`reduce` over imperative loops. Only reach for OOP when it clearly improves domain modeling or state encapsulation.
- **No dynamic imports** unless absolutely necessary.
### DRY
- Extract repeated logic into utility functions.
- Reusable hooks / higher-order components for UI patterns.
- Parameterized helpers instead of near-duplicate functions.
- Constants for repeated values; configuration objects over duplicated init code.
- Shared validators, centralized error handling, single source of truth for business rules.
- Shared typing system with interfaces/types extending common base definitions.
- Abstraction layers for external API interactions.
### Iteration and Performance
- **Minimize looping** — especially over shared data structures like message arrays, which are iterated frequently throughout the codebase. Every additional pass adds up at scale.
- Consolidate sequential O(n) operations into a single pass whenever possible; never loop over the same collection twice if the work can be combined.
- Choose data structures that reduce the need to iterate (e.g., `Map`/`Set` for lookups instead of `Array.find`/`Array.includes`).
- Avoid unnecessary object creation; consider space-time tradeoffs.
- Prevent memory leaks: careful with closures, dispose resources/event listeners, no circular references.
### Type Safety
- **Never use `any`**. Explicit types for all parameters, return values, and variables.
- **Limit `unknown`** — avoid `unknown`, `Record<string, unknown>`, and `as unknown as T` assertions. A `Record<string, unknown>` almost always signals a missing explicit type definition.
- **Don't duplicate types** — before defining a new type, check whether it already exists in the project (especially `packages/data-provider`). Reuse and extend existing types rather than creating redundant definitions.
- Use union types, generics, and interfaces appropriately.
- All TypeScript and ESLint warnings/errors must be addressed — do not leave unresolved diagnostics.
### Comments and Documentation
- Write self-documenting code; no inline comments narrating what code does.
- JSDoc only for complex/non-obvious logic or intellisense on public APIs.
- Single-line JSDoc for brief docs, multi-line for complex cases.
- Avoid standalone `//` comments unless absolutely necessary.
### Import Order
Imports are organized into three sections:
1. **Package imports** — sorted shortest to longest line length (`react` always first).
2. **`import type` imports** — sorted longest to shortest (package types first, then local types; length resets between sub-groups).
3. **Local/project imports** — sorted longest to shortest.
Multi-line imports count total character length across all lines. Consolidate value imports from the same module. Always use standalone `import type { ... }` — never inline `type` inside value imports.
### JS/TS Loop Preferences
- **Limit looping as much as possible.** Prefer single-pass transformations and avoid re-iterating the same data.
- `for (let i = 0; ...)` for performance-critical or index-dependent operations.
- `for...of` for simple array iteration.
- `for...in` only for object property enumeration.
---
## Frontend Rules (`client/src/**/*`)
### Localization
- All user-facing text must use `useLocalize()`.
- Only update English keys in `client/src/locales/en/translation.json` (other languages are automated externally).
- Semantic key prefixes: `com_ui_`, `com_assistants_`, etc.
### Components
- TypeScript for all React components with proper type imports.
- Semantic HTML with ARIA labels (`role`, `aria-label`) for accessibility.
- Group related components in feature directories (e.g., `SidePanel/Memories/`).
- Use index files for clean exports.
### Data Management
- Feature hooks: `client/src/data-provider/[Feature]/queries.ts``[Feature]/index.ts``client/src/data-provider/index.ts`.
- React Query (`@tanstack/react-query`) for all API interactions; proper query invalidation on mutations.
- QueryKeys and MutationKeys in `packages/data-provider/src/keys.ts`.
### Data-Provider Integration
- Endpoints: `packages/data-provider/src/api-endpoints.ts`
- Data service: `packages/data-provider/src/data-service.ts`
- Types: `packages/data-provider/src/types/queries.ts`
- Use `encodeURIComponent` for dynamic URL parameters.
### Performance
- Prioritize memory and speed efficiency at scale.
- Cursor pagination for large datasets.
- Proper dependency arrays to avoid unnecessary re-renders.
- Leverage React Query caching and background refetching.
---
## Development Commands
| Command | Purpose |
|---|---|
| `npm run smart-reinstall` | Install deps (if lockfile changed) + build via Turborepo |
| `npm run reinstall` | Clean install — wipe `node_modules` and reinstall from scratch |
| `npm run backend` | Start the backend server |
| `npm run backend:dev` | Start backend with file watching (development) |
| `npm run build` | Build all compiled code via Turborepo (parallel, cached) |
| `npm run frontend` | Build all compiled code sequentially (legacy fallback) |
| `npm run frontend:dev` | Start frontend dev server with HMR (port 3090, requires backend running) |
| `npm run build:data-provider` | Rebuild `packages/data-provider` after changes |
- Node.js: v20.19.0+ or ^22.12.0 or >= 23.0.0
- Database: MongoDB
- Backend runs on `http://localhost:3080/`; frontend dev server on `http://localhost:3090/`
---
## Testing
- Framework: **Jest**, run per-workspace.
- Run tests from their workspace directory: `cd api && npx jest <pattern>`, `cd packages/api && npx jest <pattern>`, etc.
- Frontend tests: `__tests__` directories alongside components; use `test/layout-test-utils` for rendering.
- Cover loading, success, and error states for UI/data flows.
### Philosophy
- **Real logic over mocks.** Exercise actual code paths with real dependencies. Mocking is a last resort.
- **Spies over mocks.** Assert that real functions are called with expected arguments and frequency without replacing underlying logic.
- **MongoDB**: use `mongodb-memory-server` for a real in-memory MongoDB instance. Test actual queries and schema validation, not mocked DB calls.
- **MCP**: use real `@modelcontextprotocol/sdk` exports for servers, transports, and tool definitions. Mirror real scenarios, don't stub SDK internals.
- Only mock what you cannot control: external HTTP APIs, rate-limited services, non-deterministic system calls.
- Heavy mocking is a code smell, not a testing strategy.
---
## Formatting
Fix all formatting lint errors (trailing spaces, tabs, newlines, indentation) using auto-fix when available. All TypeScript/ESLint warnings and errors **must** be resolved.

View file

@ -1,236 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
### ✨ New Features
- ✨ feat: implement search parameter updates by **@mawburn** in [#7151](https://github.com/danny-avila/LibreChat/pull/7151)
- 🎏 feat: Add MCP support for Streamable HTTP Transport by **@benverhees** in [#7353](https://github.com/danny-avila/LibreChat/pull/7353)
- 🔒 feat: Add Content Security Policy using Helmet middleware by **@rubentalstra** in [#7377](https://github.com/danny-avila/LibreChat/pull/7377)
- ✨ feat: Add Normalization for MCP Server Names by **@danny-avila** in [#7421](https://github.com/danny-avila/LibreChat/pull/7421)
- 📊 feat: Improve Helm Chart by **@hofq** in [#3638](https://github.com/danny-avila/LibreChat/pull/3638)
- 🦾 feat: Claude-4 Support by **@danny-avila** in [#7509](https://github.com/danny-avila/LibreChat/pull/7509)
- 🪨 feat: Bedrock Support for Claude-4 Reasoning by **@danny-avila** in [#7517](https://github.com/danny-avila/LibreChat/pull/7517)
### 🌍 Internationalization
- 🌍 i18n: Add `Danish` and `Czech` and `Catalan` localization support by **@rubentalstra** in [#7373](https://github.com/danny-avila/LibreChat/pull/7373)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7375](https://github.com/danny-avila/LibreChat/pull/7375)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7468](https://github.com/danny-avila/LibreChat/pull/7468)
### 🔧 Fixes
- 💬 fix: update aria-label for accessibility in ConvoLink component by **@berry-13** in [#7320](https://github.com/danny-avila/LibreChat/pull/7320)
- 🔑 fix: use `apiKey` instead of `openAIApiKey` in OpenAI-like Config by **@danny-avila** in [#7337](https://github.com/danny-avila/LibreChat/pull/7337)
- 🔄 fix: update navigation logic in `useFocusChatEffect` to ensure correct search parameters are used by **@mawburn** in [#7340](https://github.com/danny-avila/LibreChat/pull/7340)
- 🔄 fix: Improve MCP Connection Cleanup by **@danny-avila** in [#7400](https://github.com/danny-avila/LibreChat/pull/7400)
- 🛡️ fix: Preset and Validation Logic for URL Query Params by **@danny-avila** in [#7407](https://github.com/danny-avila/LibreChat/pull/7407)
- 🌘 fix: artifact of preview text is illegible in dark mode by **@nhtruong** in [#7405](https://github.com/danny-avila/LibreChat/pull/7405)
- 🛡️ fix: Temporarily Remove CSP until Configurable by **@danny-avila** in [#7419](https://github.com/danny-avila/LibreChat/pull/7419)
- 💽 fix: Exclude index page `/` from static cache settings by **@sbruel** in [#7382](https://github.com/danny-avila/LibreChat/pull/7382)
### ⚙️ Other Changes
- 📜 docs: CHANGELOG for release v0.7.8 by **@github-actions[bot]** in [#7290](https://github.com/danny-avila/LibreChat/pull/7290)
- 📦 chore: Update API Package Dependencies by **@danny-avila** in [#7359](https://github.com/danny-avila/LibreChat/pull/7359)
- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7321](https://github.com/danny-avila/LibreChat/pull/7321)
- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7434](https://github.com/danny-avila/LibreChat/pull/7434)
- 🛡️ chore: `multer` v2.0.0 for CVE-2025-47935 and CVE-2025-47944 by **@danny-avila** in [#7454](https://github.com/danny-avila/LibreChat/pull/7454)
- 📂 refactor: Improve `FileAttachment` & File Form Deletion by **@danny-avila** in [#7471](https://github.com/danny-avila/LibreChat/pull/7471)
- 📊 chore: Remove Old Helm Chart by **@hofq** in [#7512](https://github.com/danny-avila/LibreChat/pull/7512)
- 🪖 chore: bump helm app version to v0.7.8 by **@austin-barrington** in [#7524](https://github.com/danny-avila/LibreChat/pull/7524)
---
## [v0.7.8] -
Changes from v0.7.8-rc1 to v0.7.8.
### ✨ New Features
- ✨ feat: Enhance form submission for touch screens by **@berry-13** in [#7198](https://github.com/danny-avila/LibreChat/pull/7198)
- 🔍 feat: Additional Tavily API Tool Parameters by **@glowforge-opensource** in [#7232](https://github.com/danny-avila/LibreChat/pull/7232)
- 🐋 feat: Add python to Dockerfile for increased MCP compatibility by **@technicalpickles** in [#7270](https://github.com/danny-avila/LibreChat/pull/7270)
### 🔧 Fixes
- 🔧 fix: Google Gemma Support & OpenAI Reasoning Instructions by **@danny-avila** in [#7196](https://github.com/danny-avila/LibreChat/pull/7196)
- 🛠️ fix: Conversation Navigation State by **@danny-avila** in [#7210](https://github.com/danny-avila/LibreChat/pull/7210)
- 🔄 fix: o-Series Model Regex for System Messages by **@danny-avila** in [#7245](https://github.com/danny-avila/LibreChat/pull/7245)
- 🔖 fix: Custom Headers for Initial MCP SSE Connection by **@danny-avila** in [#7246](https://github.com/danny-avila/LibreChat/pull/7246)
- 🛡️ fix: Deep Clone `MCPOptions` for User MCP Connections by **@danny-avila** in [#7247](https://github.com/danny-avila/LibreChat/pull/7247)
- 🔄 fix: URL Param Race Condition and File Draft Persistence by **@danny-avila** in [#7257](https://github.com/danny-avila/LibreChat/pull/7257)
- 🔄 fix: Assistants Endpoint & Minor Issues by **@danny-avila** in [#7274](https://github.com/danny-avila/LibreChat/pull/7274)
- 🔄 fix: Ollama Think Tag Edge Case with Tools by **@danny-avila** in [#7275](https://github.com/danny-avila/LibreChat/pull/7275)
### ⚙️ Other Changes
- 📜 docs: CHANGELOG for release v0.7.8-rc1 by **@github-actions[bot]** in [#7153](https://github.com/danny-avila/LibreChat/pull/7153)
- 🔄 refactor: Artifact Visibility Management by **@danny-avila** in [#7181](https://github.com/danny-avila/LibreChat/pull/7181)
- 📦 chore: Bump Package Security by **@danny-avila** in [#7183](https://github.com/danny-avila/LibreChat/pull/7183)
- 🌿 refactor: Unmount Fork Popover on Hide for Better Performance by **@danny-avila** in [#7189](https://github.com/danny-avila/LibreChat/pull/7189)
- 🧰 chore: ESLint configuration to enforce Prettier formatting rules by **@mawburn** in [#7186](https://github.com/danny-avila/LibreChat/pull/7186)
- 🎨 style: Improve KaTeX Rendering for LaTeX Equations by **@andresgit** in [#7223](https://github.com/danny-avila/LibreChat/pull/7223)
- 📝 docs: Update `.env.example` Google models by **@marlonka** in [#7254](https://github.com/danny-avila/LibreChat/pull/7254)
- 💬 refactor: MCP Chat Visibility Option, Google Rates, Remove OpenAPI Plugins by **@danny-avila** in [#7286](https://github.com/danny-avila/LibreChat/pull/7286)
- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7214](https://github.com/danny-avila/LibreChat/pull/7214)
[See full release details][release-v0.7.8]
[release-v0.7.8]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8
---
## [v0.7.8-rc1] -
Changes from v0.7.7 to v0.7.8-rc1.
### ✨ New Features
- 🔍 feat: Mistral OCR API / Upload Files as Text by **@danny-avila** in [#6274](https://github.com/danny-avila/LibreChat/pull/6274)
- 🤖 feat: Support OpenAI Web Search models by **@danny-avila** in [#6313](https://github.com/danny-avila/LibreChat/pull/6313)
- 🔗 feat: Agent Chain (Mixture-of-Agents) by **@danny-avila** in [#6374](https://github.com/danny-avila/LibreChat/pull/6374)
- ⌛ feat: `initTimeout` for Slow Starting MCP Servers by **@perweij** in [#6383](https://github.com/danny-avila/LibreChat/pull/6383)
- 🚀 feat: `S3` Integration for File handling and Image uploads by **@rubentalstra** in [#6142](https://github.com/danny-avila/LibreChat/pull/6142)
- 🔒feat: Enable OpenID Auto-Redirect by **@leondape** in [#6066](https://github.com/danny-avila/LibreChat/pull/6066)
- 🚀 feat: Integrate `Azure Blob Storage` for file handling and image uploads by **@rubentalstra** in [#6153](https://github.com/danny-avila/LibreChat/pull/6153)
- 🚀 feat: Add support for custom `AWS` endpoint in `S3` by **@rubentalstra** in [#6431](https://github.com/danny-avila/LibreChat/pull/6431)
- 🚀 feat: Add support for LDAP STARTTLS in LDAP authentication by **@rubentalstra** in [#6438](https://github.com/danny-avila/LibreChat/pull/6438)
- 🚀 feat: Refactor schema exports and update package version to 0.0.4 by **@rubentalstra** in [#6455](https://github.com/danny-avila/LibreChat/pull/6455)
- 🔼 feat: Add Auto Submit For URL Query Params by **@mjaverto** in [#6440](https://github.com/danny-avila/LibreChat/pull/6440)
- 🛠 feat: Enhance Redis Integration, Rate Limiters & Log Headers by **@danny-avila** in [#6462](https://github.com/danny-avila/LibreChat/pull/6462)
- 💵 feat: Add Automatic Balance Refill by **@rubentalstra** in [#6452](https://github.com/danny-avila/LibreChat/pull/6452)
- 🗣️ feat: add support for gpt-4o-transcribe models by **@berry-13** in [#6483](https://github.com/danny-avila/LibreChat/pull/6483)
- 🎨 feat: UI Refresh for Enhanced UX by **@berry-13** in [#6346](https://github.com/danny-avila/LibreChat/pull/6346)
- 🌍 feat: Add support for Hungarian language localization by **@rubentalstra** in [#6508](https://github.com/danny-avila/LibreChat/pull/6508)
- 🚀 feat: Add Gemini 2.5 Token/Context Values, Increase Max Possible Output to 64k by **@danny-avila** in [#6563](https://github.com/danny-avila/LibreChat/pull/6563)
- 🚀 feat: Enhance MCP Connections For Multi-User Support by **@danny-avila** in [#6610](https://github.com/danny-avila/LibreChat/pull/6610)
- 🚀 feat: Enhance S3 URL Expiry with Refresh; fix: S3 File Deletion by **@danny-avila** in [#6647](https://github.com/danny-avila/LibreChat/pull/6647)
- 🚀 feat: enhance UI components and refactor settings by **@berry-13** in [#6625](https://github.com/danny-avila/LibreChat/pull/6625)
- 💬 feat: move TemporaryChat to the Header by **@berry-13** in [#6646](https://github.com/danny-avila/LibreChat/pull/6646)
- 🚀 feat: Use Model Specs + Specific Endpoints, Limit Providers for Agents by **@danny-avila** in [#6650](https://github.com/danny-avila/LibreChat/pull/6650)
- 🪙 feat: Sync Balance Config on Login by **@danny-avila** in [#6671](https://github.com/danny-avila/LibreChat/pull/6671)
- 🔦 feat: MCP Support for Non-Agent Endpoints by **@danny-avila** in [#6775](https://github.com/danny-avila/LibreChat/pull/6775)
- 🗃️ feat: Code Interpreter File Persistence between Sessions by **@danny-avila** in [#6790](https://github.com/danny-avila/LibreChat/pull/6790)
- 🖥️ feat: Code Interpreter API for Non-Agent Endpoints by **@danny-avila** in [#6803](https://github.com/danny-avila/LibreChat/pull/6803)
- ⚡ feat: Self-hosted Artifacts Static Bundler URL by **@danny-avila** in [#6827](https://github.com/danny-avila/LibreChat/pull/6827)
- 🐳 feat: Add Jemalloc and UV to Docker Builds by **@danny-avila** in [#6836](https://github.com/danny-avila/LibreChat/pull/6836)
- 🤖 feat: GPT-4.1 by **@danny-avila** in [#6880](https://github.com/danny-avila/LibreChat/pull/6880)
- 👋 feat: remove Edge TTS by **@berry-13** in [#6885](https://github.com/danny-avila/LibreChat/pull/6885)
- feat: nav optimization by **@berry-13** in [#5785](https://github.com/danny-avila/LibreChat/pull/5785)
- 🗺️ feat: Add Parameter Location Mapping for OpenAPI actions by **@peeeteeer** in [#6858](https://github.com/danny-avila/LibreChat/pull/6858)
- 🤖 feat: Support `o4-mini` and `o3` Models by **@danny-avila** in [#6928](https://github.com/danny-avila/LibreChat/pull/6928)
- 🎨 feat: OpenAI Image Tools (GPT-Image-1) by **@danny-avila** in [#7079](https://github.com/danny-avila/LibreChat/pull/7079)
- 🗓️ feat: Add Special Variables for Prompts & Agents, Prompt UI Improvements by **@danny-avila** in [#7123](https://github.com/danny-avila/LibreChat/pull/7123)
### 🌍 Internationalization
- 🌍 i18n: Add Thai Language Support and Update Translations by **@rubentalstra** in [#6219](https://github.com/danny-avila/LibreChat/pull/6219)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6220](https://github.com/danny-avila/LibreChat/pull/6220)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6240](https://github.com/danny-avila/LibreChat/pull/6240)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6241](https://github.com/danny-avila/LibreChat/pull/6241)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6277](https://github.com/danny-avila/LibreChat/pull/6277)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6414](https://github.com/danny-avila/LibreChat/pull/6414)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6505](https://github.com/danny-avila/LibreChat/pull/6505)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6530](https://github.com/danny-avila/LibreChat/pull/6530)
- 🌍 i18n: Add Persian Localization Support by **@rubentalstra** in [#6669](https://github.com/danny-avila/LibreChat/pull/6669)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6667](https://github.com/danny-avila/LibreChat/pull/6667)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7126](https://github.com/danny-avila/LibreChat/pull/7126)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7148](https://github.com/danny-avila/LibreChat/pull/7148)
### 👐 Accessibility
- 🎨 a11y: Update Model Spec Description Text by **@berry-13** in [#6294](https://github.com/danny-avila/LibreChat/pull/6294)
- 🗑️ a11y: Add Accessible Name to Button for File Attachment Removal by **@kangabell** in [#6709](https://github.com/danny-avila/LibreChat/pull/6709)
- ⌨️ a11y: enhance accessibility & visual consistency by **@berry-13** in [#6866](https://github.com/danny-avila/LibreChat/pull/6866)
- 🙌 a11y: Searchbar/Conversations List Focus by **@danny-avila** in [#7096](https://github.com/danny-avila/LibreChat/pull/7096)
- 👐 a11y: Improve Fork and SplitText Accessibility by **@danny-avila** in [#7147](https://github.com/danny-avila/LibreChat/pull/7147)
### 🔧 Fixes
- 🐛 fix: Avatar Type Definitions in Agent/Assistant Schemas by **@danny-avila** in [#6235](https://github.com/danny-avila/LibreChat/pull/6235)
- 🔧 fix: MeiliSearch Field Error and Patch Incorrect Import by #6210 by **@rubentalstra** in [#6245](https://github.com/danny-avila/LibreChat/pull/6245)
- 🔏 fix: Enhance Two-Factor Authentication by **@rubentalstra** in [#6247](https://github.com/danny-avila/LibreChat/pull/6247)
- 🐛 fix: Await saveMessage in abortMiddleware to ensure proper execution by **@sh4shii** in [#6248](https://github.com/danny-avila/LibreChat/pull/6248)
- 🔧 fix: Axios Proxy Usage And Bump `mongoose` by **@danny-avila** in [#6298](https://github.com/danny-avila/LibreChat/pull/6298)
- 🔧 fix: comment out MCP servers to resolve service run issues by **@KunalScriptz** in [#6316](https://github.com/danny-avila/LibreChat/pull/6316)
- 🔧 fix: Update Token Calculations and Mapping, MCP `env` Initialization by **@danny-avila** in [#6406](https://github.com/danny-avila/LibreChat/pull/6406)
- 🐞 fix: Agent "Resend" Message Attachments + Source Icon Styling by **@danny-avila** in [#6408](https://github.com/danny-avila/LibreChat/pull/6408)
- 🐛 fix: Prevent Crash on Duplicate Message ID by **@Odrec** in [#6392](https://github.com/danny-avila/LibreChat/pull/6392)
- 🔐 fix: Invalid Key Length in 2FA Encryption by **@rubentalstra** in [#6432](https://github.com/danny-avila/LibreChat/pull/6432)
- 🏗️ fix: Fix Agents Token Spend Race Conditions, Expand Test Coverage by **@danny-avila** in [#6480](https://github.com/danny-avila/LibreChat/pull/6480)
- 🔃 fix: Draft Clearing, Claude Titles, Remove Default Vision Max Tokens by **@danny-avila** in [#6501](https://github.com/danny-avila/LibreChat/pull/6501)
- 🔧 fix: Update username reference to use user.name in greeting display by **@rubentalstra** in [#6534](https://github.com/danny-avila/LibreChat/pull/6534)
- 🔧 fix: S3 Download Stream with Key Extraction and Blob Storage Encoding for Vision by **@danny-avila** in [#6557](https://github.com/danny-avila/LibreChat/pull/6557)
- 🔧 fix: Mistral type strictness for `usage` & update token values/windows by **@danny-avila** in [#6562](https://github.com/danny-avila/LibreChat/pull/6562)
- 🔧 fix: Consolidate Text Parsing and TTS Edge Initialization by **@danny-avila** in [#6582](https://github.com/danny-avila/LibreChat/pull/6582)
- 🔧 fix: Ensure continuation in image processing on base64 encoding from Blob Storage by **@danny-avila** in [#6619](https://github.com/danny-avila/LibreChat/pull/6619)
- ✉️ fix: Fallback For User Name In Email Templates by **@danny-avila** in [#6620](https://github.com/danny-avila/LibreChat/pull/6620)
- 🔧 fix: Azure Blob Integration and File Source References by **@rubentalstra** in [#6575](https://github.com/danny-avila/LibreChat/pull/6575)
- 🐛 fix: Safeguard against undefined addedEndpoints by **@wipash** in [#6654](https://github.com/danny-avila/LibreChat/pull/6654)
- 🤖 fix: Gemini 2.5 Vision Support by **@danny-avila** in [#6663](https://github.com/danny-avila/LibreChat/pull/6663)
- 🔄 fix: Avatar & Error Handling Enhancements by **@danny-avila** in [#6687](https://github.com/danny-avila/LibreChat/pull/6687)
- 🔧 fix: Chat Middleware, Zod Conversion, Auto-Save and S3 URL Refresh by **@danny-avila** in [#6720](https://github.com/danny-avila/LibreChat/pull/6720)
- 🔧 fix: Agent Capability Checks & DocumentDB Compatibility for Agent Resource Removal by **@danny-avila** in [#6726](https://github.com/danny-avila/LibreChat/pull/6726)
- 🔄 fix: Improve audio MIME type detection and handling by **@berry-13** in [#6707](https://github.com/danny-avila/LibreChat/pull/6707)
- 🪺 fix: Update Role Handling due to New Schema Shape by **@danny-avila** in [#6774](https://github.com/danny-avila/LibreChat/pull/6774)
- 🗨️ fix: Show ModelSpec Greeting by **@berry-13** in [#6770](https://github.com/danny-avila/LibreChat/pull/6770)
- 🔧 fix: Keyv and Proxy Issues, and More Memory Optimizations by **@danny-avila** in [#6867](https://github.com/danny-avila/LibreChat/pull/6867)
- ✨ fix: Implement dynamic text sizing for greeting and name display by **@berry-13** in [#6833](https://github.com/danny-avila/LibreChat/pull/6833)
- 📝 fix: Mistral OCR Image Support and Azure Agent Titles by **@danny-avila** in [#6901](https://github.com/danny-avila/LibreChat/pull/6901)
- 📢 fix: Invalid `engineTTS` and Conversation State on Navigation by **@berry-13** in [#6904](https://github.com/danny-avila/LibreChat/pull/6904)
- 🛠️ fix: Improve Accessibility and Display of Conversation Menu by **@danny-avila** in [#6913](https://github.com/danny-avila/LibreChat/pull/6913)
- 🔧 fix: Agent Resource Form, Convo Menu Style, Ensure Draft Clears on Submission by **@danny-avila** in [#6925](https://github.com/danny-avila/LibreChat/pull/6925)
- 🔀 fix: MCP Improvements, Auto-Save Drafts, Artifact Markup by **@danny-avila** in [#7040](https://github.com/danny-avila/LibreChat/pull/7040)
- 🐋 fix: Improve Deepseek Compatbility by **@danny-avila** in [#7132](https://github.com/danny-avila/LibreChat/pull/7132)
- 🐙 fix: Add Redis Ping Interval to Prevent Connection Drops by **@peeeteeer** in [#7127](https://github.com/danny-avila/LibreChat/pull/7127)
### ⚙️ Other Changes
- 📦 refactor: Move DB Models to `@librechat/data-schemas` by **@rubentalstra** in [#6210](https://github.com/danny-avila/LibreChat/pull/6210)
- 📦 chore: Patch `axios` to address CVE-2025-27152 by **@danny-avila** in [#6222](https://github.com/danny-avila/LibreChat/pull/6222)
- ⚠️ refactor: Use Error Content Part Instead Of Throwing Error for Agents by **@danny-avila** in [#6262](https://github.com/danny-avila/LibreChat/pull/6262)
- 🏃‍♂️ refactor: Improve Agent Run Context & Misc. Changes by **@danny-avila** in [#6448](https://github.com/danny-avila/LibreChat/pull/6448)
- 📝 docs: librechat.example.yaml by **@ineiti** in [#6442](https://github.com/danny-avila/LibreChat/pull/6442)
- 🏃‍♂️ refactor: More Agent Context Improvements during Run by **@danny-avila** in [#6477](https://github.com/danny-avila/LibreChat/pull/6477)
- 🔃 refactor: Allow streaming for `o1` models by **@danny-avila** in [#6509](https://github.com/danny-avila/LibreChat/pull/6509)
- 🔧 chore: `Vite` Plugin Upgrades & Config Optimizations by **@rubentalstra** in [#6547](https://github.com/danny-avila/LibreChat/pull/6547)
- 🔧 refactor: Consolidate Logging, Model Selection & Actions Optimizations, Minor Fixes by **@danny-avila** in [#6553](https://github.com/danny-avila/LibreChat/pull/6553)
- 🎨 style: Address Minor UI Refresh Issues by **@berry-13** in [#6552](https://github.com/danny-avila/LibreChat/pull/6552)
- 🔧 refactor: Enhance Model & Endpoint Configurations with Global Indicators 🌍 by **@berry-13** in [#6578](https://github.com/danny-avila/LibreChat/pull/6578)
- 💬 style: Chat UI, Greeting, and Message adjustments by **@berry-13** in [#6612](https://github.com/danny-avila/LibreChat/pull/6612)
- ⚡ refactor: DocumentDB Compatibility for Balance Updates by **@danny-avila** in [#6673](https://github.com/danny-avila/LibreChat/pull/6673)
- 🧹 chore: Update ESLint rules for React hooks by **@rubentalstra** in [#6685](https://github.com/danny-avila/LibreChat/pull/6685)
- 🪙 chore: Update Gemini Pricing by **@RedwindA** in [#6731](https://github.com/danny-avila/LibreChat/pull/6731)
- 🪺 refactor: Nest Permission fields for Roles by **@rubentalstra** in [#6487](https://github.com/danny-avila/LibreChat/pull/6487)
- 📦 chore: Update `caniuse-lite` dependency to version 1.0.30001706 by **@rubentalstra** in [#6482](https://github.com/danny-avila/LibreChat/pull/6482)
- ⚙️ refactor: OAuth Flow Signal, Type Safety, Tool Progress & Updated Packages by **@danny-avila** in [#6752](https://github.com/danny-avila/LibreChat/pull/6752)
- 📦 chore: bump vite from 6.2.3 to 6.2.5 by **@dependabot[bot]** in [#6745](https://github.com/danny-avila/LibreChat/pull/6745)
- 💾 chore: Enhance Local Storage Handling and Update MCP SDK by **@danny-avila** in [#6809](https://github.com/danny-avila/LibreChat/pull/6809)
- 🤖 refactor: Improve Agents Memory Usage, Bump Keyv, Grok 3 by **@danny-avila** in [#6850](https://github.com/danny-avila/LibreChat/pull/6850)
- 💾 refactor: Enhance Memory In Image Encodings & Client Disposal by **@danny-avila** in [#6852](https://github.com/danny-avila/LibreChat/pull/6852)
- 🔁 refactor: Token Event Handler and Standardize `maxTokens` Key by **@danny-avila** in [#6886](https://github.com/danny-avila/LibreChat/pull/6886)
- 🔍 refactor: Search & Message Retrieval by **@berry-13** in [#6903](https://github.com/danny-avila/LibreChat/pull/6903)
- 🎨 style: standardize dropdown styling & fix z-Index layering by **@berry-13** in [#6939](https://github.com/danny-avila/LibreChat/pull/6939)
- 📙 docs: CONTRIBUTING.md by **@dblock** in [#6831](https://github.com/danny-avila/LibreChat/pull/6831)
- 🧭 refactor: Modernize Nav/Header by **@danny-avila** in [#7094](https://github.com/danny-avila/LibreChat/pull/7094)
- 🪶 refactor: Chat Input Focus for Conversation Navigations & ChatForm Optimizations by **@danny-avila** in [#7100](https://github.com/danny-avila/LibreChat/pull/7100)
- 🔃 refactor: Streamline Navigation, Message Loading UX by **@danny-avila** in [#7118](https://github.com/danny-avila/LibreChat/pull/7118)
- 📜 docs: Unreleased changelog by **@github-actions[bot]** in [#6265](https://github.com/danny-avila/LibreChat/pull/6265)
[See full release details][release-v0.7.8-rc1]
[release-v0.7.8-rc1]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8-rc1
---

1
CLAUDE.md Symbolic link
View file

@ -0,0 +1 @@
AGENTS.md

View file

@ -1,4 +1,4 @@
# v0.8.2-rc2
# v0.8.3
# Base node image
FROM node:20-alpine AS node

View file

@ -1,5 +1,5 @@
# Dockerfile.multi
# v0.8.2-rc2
# v0.8.3
# Set configurable max-old-space-size with default
ARG NODE_MAX_OLD_SPACE_SIZE=6144

View file

@ -27,8 +27,8 @@
</p>
<p align="center">
<a href="https://railway.app/template/b5k2mn?referralCode=HI9hWz">
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="30">
<a href="https://railway.com/deploy/b5k2mn?referralCode=HI9hWz">
<img src="https://railway.com/button.svg" alt="Deploy on Railway" height="30">
</a>
<a href="https://zeabur.com/templates/0X2ZY8">
<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30"/>
@ -109,6 +109,11 @@
- 🎨 **Customizable Interface**:
- Customizable Dropdown & Interface that adapts to both power users and newcomers
- 🌊 **[Resumable Streams](https://www.librechat.ai/docs/features/resumable_streams)**:
- Never lose a response: AI responses automatically reconnect and resume if your connection drops
- Multi-Tab & Multi-Device Sync: Open the same chat in multiple tabs or pick up on another device
- Production-Ready: Works from single-server setups to horizontally scaled deployments with Redis
- 🗣️ **Speech & Audio**:
- Chat hands-free with Speech-to-Text and Text-to-Speech
- Automatically send and play Audio
@ -137,13 +142,11 @@
## 🪶 All-In-One AI Conversations with LibreChat
LibreChat brings together the future of assistant AIs with the revolutionary technology of OpenAI's ChatGPT. Celebrating the original styling, LibreChat gives you the ability to integrate multiple AI models. It also integrates and enhances original client features such as conversation and message search, prompt templates and plugins.
LibreChat is a self-hosted AI chat platform that unifies all major AI providers in a single, privacy-focused interface.
With LibreChat, you no longer need to opt for ChatGPT Plus and can instead use free or pay-per-call APIs. We welcome contributions, cloning, and forking to enhance the capabilities of this advanced chatbot platform.
Beyond chat, LibreChat provides AI Agents, Model Context Protocol (MCP) support, Artifacts, Code Interpreter, custom actions, conversation search, and enterprise-ready multi-user authentication.
[![Watch the video](https://raw.githubusercontent.com/LibreChat-AI/librechat.ai/main/public/images/changelog/v0.7.6.gif)](https://www.youtube.com/watch?v=ilfwGQtJNlI)
Click on the thumbnail to open the video☝
Open source, actively developed, and built for anyone who values control over their AI infrastructure.
---

View file

@ -4,6 +4,7 @@ const { logger } = require('@librechat/data-schemas');
const {
countTokens,
getBalanceConfig,
buildMessageFiles,
extractFileContext,
encodeAndFormatAudios,
encodeAndFormatVideos,
@ -20,6 +21,7 @@ const {
isAgentsEndpoint,
isEphemeralAgentId,
supportsBalanceCheck,
isBedrockDocumentType,
} = require('librechat-data-provider');
const {
updateMessage,
@ -122,7 +124,9 @@ class BaseClient {
* @returns {number}
*/
getTokenCountForResponse(responseMessage) {
logger.debug('[BaseClient] `recordTokenUsage` not implemented.', responseMessage);
logger.debug('[BaseClient] `recordTokenUsage` not implemented.', {
messageId: responseMessage?.messageId,
});
}
/**
@ -133,12 +137,14 @@ class BaseClient {
* @param {AppConfig['balance']} [balance]
* @param {number} promptTokens
* @param {number} completionTokens
* @param {string} [messageId]
* @returns {Promise<void>}
*/
async recordTokenUsage({ model, balance, promptTokens, completionTokens }) {
async recordTokenUsage({ model, balance, promptTokens, completionTokens, messageId }) {
logger.debug('[BaseClient] `recordTokenUsage` not implemented.', {
model,
balance,
messageId,
promptTokens,
completionTokens,
});
@ -659,16 +665,27 @@ class BaseClient {
);
if (tokenCountMap) {
logger.debug('[BaseClient] tokenCountMap', tokenCountMap);
if (tokenCountMap[userMessage.messageId]) {
userMessage.tokenCount = tokenCountMap[userMessage.messageId];
logger.debug('[BaseClient] userMessage', userMessage);
logger.debug('[BaseClient] userMessage', {
messageId: userMessage.messageId,
tokenCount: userMessage.tokenCount,
conversationId: userMessage.conversationId,
});
}
this.handleTokenCountMap(tokenCountMap);
}
if (!isEdited && !this.skipSaveUserMessage) {
const reqFiles = this.options.req?.body?.files;
if (reqFiles && Array.isArray(this.options.attachments)) {
const files = buildMessageFiles(reqFiles, this.options.attachments);
if (files.length > 0) {
userMessage.files = files;
}
delete userMessage.image_urls;
}
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
this.savedMessageIds.add(userMessage.messageId);
if (typeof opts?.getReqData === 'function') {
@ -780,9 +797,18 @@ class BaseClient {
promptTokens,
completionTokens,
balance: balanceConfig,
model: responseMessage.model,
/** Note: When using agents, responseMessage.model is the agent ID, not the model */
model: this.model,
messageId: this.responseMessageId,
});
}
logger.debug('[BaseClient] Response token usage', {
messageId: responseMessage.messageId,
model: responseMessage.model,
promptTokens,
completionTokens,
});
}
if (userMessagePromise) {
@ -1300,6 +1326,9 @@ class BaseClient {
const allFiles = [];
const provider = this.options.agent?.provider ?? this.options.endpoint;
const isBedrock = provider === EModelEndpoint.bedrock;
for (const file of attachments) {
/** @type {FileSources} */
const source = file.source ?? FileSources.local;
@ -1317,6 +1346,9 @@ class BaseClient {
} else if (file.type === 'application/pdf') {
categorizedAttachments.documents.push(file);
allFiles.push(file);
} else if (isBedrock && isBedrockDocumentType(file.type)) {
categorizedAttachments.documents.push(file);
allFiles.push(file);
} else if (file.type.startsWith('video/')) {
categorizedAttachments.videos.push(file);
allFiles.push(file);

View file

@ -41,9 +41,9 @@ jest.mock('~/models', () => ({
const { getConvo, saveConvo } = require('~/models');
jest.mock('@librechat/agents', () => {
const { Providers } = jest.requireActual('@librechat/agents');
const actual = jest.requireActual('@librechat/agents');
return {
Providers,
...actual,
ChatOpenAI: jest.fn().mockImplementation(() => {
return {};
}),
@ -821,6 +821,56 @@ describe('BaseClient', () => {
});
});
describe('recordTokenUsage model assignment', () => {
test('should pass this.model to recordTokenUsage, not the agent ID from responseMessage.model', async () => {
const actualModel = 'claude-opus-4-5';
const agentId = 'agent_p5Z_IU6EIxBoqn1BoqLBp';
TestClient.model = actualModel;
TestClient.options.endpoint = 'agents';
TestClient.options.agent = { id: agentId };
TestClient.getTokenCountForResponse = jest.fn().mockReturnValue(50);
TestClient.recordTokenUsage = jest.fn().mockResolvedValue(undefined);
TestClient.buildMessages.mockReturnValue({
prompt: [],
tokenCountMap: { res: 50 },
});
await TestClient.sendMessage('Hello', {});
expect(TestClient.recordTokenUsage).toHaveBeenCalledWith(
expect.objectContaining({
model: actualModel,
}),
);
const callArgs = TestClient.recordTokenUsage.mock.calls[0][0];
expect(callArgs.model).not.toBe(agentId);
});
test('should pass this.model even when this.model differs from modelOptions.model', async () => {
const instanceModel = 'gpt-4o';
TestClient.model = instanceModel;
TestClient.modelOptions = { model: 'gpt-4o-mini' };
TestClient.getTokenCountForResponse = jest.fn().mockReturnValue(50);
TestClient.recordTokenUsage = jest.fn().mockResolvedValue(undefined);
TestClient.buildMessages.mockReturnValue({
prompt: [],
tokenCountMap: { res: 50 },
});
await TestClient.sendMessage('Hello', {});
expect(TestClient.recordTokenUsage).toHaveBeenCalledWith(
expect.objectContaining({
model: instanceModel,
}),
);
});
});
describe('getMessagesWithinTokenLimit with instructions', () => {
test('should always include instructions when present', async () => {
TestClient.maxContextTokens = 50;
@ -928,4 +978,123 @@ describe('BaseClient', () => {
expect(result.remainingContextTokens).toBe(2); // 25 - 20 - 3(assistant label)
});
});
describe('sendMessage file population', () => {
const attachment = {
file_id: 'file-abc',
filename: 'image.png',
filepath: '/uploads/image.png',
type: 'image/png',
bytes: 1024,
object: 'file',
user: 'user-1',
embedded: false,
usage: 0,
text: 'large ocr blob that should be stripped',
_id: 'mongo-id-1',
};
beforeEach(() => {
TestClient.options.req = { body: { files: [{ file_id: 'file-abc' }] } };
TestClient.options.attachments = [attachment];
});
test('populates userMessage.files before saveMessageToDatabase is called', async () => {
TestClient.saveMessageToDatabase = jest.fn().mockImplementation((msg) => {
return Promise.resolve({ message: msg });
});
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave).toBeDefined();
expect(userSave[0].files).toBeDefined();
expect(userSave[0].files).toHaveLength(1);
expect(userSave[0].files[0].file_id).toBe('file-abc');
});
test('strips text and _id from files before saving', async () => {
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files[0].text).toBeUndefined();
expect(userSave[0].files[0]._id).toBeUndefined();
expect(userSave[0].files[0].filename).toBe('image.png');
});
test('deletes image_urls from userMessage when files are present', async () => {
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
TestClient.options.attachments = [
{ ...attachment, image_urls: ['data:image/png;base64,...'] },
];
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].image_urls).toBeUndefined();
});
test('does not set files when no attachments match request file IDs', async () => {
TestClient.options.req = { body: { files: [{ file_id: 'file-nomatch' }] } };
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files).toBeUndefined();
});
test('skips file population when attachments is not an array (Promise case)', async () => {
TestClient.options.attachments = Promise.resolve([attachment]);
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files).toBeUndefined();
});
test('skips file population when skipSaveUserMessage is true', async () => {
TestClient.skipSaveUserMessage = true;
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg?.isCreatedByUser,
);
expect(userSave).toBeUndefined();
});
test('ignores file_id: undefined entries in req.body.files (no set poisoning)', async () => {
TestClient.options.req = {
body: { files: [{ file_id: undefined }, { file_id: 'file-abc' }] },
};
TestClient.options.attachments = [
{ ...attachment, file_id: undefined },
{ ...attachment, file_id: 'file-abc' },
];
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files).toHaveLength(1);
expect(userSave[0].files[0].file_id).toBe('file-abc');
});
});
});

View file

@ -5,7 +5,6 @@ const DALLE3 = require('./structured/DALLE3');
const FluxAPI = require('./structured/FluxAPI');
const OpenWeather = require('./structured/OpenWeather');
const StructuredWolfram = require('./structured/Wolfram');
const createYouTubeTools = require('./structured/YouTube');
const StructuredACS = require('./structured/AzureAISearch');
const StructuredSD = require('./structured/StableDiffusion');
const GoogleSearchAPI = require('./structured/GoogleSearch');
@ -25,7 +24,6 @@ module.exports = {
GoogleSearchAPI,
TraversaalSearch,
StructuredWolfram,
createYouTubeTools,
TavilySearchResults,
createOpenAIImageTools,
createGeminiImageTool,

View file

@ -16,7 +16,7 @@
"name": "Google",
"pluginKey": "google",
"description": "Use Google Search to find information about the weather, news, sports, and more.",
"icon": "https://i.imgur.com/SMmVkNB.png",
"icon": "assets/google-search.svg",
"authConfig": [
{
"authField": "GOOGLE_CSE_ID",
@ -30,20 +30,6 @@
}
]
},
{
"name": "YouTube",
"pluginKey": "youtube",
"toolkit": true,
"description": "Get YouTube video information, retrieve comments, analyze transcripts and search for videos.",
"icon": "https://www.youtube.com/s/desktop/7449ebf7/img/favicon_144x144.png",
"authConfig": [
{
"authField": "YOUTUBE_API_KEY",
"label": "YouTube API Key",
"description": "Your YouTube Data API v3 key."
}
]
},
{
"name": "OpenAI Image Tools",
"pluginKey": "image_gen_oai",
@ -71,24 +57,11 @@
}
]
},
{
"name": "Browser",
"pluginKey": "web-browser",
"description": "Scrape and summarize webpage data",
"icon": "assets/web-browser.svg",
"authConfig": [
{
"authField": "OPENAI_API_KEY",
"label": "OpenAI API Key",
"description": "Browser makes use of OpenAI embeddings"
}
]
},
{
"name": "DALL-E-3",
"pluginKey": "dalle",
"description": "[DALL-E-3] Create realistic images and art from a description in natural language",
"icon": "https://i.imgur.com/u2TzXzH.png",
"icon": "assets/openai.svg",
"authConfig": [
{
"authField": "DALLE3_API_KEY||DALLE_API_KEY",
@ -101,7 +74,7 @@
"name": "Tavily Search",
"pluginKey": "tavily_search_results_json",
"description": "Tavily Search is a robust search API tailored for LLM Agents. It seamlessly integrates with diverse data sources to ensure a superior, relevant search experience.",
"icon": "https://tavily.com/favicon.ico",
"icon": "assets/tavily.svg",
"authConfig": [
{
"authField": "TAVILY_API_KEY",
@ -114,14 +87,14 @@
"name": "Calculator",
"pluginKey": "calculator",
"description": "Perform simple and complex mathematical calculations.",
"icon": "https://i.imgur.com/RHsSG5h.png",
"icon": "assets/calculator.svg",
"authConfig": []
},
{
"name": "Stable Diffusion",
"pluginKey": "stable-diffusion",
"description": "Generate photo-realistic images given any text input.",
"icon": "https://i.imgur.com/Yr466dp.png",
"icon": "assets/stability-ai.svg",
"authConfig": [
{
"authField": "SD_WEBUI_URL",
@ -134,7 +107,7 @@
"name": "Azure AI Search",
"pluginKey": "azure-ai-search",
"description": "Use Azure AI Search to find information",
"icon": "https://i.imgur.com/E7crPze.png",
"icon": "assets/azure-ai-search.svg",
"authConfig": [
{
"authField": "AZURE_AI_SEARCH_SERVICE_ENDPOINT",
@ -170,7 +143,7 @@
"name": "Flux",
"pluginKey": "flux",
"description": "Generate images using text with the Flux API.",
"icon": "https://blackforestlabs.ai/wp-content/uploads/2024/07/bfl_logo_retraced_blk.png",
"icon": "assets/bfl-ai.svg",
"isAuthRequired": "true",
"authConfig": [
{
@ -183,14 +156,14 @@
{
"name": "Gemini Image Tools",
"pluginKey": "gemini_image_gen",
"toolkit": true,
"description": "Generate high-quality images using Google's Gemini Image Models. Supports Gemini API or Vertex AI.",
"icon": "assets/gemini_image_gen.svg",
"authConfig": [
{
"authField": "GEMINI_API_KEY||GOOGLE_KEY||GEMINI_VERTEX_ENABLED",
"label": "Gemini API Key (Optional if Vertex AI is configured)",
"description": "Your Google Gemini API Key from <a href='https://aistudio.google.com/app/apikey' target='_blank'>Google AI Studio</a>. Leave blank if using Vertex AI with service account."
"authField": "GEMINI_API_KEY||GOOGLE_KEY||GOOGLE_SERVICE_KEY_FILE",
"label": "Gemini API Key (optional)",
"description": "Your Google Gemini API Key from <a href='https://aistudio.google.com/app/apikey' target='_blank'>Google AI Studio</a>. Leave blank to use Vertex AI with a service account (GOOGLE_SERVICE_KEY_FILE or api/data/auth.json).",
"optional": true
}
]
}

View file

@ -1,14 +1,28 @@
const { z } = require('zod');
const { Tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { SearchClient, AzureKeyCredential } = require('@azure/search-documents');
const azureAISearchJsonSchema = {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search word or phrase to Azure AI Search',
},
},
required: ['query'],
};
class AzureAISearch extends Tool {
// Constants for default values
static DEFAULT_API_VERSION = '2023-11-01';
static DEFAULT_QUERY_TYPE = 'simple';
static DEFAULT_TOP = 5;
static get jsonSchema() {
return azureAISearchJsonSchema;
}
// Helper function for initializing properties
_initializeField(field, envVar, defaultValue) {
return field || process.env[envVar] || defaultValue;
@ -22,10 +36,7 @@ class AzureAISearch extends Tool {
/* Used to initialize the Tool without necessary variables. */
this.override = fields.override ?? false;
// Define schema
this.schema = z.object({
query: z.string().describe('Search word or phrase to Azure AI Search'),
});
this.schema = azureAISearchJsonSchema;
// Initialize properties using helper function
this.serviceEndpoint = this._initializeField(

View file

@ -1,4 +1,3 @@
const { z } = require('zod');
const path = require('path');
const OpenAI = require('openai');
const { v4: uuidv4 } = require('uuid');
@ -8,6 +7,36 @@ const { logger } = require('@librechat/data-schemas');
const { getImageBasename, extractBaseURL } = require('@librechat/api');
const { FileContext, ContentTypes } = require('librechat-data-provider');
const dalle3JsonSchema = {
type: 'object',
properties: {
prompt: {
type: 'string',
maxLength: 4000,
description:
'A text description of the desired image, following the rules, up to 4000 characters.',
},
style: {
type: 'string',
enum: ['vivid', 'natural'],
description:
'Must be one of `vivid` or `natural`. `vivid` generates hyper-real and dramatic images, `natural` produces more natural, less hyper-real looking images',
},
quality: {
type: 'string',
enum: ['hd', 'standard'],
description: 'The quality of the generated image. Only `hd` and `standard` are supported.',
},
size: {
type: 'string',
enum: ['1024x1024', '1792x1024', '1024x1792'],
description:
'The size of the requested image. Use 1024x1024 (square) as the default, 1792x1024 if the user requests a wide image, and 1024x1792 for full-body portraits. Always include this parameter in the request.',
},
},
required: ['prompt', 'style', 'quality', 'size'],
};
const displayMessage =
"DALL-E displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
class DALLE3 extends Tool {
@ -72,27 +101,11 @@ class DALLE3 extends Tool {
// The prompt must intricately describe every part of the image in concrete, objective detail. THINK about what the end goal of the description is, and extrapolate that to what would make satisfying images.
// All descriptions sent to dalle should be a paragraph of text that is extremely descriptive and detailed. Each should be more than 3 sentences long.
// - The "vivid" style is HIGHLY preferred, but "natural" is also supported.`;
this.schema = z.object({
prompt: z
.string()
.max(4000)
.describe(
'A text description of the desired image, following the rules, up to 4000 characters.',
),
style: z
.enum(['vivid', 'natural'])
.describe(
'Must be one of `vivid` or `natural`. `vivid` generates hyper-real and dramatic images, `natural` produces more natural, less hyper-real looking images',
),
quality: z
.enum(['hd', 'standard'])
.describe('The quality of the generated image. Only `hd` and `standard` are supported.'),
size: z
.enum(['1024x1024', '1792x1024', '1024x1792'])
.describe(
'The size of the requested image. Use 1024x1024 (square) as the default, 1792x1024 if the user requests a wide image, and 1024x1792 for full-body portraits. Always include this parameter in the request.',
),
});
this.schema = dalle3JsonSchema;
}
static get jsonSchema() {
return dalle3JsonSchema;
}
getApiKey() {

View file

@ -1,4 +1,3 @@
const { z } = require('zod');
const axios = require('axios');
const fetch = require('node-fetch');
const { v4: uuidv4 } = require('uuid');
@ -7,6 +6,84 @@ const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { FileContext, ContentTypes } = require('librechat-data-provider');
const fluxApiJsonSchema = {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['generate', 'list_finetunes', 'generate_finetuned'],
description:
'Action to perform: "generate" for image generation, "generate_finetuned" for finetuned model generation, "list_finetunes" to get available custom models',
},
prompt: {
type: 'string',
description:
'Text prompt for image generation. Required when action is "generate". Not used for list_finetunes.',
},
width: {
type: 'number',
description:
'Width of the generated image in pixels. Must be a multiple of 32. Default is 1024.',
},
height: {
type: 'number',
description:
'Height of the generated image in pixels. Must be a multiple of 32. Default is 768.',
},
prompt_upsampling: {
type: 'boolean',
description: 'Whether to perform upsampling on the prompt.',
},
steps: {
type: 'integer',
description: 'Number of steps to run the model for, a number from 1 to 50. Default is 40.',
},
seed: {
type: 'number',
description: 'Optional seed for reproducibility.',
},
safety_tolerance: {
type: 'number',
description:
'Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict.',
},
endpoint: {
type: 'string',
enum: [
'/v1/flux-pro-1.1',
'/v1/flux-pro',
'/v1/flux-dev',
'/v1/flux-pro-1.1-ultra',
'/v1/flux-pro-finetuned',
'/v1/flux-pro-1.1-ultra-finetuned',
],
description: 'Endpoint to use for image generation.',
},
raw: {
type: 'boolean',
description:
'Generate less processed, more natural-looking images. Only works for /v1/flux-pro-1.1-ultra.',
},
finetune_id: {
type: 'string',
description: 'ID of the finetuned model to use',
},
finetune_strength: {
type: 'number',
description: 'Strength of the finetuning effect (typically between 0.1 and 1.2)',
},
guidance: {
type: 'number',
description: 'Guidance scale for finetuned models',
},
aspect_ratio: {
type: 'string',
description: 'Aspect ratio for ultra models (e.g., "16:9")',
},
},
required: [],
};
const displayMessage =
"Flux displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
@ -57,82 +134,11 @@ class FluxAPI extends Tool {
// Add base URL from environment variable with fallback
this.baseUrl = process.env.FLUX_API_BASE_URL || 'https://api.us1.bfl.ai';
// Define the schema for structured input
this.schema = z.object({
action: z
.enum(['generate', 'list_finetunes', 'generate_finetuned'])
.default('generate')
.describe(
'Action to perform: "generate" for image generation, "generate_finetuned" for finetuned model generation, "list_finetunes" to get available custom models',
),
prompt: z
.string()
.optional()
.describe(
'Text prompt for image generation. Required when action is "generate". Not used for list_finetunes.',
),
width: z
.number()
.optional()
.describe(
'Width of the generated image in pixels. Must be a multiple of 32. Default is 1024.',
),
height: z
.number()
.optional()
.describe(
'Height of the generated image in pixels. Must be a multiple of 32. Default is 768.',
),
prompt_upsampling: z
.boolean()
.optional()
.default(false)
.describe('Whether to perform upsampling on the prompt.'),
steps: z
.number()
.int()
.optional()
.describe('Number of steps to run the model for, a number from 1 to 50. Default is 40.'),
seed: z.number().optional().describe('Optional seed for reproducibility.'),
safety_tolerance: z
.number()
.optional()
.default(6)
.describe(
'Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict.',
),
endpoint: z
.enum([
'/v1/flux-pro-1.1',
'/v1/flux-pro',
'/v1/flux-dev',
'/v1/flux-pro-1.1-ultra',
'/v1/flux-pro-finetuned',
'/v1/flux-pro-1.1-ultra-finetuned',
])
.optional()
.default('/v1/flux-pro-1.1')
.describe('Endpoint to use for image generation.'),
raw: z
.boolean()
.optional()
.default(false)
.describe(
'Generate less processed, more natural-looking images. Only works for /v1/flux-pro-1.1-ultra.',
),
finetune_id: z.string().optional().describe('ID of the finetuned model to use'),
finetune_strength: z
.number()
.optional()
.default(1.1)
.describe('Strength of the finetuning effect (typically between 0.1 and 1.2)'),
guidance: z.number().optional().default(2.5).describe('Guidance scale for finetuned models'),
aspect_ratio: z
.string()
.optional()
.default('16:9')
.describe('Aspect ratio for ultra models (e.g., "16:9")'),
});
this.schema = fluxApiJsonSchema;
}
static get jsonSchema() {
return fluxApiJsonSchema;
}
getAxiosConfig() {

View file

@ -1,16 +1,11 @@
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
const { v4 } = require('uuid');
const { ProxyAgent } = require('undici');
const { GoogleGenAI } = require('@google/genai');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const {
FileContext,
ContentTypes,
FileSources,
EImageOutputType,
} = require('librechat-data-provider');
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
const {
geminiToolkit,
loadServiceKey,
@ -21,6 +16,24 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { spendTokens } = require('~/models/spendTokens');
const { getFiles } = require('~/models/File');
/**
* Configure proxy support for Google APIs
* This wraps globalThis.fetch to add a proxy dispatcher only for googleapis.com URLs
* This is necessary because @google/genai SDK doesn't support custom fetch or httpOptions.dispatcher
*/
if (process.env.PROXY) {
const originalFetch = globalThis.fetch;
const proxyAgent = new ProxyAgent(process.env.PROXY);
globalThis.fetch = function (url, options = {}) {
const urlString = url.toString();
if (urlString.includes('googleapis.com')) {
options = { ...options, dispatcher: proxyAgent };
}
return originalFetch.call(this, url, options);
};
}
/**
* Get the default service key file path (consistent with main Google endpoint)
* @returns {string} - The default path to the service key file
@ -40,17 +53,12 @@ const displayMessage =
* @returns {string} - The processed string
*/
function replaceUnwantedChars(inputString) {
return inputString?.replace(/[^\w\s\-_.,!?()]/g, '') || '';
}
/**
* Validate and sanitize image format
* @param {string} format - The format to validate
* @returns {string} - Safe format
*/
function getSafeFormat(format) {
const allowedFormats = ['png', 'jpg', 'jpeg', 'webp', 'gif'];
return allowedFormats.includes(format?.toLowerCase()) ? format.toLowerCase() : 'png';
return (
inputString
?.replace(/\r\n|\r|\n/g, ' ')
.replace(/"/g, '')
.trim() || ''
);
}
/**
@ -98,11 +106,8 @@ async function initializeGeminiClient(options = {}) {
return new GoogleGenAI({ apiKey: googleKey });
}
// Fall back to Vertex AI with service account
logger.debug('[GeminiImageGen] Using Vertex AI with service account');
const credentialsPath = getDefaultServiceKeyPath();
// Use loadServiceKey for consistent loading (supports file paths, JSON strings, base64)
const serviceKey = await loadServiceKey(credentialsPath);
if (!serviceKey || !serviceKey.project_id) {
@ -112,75 +117,14 @@ async function initializeGeminiClient(options = {}) {
);
}
// Set GOOGLE_APPLICATION_CREDENTIALS for any Google Cloud SDK dependencies
try {
await fs.promises.access(credentialsPath);
process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
} catch {
// File doesn't exist, skip setting env var
}
return new GoogleGenAI({
vertexai: true,
project: serviceKey.project_id,
location: process.env.GOOGLE_LOC || process.env.GOOGLE_CLOUD_LOCATION || 'global',
googleAuthOptions: { credentials: serviceKey },
});
}
/**
* Save image to local filesystem
* @param {string} base64Data - Base64 encoded image data
* @param {string} format - Image format
* @param {string} userId - User ID
* @returns {Promise<string>} - The relative URL
*/
async function saveImageLocally(base64Data, format, userId) {
const safeFormat = getSafeFormat(format);
const safeUserId = userId ? path.basename(userId) : 'default';
const imageName = `gemini-img-${v4()}.${safeFormat}`;
const userDir = path.join(process.cwd(), 'client/public/images', safeUserId);
await fs.promises.mkdir(userDir, { recursive: true });
const filePath = path.join(userDir, imageName);
await fs.promises.writeFile(filePath, Buffer.from(base64Data, 'base64'));
logger.debug('[GeminiImageGen] Image saved locally to:', filePath);
return `/images/${safeUserId}/${imageName}`;
}
/**
* Save image to cloud storage
* @param {Object} params - Parameters
* @returns {Promise<string|null>} - The storage URL or null
*/
async function saveToCloudStorage({ base64Data, format, processFileURL, fileStrategy, userId }) {
if (!processFileURL || !fileStrategy || !userId) {
return null;
}
try {
const safeFormat = getSafeFormat(format);
const safeUserId = path.basename(userId);
const dataURL = `data:image/${safeFormat};base64,${base64Data}`;
const imageName = `gemini-img-${v4()}.${safeFormat}`;
const result = await processFileURL({
URL: dataURL,
basePath: 'images',
userId: safeUserId,
fileName: imageName,
fileStrategy,
context: FileContext.image_generation,
});
return result.filepath;
} catch (error) {
logger.error('[GeminiImageGen] Error saving to cloud storage:', error);
return null;
}
}
/**
* Convert image files to Gemini inline data format
* @param {Object} params - Parameters
@ -307,8 +251,9 @@ function checkForSafetyBlock(response) {
* @param {string} params.userId - The user ID
* @param {string} params.conversationId - The conversation ID
* @param {string} params.model - The model name
* @param {string} [params.messageId] - The response message ID for transaction correlation
*/
async function recordTokenUsage({ usageMetadata, req, userId, conversationId, model }) {
async function recordTokenUsage({ usageMetadata, req, userId, conversationId, model, messageId }) {
if (!usageMetadata) {
logger.debug('[GeminiImageGen] No usage metadata available for balance tracking');
return;
@ -344,6 +289,7 @@ async function recordTokenUsage({ usageMetadata, req, userId, conversationId, mo
{
user: userId,
model,
messageId,
conversationId,
context: 'image_generation',
balance,
@ -371,34 +317,18 @@ function createGeminiImageTool(fields = {}) {
throw new Error('This tool is only available for agents.');
}
// Skip validation during tool creation - validation happens at runtime in initializeGeminiClient
// This allows the tool to be added to agents when using Vertex AI without requiring API keys
// The actual credentials check happens when the tool is invoked
const {
req,
imageFiles = [],
processFileURL,
userId,
fileStrategy,
GEMINI_API_KEY,
GOOGLE_KEY,
// GEMINI_VERTEX_ENABLED is used for auth validation only (not used in code)
// When set as env var, it signals Vertex AI is configured and bypasses API key requirement
} = fields;
const { req, imageFiles = [], userId, fileStrategy, GEMINI_API_KEY, GOOGLE_KEY } = fields;
const imageOutputType = fields.imageOutputType || EImageOutputType.PNG;
const geminiImageGenTool = tool(
async ({ prompt, image_ids, aspectRatio, imageSize }, _runnableConfig) => {
async ({ prompt, image_ids, aspectRatio, imageSize }, runnableConfig) => {
if (!prompt) {
throw new Error('Missing required field: prompt');
}
logger.debug('[GeminiImageGen] Generating image with prompt:', prompt?.substring(0, 100));
logger.debug('[GeminiImageGen] Options:', { aspectRatio, imageSize });
logger.debug('[GeminiImageGen] Generating image', { aspectRatio, imageSize });
// Initialize Gemini client with user-provided credentials
let ai;
try {
ai = await initializeGeminiClient({
@ -413,10 +343,8 @@ function createGeminiImageTool(fields = {}) {
];
}
// Build request contents
const contents = [{ text: replaceUnwantedChars(prompt) }];
// Add context images if provided
if (image_ids?.length > 0) {
const contextImages = await convertImagesToInlineData({
imageFiles,
@ -428,28 +356,34 @@ function createGeminiImageTool(fields = {}) {
logger.debug('[GeminiImageGen] Added', contextImages.length, 'context images');
}
// Generate image
let apiResponse;
const geminiModel = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image';
try {
// Build config with optional imageConfig
const config = {
responseModalities: ['TEXT', 'IMAGE'],
};
const config = {
responseModalities: ['TEXT', 'IMAGE'],
};
// Add imageConfig if aspectRatio or imageSize is specified
// Note: gemini-2.5-flash-image doesn't support imageSize
const supportsImageSize = !geminiModel.includes('gemini-2.5-flash-image');
if (aspectRatio || (imageSize && supportsImageSize)) {
config.imageConfig = {};
if (aspectRatio) {
config.imageConfig.aspectRatio = aspectRatio;
}
if (imageSize && supportsImageSize) {
config.imageConfig.imageSize = imageSize;
}
const supportsImageSize = !geminiModel.includes('gemini-2.5-flash-image');
if (aspectRatio || (imageSize && supportsImageSize)) {
config.imageConfig = {};
if (aspectRatio) {
config.imageConfig.aspectRatio = aspectRatio;
}
if (imageSize && supportsImageSize) {
config.imageConfig.imageSize = imageSize;
}
}
let derivedSignal = null;
let abortHandler = null;
if (runnableConfig?.signal) {
derivedSignal = AbortSignal.any([runnableConfig.signal]);
abortHandler = () => logger.debug('[GeminiImageGen] Image generation aborted');
derivedSignal.addEventListener('abort', abortHandler, { once: true });
config.abortSignal = derivedSignal;
}
try {
apiResponse = await ai.models.generateContent({
model: geminiModel,
contents,
@ -461,9 +395,12 @@ function createGeminiImageTool(fields = {}) {
[{ type: ContentTypes.TEXT, text: `Image generation failed: ${error.message}` }],
{ content: [], file_ids: [] },
];
} finally {
if (abortHandler && derivedSignal) {
derivedSignal.removeEventListener('abort', abortHandler);
}
}
// Check for safety blocks
const safetyBlock = checkForSafetyBlock(apiResponse);
if (safetyBlock) {
logger.warn('[GeminiImageGen] Safety block:', safetyBlock);
@ -490,46 +427,7 @@ function createGeminiImageTool(fields = {}) {
const imageData = convertedBuffer.toString('base64');
const mimeType = outputFormat === 'jpeg' ? 'image/jpeg' : `image/${outputFormat}`;
logger.debug('[GeminiImageGen] Image format:', { outputFormat, mimeType });
let imageUrl;
const useLocalStorage = !fileStrategy || fileStrategy === FileSources.local;
if (useLocalStorage) {
try {
imageUrl = await saveImageLocally(imageData, outputFormat, userId);
} catch (error) {
logger.error('[GeminiImageGen] Local save failed:', error);
imageUrl = `data:${mimeType};base64,${imageData}`;
}
} else {
const cloudUrl = await saveToCloudStorage({
base64Data: imageData,
format: outputFormat,
processFileURL,
fileStrategy,
userId,
});
if (cloudUrl) {
imageUrl = cloudUrl;
} else {
// Fallback to local
try {
imageUrl = await saveImageLocally(imageData, outputFormat, userId);
} catch (_error) {
imageUrl = `data:${mimeType};base64,${imageData}`;
}
}
}
logger.debug('[GeminiImageGen] Image URL:', imageUrl);
// For the artifact, we need a data URL (same as OpenAI)
// The local file save is for persistence, but the response needs a data URL
const dataUrl = `data:${mimeType};base64,${imageData}`;
// Return in content_and_artifact format (same as OpenAI)
const file_ids = [v4()];
const content = [
{
@ -548,12 +446,15 @@ function createGeminiImageTool(fields = {}) {
},
];
// Record token usage for balance tracking (don't await to avoid blocking response)
const conversationId = _runnableConfig?.configurable?.thread_id;
const conversationId = runnableConfig?.configurable?.thread_id;
const messageId =
runnableConfig?.configurable?.run_id ??
runnableConfig?.configurable?.requestBody?.messageId;
recordTokenUsage({
usageMetadata: apiResponse.usageMetadata,
req,
userId,
messageId,
conversationId,
model: geminiModel,
}).catch((error) => {

View file

@ -1,12 +1,33 @@
const { z } = require('zod');
const { Tool } = require('@langchain/core/tools');
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
const googleSearchJsonSchema = {
type: 'object',
properties: {
query: {
type: 'string',
minLength: 1,
description: 'The search query string.',
},
max_results: {
type: 'integer',
minimum: 1,
maximum: 10,
description: 'The maximum number of search results to return. Defaults to 5.',
},
},
required: ['query'],
};
class GoogleSearchResults extends Tool {
static lc_name() {
return 'google';
}
static get jsonSchema() {
return googleSearchJsonSchema;
}
constructor(fields = {}) {
super(fields);
this.name = 'google';
@ -28,25 +49,11 @@ class GoogleSearchResults extends Tool {
this.description =
'A search engine optimized for comprehensive, accurate, and trusted results. Useful for when you need to answer questions about current events.';
this.schema = z.object({
query: z.string().min(1).describe('The search query string.'),
max_results: z
.number()
.min(1)
.max(10)
.optional()
.describe('The maximum number of search results to return. Defaults to 10.'),
// Note: Google API has its own parameters for search customization, adjust as needed.
});
this.schema = googleSearchJsonSchema;
}
async _call(input) {
const validationResult = this.schema.safeParse(input);
if (!validationResult.success) {
throw new Error(`Validation failed: ${JSON.stringify(validationResult.error.issues)}`);
}
const { query, max_results = 5 } = validationResult.data;
const { query, max_results = 5 } = input;
const response = await fetch(
`https://www.googleapis.com/customsearch/v1?key=${this.apiKey}&cx=${

View file

@ -1,8 +1,52 @@
const { Tool } = require('@langchain/core/tools');
const { z } = require('zod');
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
const fetch = require('node-fetch');
const openWeatherJsonSchema = {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['help', 'current_forecast', 'timestamp', 'daily_aggregation', 'overview'],
description: 'The action to perform',
},
city: {
type: 'string',
description: 'City name for geocoding if lat/lon not provided',
},
lat: {
type: 'number',
description: 'Latitude coordinate',
},
lon: {
type: 'number',
description: 'Longitude coordinate',
},
exclude: {
type: 'string',
description: 'Parts to exclude from the response',
},
units: {
type: 'string',
enum: ['Celsius', 'Kelvin', 'Fahrenheit'],
description: 'Temperature units',
},
lang: {
type: 'string',
description: 'Language code',
},
date: {
type: 'string',
description: 'Date in YYYY-MM-DD format for timestamp and daily_aggregation',
},
tz: {
type: 'string',
description: 'Timezone',
},
},
required: ['action'],
};
/**
* Map user-friendly units to OpenWeather units.
* Defaults to Celsius if not specified.
@ -66,17 +110,11 @@ class OpenWeather extends Tool {
'Units: "Celsius", "Kelvin", or "Fahrenheit" (default: Celsius). ' +
'For timestamp action, use "date" in YYYY-MM-DD format.';
schema = z.object({
action: z.enum(['help', 'current_forecast', 'timestamp', 'daily_aggregation', 'overview']),
city: z.string().optional(),
lat: z.number().optional(),
lon: z.number().optional(),
exclude: z.string().optional(),
units: z.enum(['Celsius', 'Kelvin', 'Fahrenheit']).optional(),
lang: z.string().optional(),
date: z.string().optional(), // For timestamp and daily_aggregation
tz: z.string().optional(),
});
schema = openWeatherJsonSchema;
static get jsonSchema() {
return openWeatherJsonSchema;
}
constructor(fields = {}) {
super();

View file

@ -1,6 +1,5 @@
// Generates image using stable diffusion webui's api (automatic1111)
const fs = require('fs');
const { z } = require('zod');
const path = require('path');
const axios = require('axios');
const sharp = require('sharp');
@ -11,6 +10,23 @@ const { FileContext, ContentTypes } = require('librechat-data-provider');
const { getBasePath } = require('@librechat/api');
const paths = require('~/config/paths');
const stableDiffusionJsonSchema = {
type: 'object',
properties: {
prompt: {
type: 'string',
description:
'Detailed keywords to describe the subject, using at least 7 keywords to accurately describe the image, separated by comma',
},
negative_prompt: {
type: 'string',
description:
'Keywords we want to exclude from the final image, using at least 7 keywords to accurately describe the image, separated by comma',
},
},
required: ['prompt', 'negative_prompt'],
};
const displayMessage =
"Stable Diffusion displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
@ -46,18 +62,11 @@ class StableDiffusionAPI extends Tool {
// - Generate images only once per human query unless explicitly requested by the user`;
this.description =
"You can generate images using text with 'stable-diffusion'. This tool is exclusively for visual content.";
this.schema = z.object({
prompt: z
.string()
.describe(
'Detailed keywords to describe the subject, using at least 7 keywords to accurately describe the image, separated by comma',
),
negative_prompt: z
.string()
.describe(
'Keywords we want to exclude from the final image, using at least 7 keywords to accurately describe the image, separated by comma',
),
});
this.schema = stableDiffusionJsonSchema;
}
static get jsonSchema() {
return stableDiffusionJsonSchema;
}
replaceNewLinesWithSpaces(inputString) {

View file

@ -1,8 +1,75 @@
const { z } = require('zod');
const { ProxyAgent, fetch } = require('undici');
const { Tool } = require('@langchain/core/tools');
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
const tavilySearchJsonSchema = {
type: 'object',
properties: {
query: {
type: 'string',
minLength: 1,
description: 'The search query string.',
},
max_results: {
type: 'number',
minimum: 1,
maximum: 10,
description: 'The maximum number of search results to return. Defaults to 5.',
},
search_depth: {
type: 'string',
enum: ['basic', 'advanced'],
description:
'The depth of the search, affecting result quality and response time (`basic` or `advanced`). Default is basic for quick results and advanced for indepth high quality results but longer response time. Advanced calls equals 2 requests.',
},
include_images: {
type: 'boolean',
description:
'Whether to include a list of query-related images in the response. Default is False.',
},
include_answer: {
type: 'boolean',
description: 'Whether to include answers in the search results. Default is False.',
},
include_raw_content: {
type: 'boolean',
description: 'Whether to include raw content in the search results. Default is False.',
},
include_domains: {
type: 'array',
items: { type: 'string' },
description: 'A list of domains to specifically include in the search results.',
},
exclude_domains: {
type: 'array',
items: { type: 'string' },
description: 'A list of domains to specifically exclude from the search results.',
},
topic: {
type: 'string',
enum: ['general', 'news', 'finance'],
description:
'The category of the search. Use news ONLY if query SPECIFCALLY mentions the word "news".',
},
time_range: {
type: 'string',
enum: ['day', 'week', 'month', 'year', 'd', 'w', 'm', 'y'],
description: 'The time range back from the current date to filter results.',
},
days: {
type: 'number',
minimum: 1,
description: 'Number of days back from the current date to include. Only if topic is news.',
},
include_image_descriptions: {
type: 'boolean',
description:
'When include_images is true, also add a descriptive text for each image. Default is false.',
},
},
required: ['query'],
};
class TavilySearchResults extends Tool {
static lc_name() {
return 'TavilySearchResults';
@ -20,64 +87,11 @@ class TavilySearchResults extends Tool {
this.description =
'A search engine optimized for comprehensive, accurate, and trusted results. Useful for when you need to answer questions about current events.';
this.schema = z.object({
query: z.string().min(1).describe('The search query string.'),
max_results: z
.number()
.min(1)
.max(10)
.optional()
.describe('The maximum number of search results to return. Defaults to 5.'),
search_depth: z
.enum(['basic', 'advanced'])
.optional()
.describe(
'The depth of the search, affecting result quality and response time (`basic` or `advanced`). Default is basic for quick results and advanced for indepth high quality results but longer response time. Advanced calls equals 2 requests.',
),
include_images: z
.boolean()
.optional()
.describe(
'Whether to include a list of query-related images in the response. Default is False.',
),
include_answer: z
.boolean()
.optional()
.describe('Whether to include answers in the search results. Default is False.'),
include_raw_content: z
.boolean()
.optional()
.describe('Whether to include raw content in the search results. Default is False.'),
include_domains: z
.array(z.string())
.optional()
.describe('A list of domains to specifically include in the search results.'),
exclude_domains: z
.array(z.string())
.optional()
.describe('A list of domains to specifically exclude from the search results.'),
topic: z
.enum(['general', 'news', 'finance'])
.optional()
.describe(
'The category of the search. Use news ONLY if query SPECIFCALLY mentions the word "news".',
),
time_range: z
.enum(['day', 'week', 'month', 'year', 'd', 'w', 'm', 'y'])
.optional()
.describe('The time range back from the current date to filter results.'),
days: z
.number()
.min(1)
.optional()
.describe('Number of days back from the current date to include. Only if topic is news.'),
include_image_descriptions: z
.boolean()
.optional()
.describe(
'When include_images is true, also add a descriptive text for each image. Default is false.',
),
});
this.schema = tavilySearchJsonSchema;
}
static get jsonSchema() {
return tavilySearchJsonSchema;
}
getApiKey() {
@ -89,12 +103,7 @@ class TavilySearchResults extends Tool {
}
async _call(input) {
const validationResult = this.schema.safeParse(input);
if (!validationResult.success) {
throw new Error(`Validation failed: ${JSON.stringify(validationResult.error.issues)}`);
}
const { query, ...rest } = validationResult.data;
const { query, ...rest } = input;
const requestBody = {
api_key: this.apiKey,

View file

@ -1,8 +1,19 @@
const { z } = require('zod');
const { Tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
const traversaalSearchJsonSchema = {
type: 'object',
properties: {
query: {
type: 'string',
description:
"A properly written sentence to be interpreted by an AI to search the web according to the user's request.",
},
},
required: ['query'],
};
/**
* Tool for the Traversaal AI search API, Ares.
*/
@ -17,17 +28,15 @@ class TraversaalSearch extends Tool {
Useful for when you need to answer questions about current events. Input should be a search query.`;
this.description_for_model =
'\'Please create a specific sentence for the AI to understand and use as a query to search the web based on the user\'s request. For example, "Find information about the highest mountains in the world." or "Show me the latest news articles about climate change and its impact on polar ice caps."\'';
this.schema = z.object({
query: z
.string()
.describe(
"A properly written sentence to be interpreted by an AI to search the web according to the user's request.",
),
});
this.schema = traversaalSearchJsonSchema;
this.apiKey = fields?.TRAVERSAAL_API_KEY ?? this.getApiKey();
}
static get jsonSchema() {
return traversaalSearchJsonSchema;
}
getApiKey() {
const apiKey = getEnvironmentVariable('TRAVERSAAL_API_KEY');
if (!apiKey && this.override) {

View file

@ -1,9 +1,19 @@
/* eslint-disable no-useless-escape */
const { z } = require('zod');
const axios = require('axios');
const { Tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const wolframJsonSchema = {
type: 'object',
properties: {
input: {
type: 'string',
description: 'Natural language query to WolframAlpha following the guidelines',
},
},
required: ['input'],
};
class WolframAlphaAPI extends Tool {
constructor(fields) {
super();
@ -41,9 +51,11 @@ class WolframAlphaAPI extends Tool {
// -- Do not explain each step unless user input is needed. Proceed directly to making a better API call based on the available assumptions.`;
this.description = `WolframAlpha offers computation, math, curated knowledge, and real-time data. It handles natural language queries and performs complex calculations.
Follow the guidelines to get the best results.`;
this.schema = z.object({
input: z.string().describe('Natural language query to WolframAlpha following the guidelines'),
});
this.schema = wolframJsonSchema;
}
static get jsonSchema() {
return wolframJsonSchema;
}
async fetchRawText(url) {

View file

@ -1,137 +0,0 @@
const { ytToolkit } = require('@librechat/api');
const { tool } = require('@langchain/core/tools');
const { youtube } = require('@googleapis/youtube');
const { logger } = require('@librechat/data-schemas');
const { YoutubeTranscript } = require('youtube-transcript');
const { getApiKey } = require('./credentials');
function extractVideoId(url) {
const rawIdRegex = /^[a-zA-Z0-9_-]{11}$/;
if (rawIdRegex.test(url)) {
return url;
}
const regex = new RegExp(
'(?:youtu\\.be/|youtube(?:\\.com)?/(?:' +
'(?:watch\\?v=)|(?:embed/)|(?:shorts/)|(?:live/)|(?:v/)|(?:/))?)' +
'([a-zA-Z0-9_-]{11})(?:\\S+)?$',
);
const match = url.match(regex);
return match ? match[1] : null;
}
function parseTranscript(transcriptResponse) {
if (!Array.isArray(transcriptResponse)) {
return '';
}
return transcriptResponse
.map((entry) => entry.text.trim())
.filter((text) => text)
.join(' ')
.replaceAll('&amp;#39;', "'");
}
function createYouTubeTools(fields = {}) {
const envVar = 'YOUTUBE_API_KEY';
const override = fields.override ?? false;
const apiKey = fields.apiKey ?? fields[envVar] ?? getApiKey(envVar, override);
const youtubeClient = youtube({
version: 'v3',
auth: apiKey,
});
const searchTool = tool(async ({ query, maxResults = 5 }) => {
const response = await youtubeClient.search.list({
part: 'snippet',
q: query,
type: 'video',
maxResults: maxResults || 5,
});
const result = response.data.items.map((item) => ({
title: item.snippet.title,
description: item.snippet.description,
url: `https://www.youtube.com/watch?v=${item.id.videoId}`,
}));
return JSON.stringify(result, null, 2);
}, ytToolkit.youtube_search);
const infoTool = tool(async ({ url }) => {
const videoId = extractVideoId(url);
if (!videoId) {
throw new Error('Invalid YouTube URL or video ID');
}
const response = await youtubeClient.videos.list({
part: 'snippet,statistics',
id: videoId,
});
if (!response.data.items?.length) {
throw new Error('Video not found');
}
const video = response.data.items[0];
const result = {
title: video.snippet.title,
description: video.snippet.description,
views: video.statistics.viewCount,
likes: video.statistics.likeCount,
comments: video.statistics.commentCount,
};
return JSON.stringify(result, null, 2);
}, ytToolkit.youtube_info);
const commentsTool = tool(async ({ url, maxResults = 10 }) => {
const videoId = extractVideoId(url);
if (!videoId) {
throw new Error('Invalid YouTube URL or video ID');
}
const response = await youtubeClient.commentThreads.list({
part: 'snippet',
videoId,
maxResults: maxResults || 10,
});
const result = response.data.items.map((item) => ({
author: item.snippet.topLevelComment.snippet.authorDisplayName,
text: item.snippet.topLevelComment.snippet.textDisplay,
likes: item.snippet.topLevelComment.snippet.likeCount,
}));
return JSON.stringify(result, null, 2);
}, ytToolkit.youtube_comments);
const transcriptTool = tool(async ({ url }) => {
const videoId = extractVideoId(url);
if (!videoId) {
throw new Error('Invalid YouTube URL or video ID');
}
try {
try {
const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'en' });
return parseTranscript(transcript);
} catch (e) {
logger.error(e);
}
try {
const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'de' });
return parseTranscript(transcript);
} catch (e) {
logger.error(e);
}
const transcript = await YoutubeTranscript.fetchTranscript(videoId);
return parseTranscript(transcript);
} catch (error) {
throw new Error(`Failed to fetch transcript: ${error.message}`);
}
}, ytToolkit.youtube_transcript);
return [searchTool, infoTool, commentsTool, transcriptTool];
}
module.exports = createYouTubeTools;

View file

@ -1,7 +1,6 @@
const DALLE3 = require('../DALLE3');
const { ProxyAgent } = require('undici');
jest.mock('tiktoken');
const processFileURL = jest.fn();
describe('DALLE3 Proxy Configuration', () => {

View file

@ -14,15 +14,6 @@ jest.mock('@librechat/data-schemas', () => {
};
});
jest.mock('tiktoken', () => {
return {
encoding_for_model: jest.fn().mockReturnValue({
encode: jest.fn(),
decode: jest.fn(),
}),
};
});
const processFileURL = jest.fn();
const generate = jest.fn();

View file

@ -0,0 +1,125 @@
const { ProxyAgent } = require('undici');
/**
* These tests verify the proxy wrapper behavior for GeminiImageGen.
* Instead of loading the full module (which has many dependencies),
* we directly test the wrapper logic that would be applied.
*/
describe('GeminiImageGen Proxy Configuration', () => {
let originalEnv;
let originalFetch;
beforeAll(() => {
originalEnv = { ...process.env };
originalFetch = globalThis.fetch;
});
beforeEach(() => {
process.env = { ...originalEnv };
globalThis.fetch = originalFetch;
});
afterEach(() => {
process.env = originalEnv;
globalThis.fetch = originalFetch;
});
/**
* Simulates the proxy wrapper that GeminiImageGen applies at module load.
* This is the same logic from GeminiImageGen.js lines 30-42.
*/
function applyProxyWrapper() {
if (process.env.PROXY) {
const _originalFetch = globalThis.fetch;
const proxyAgent = new ProxyAgent(process.env.PROXY);
globalThis.fetch = function (url, options = {}) {
const urlString = url.toString();
if (urlString.includes('googleapis.com')) {
options = { ...options, dispatcher: proxyAgent };
}
return _originalFetch.call(this, url, options);
};
}
}
it('should wrap globalThis.fetch when PROXY env is set', () => {
process.env.PROXY = 'http://proxy.example.com:8080';
const fetchBeforeWrap = globalThis.fetch;
applyProxyWrapper();
expect(globalThis.fetch).not.toBe(fetchBeforeWrap);
});
it('should not wrap globalThis.fetch when PROXY env is not set', () => {
delete process.env.PROXY;
const fetchBeforeWrap = globalThis.fetch;
applyProxyWrapper();
expect(globalThis.fetch).toBe(fetchBeforeWrap);
});
it('should add dispatcher to googleapis.com URLs', async () => {
process.env.PROXY = 'http://proxy.example.com:8080';
let capturedOptions = null;
const mockFetch = jest.fn((url, options) => {
capturedOptions = options;
return Promise.resolve({ ok: true });
});
globalThis.fetch = mockFetch;
applyProxyWrapper();
await globalThis.fetch('https://generativelanguage.googleapis.com/v1/models', {});
expect(capturedOptions).toBeDefined();
expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent);
});
it('should not add dispatcher to non-googleapis.com URLs', async () => {
process.env.PROXY = 'http://proxy.example.com:8080';
let capturedOptions = null;
const mockFetch = jest.fn((url, options) => {
capturedOptions = options;
return Promise.resolve({ ok: true });
});
globalThis.fetch = mockFetch;
applyProxyWrapper();
await globalThis.fetch('https://api.openai.com/v1/images', {});
expect(capturedOptions).toBeDefined();
expect(capturedOptions.dispatcher).toBeUndefined();
});
it('should preserve existing options when adding dispatcher', async () => {
process.env.PROXY = 'http://proxy.example.com:8080';
let capturedOptions = null;
const mockFetch = jest.fn((url, options) => {
capturedOptions = options;
return Promise.resolve({ ok: true });
});
globalThis.fetch = mockFetch;
applyProxyWrapper();
const customHeaders = { 'X-Custom-Header': 'test' };
await globalThis.fetch('https://aiplatform.googleapis.com/v1/models', {
headers: customHeaders,
method: 'POST',
});
expect(capturedOptions).toBeDefined();
expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent);
expect(capturedOptions.headers).toEqual(customHeaders);
expect(capturedOptions.method).toBe('POST');
});
});

View file

@ -1,4 +1,3 @@
const { z } = require('zod');
const axios = require('axios');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
@ -7,6 +6,18 @@ const { Tools, EToolResources } = require('librechat-data-provider');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { getFiles } = require('~/models');
const fileSearchJsonSchema = {
type: 'object',
properties: {
query: {
type: 'string',
description:
"A natural language query to search for relevant information in the files. Be specific and use keywords related to the information you're looking for. The query will be used for semantic similarity matching against the file contents.",
},
},
required: ['query'],
};
/**
*
* @param {Object} options
@ -182,15 +193,9 @@ Use the EXACT anchor markers shown below (copy them verbatim) immediately after
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`
: ''
}`,
schema: z.object({
query: z
.string()
.describe(
"A natural language query to search for relevant information in the files. Be specific and use keywords related to the information you're looking for. The query will be used for semantic similarity matching against the file contents.",
),
}),
schema: fileSearchJsonSchema,
},
);
};
module.exports = { createFileSearchTool, primeFiles };
module.exports = { createFileSearchTool, primeFiles, fileSearchJsonSchema };

View file

@ -7,10 +7,12 @@ const {
} = require('@librechat/agents');
const {
checkAccess,
toolkitParent,
createSafeUser,
mcpToolPattern,
loadWebSearchAuth,
buildImageToolContext,
buildWebSearchContext,
} = require('@librechat/api');
const { getMCPServersRegistry } = require('~/config');
const {
@ -19,7 +21,6 @@ const {
Permissions,
EToolResources,
PermissionTypes,
replaceSpecialVars,
} = require('librechat-data-provider');
const {
availableTools,
@ -34,7 +35,6 @@ const {
StructuredACS,
TraversaalSearch,
StructuredWolfram,
createYouTubeTools,
TavilySearchResults,
createGeminiImageTool,
createOpenAIImageTools,
@ -185,11 +185,6 @@ const loadTools = async ({
};
const customConstructors = {
youtube: async (_toolContextMap) => {
const authFields = getAuthFields('youtube');
const authValues = await loadAuthValues({ userId: user, authFields });
return createYouTubeTools(authValues);
},
image_gen_oai: async (toolContextMap) => {
const authFields = getAuthFields('image_gen_oai');
const authValues = await loadAuthValues({ userId: user, authFields });
@ -213,7 +208,7 @@ const loadTools = async ({
},
gemini_image_gen: async (toolContextMap) => {
const authFields = getAuthFields('gemini_image_gen');
const authValues = await loadAuthValues({ userId: user, authFields });
const authValues = await loadAuthValues({ userId: user, authFields, throwError: false });
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
const toolContext = buildImageToolContext({
imageFiles,
@ -228,7 +223,6 @@ const loadTools = async ({
isAgent: !!agent,
req: options.req,
imageFiles,
processFileURL: options.processFileURL,
userId: user,
fileStrategy,
});
@ -331,24 +325,7 @@ const loadTools = async ({
});
const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {};
requestedTools[tool] = async () => {
toolContextMap[tool] = `# \`${tool}\`:
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
**Execute immediately without preface.** After search, provide a brief summary addressing the query directly, then structure your response with clear Markdown formatting (## headers, lists, tables). Cite sources properly, tailor tone to query type, and provide comprehensive details.
**CITATION FORMAT - UNICODE ESCAPE SEQUENCES ONLY:**
Use these EXACT escape sequences (copy verbatim): \\ue202 (before each anchor), \\ue200 (group start), \\ue201 (group end), \\ue203 (highlight start), \\ue204 (highlight end)
Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|news|image|ref, index=0,1,2...
**Examples (copy these exactly):**
- Single: "Statement.\\ue202turn0search0"
- Multiple: "Statement.\\ue202turn0search0\\ue202turn0news1"
- Group: "Statement. \\ue200\\ue202turn0search0\\ue202turn0news1\\ue201"
- Highlight: "\\ue203Cited text.\\ue204\\ue202turn0search0"
- Image: "See photo\\ue202turn0image0."
**CRITICAL:** Output escape sequences EXACTLY as shown. Do NOT substitute with or other symbols. Place anchors AFTER punctuation. Cite every non-obvious fact/quote. NEVER use markdown links, [1], footnotes, or HTML tags.`.trim();
toolContextMap[tool] = buildWebSearchContext();
return createSearchTool({
...result.authResult,
onSearchResults,
@ -393,8 +370,16 @@ Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|new
continue;
}
if (customConstructors[tool]) {
requestedTools[tool] = async () => customConstructors[tool](toolContextMap);
const toolKey = customConstructors[tool] ? tool : toolkitParent[tool];
if (toolKey && customConstructors[toolKey]) {
if (!requestedTools[toolKey]) {
let cached;
requestedTools[toolKey] = async () => {
cached ??= customConstructors[toolKey](toolContextMap);
return cached;
};
}
requestedTools[tool] = requestedTools[toolKey];
continue;
}

View file

@ -55,6 +55,7 @@ const banViolation = async (req, res, errorMessage) => {
res.clearCookie('refreshToken');
res.clearCookie('openid_access_token');
res.clearCookie('openid_id_token');
res.clearCookie('openid_user_id');
res.clearCookie('token_provider');

View file

@ -37,6 +37,7 @@ const namespaces = {
[CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
[CacheKeys.APP_CONFIG]: standardCache(CacheKeys.APP_CONFIG),
[CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
[CacheKeys.TOOL_CACHE]: standardCache(CacheKeys.TOOL_CACHE),
[CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ),
[CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }),
[CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES),
@ -46,11 +47,15 @@ const namespaces = {
[CacheKeys.MODEL_QUERIES]: standardCache(CacheKeys.MODEL_QUERIES),
[CacheKeys.AUDIO_RUNS]: standardCache(CacheKeys.AUDIO_RUNS, Time.TEN_MINUTES),
[CacheKeys.MESSAGES]: standardCache(CacheKeys.MESSAGES, Time.ONE_MINUTE),
[CacheKeys.FLOWS]: standardCache(CacheKeys.FLOWS, Time.ONE_MINUTE * 3),
[CacheKeys.FLOWS]: standardCache(CacheKeys.FLOWS, Time.ONE_MINUTE * 10),
[CacheKeys.OPENID_EXCHANGED_TOKENS]: standardCache(
CacheKeys.OPENID_EXCHANGED_TOKENS,
Time.TEN_MINUTES,
),
[CacheKeys.ADMIN_OAUTH_EXCHANGE]: standardCache(
CacheKeys.ADMIN_OAUTH_EXCHANGE,
Time.THIRTY_SECONDS,
),
};
/**

View file

@ -40,6 +40,10 @@ if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}
mongoose.connection.on('error', (err) => {
logger.error('[connectDb] MongoDB connection error:', err);
});
async function connectDb() {
if (cached.conn && cached.conn?._readyState === 1) {
return cached.conn;

View file

@ -13,6 +13,11 @@ const searchEnabled = isEnabled(process.env.SEARCH);
const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC);
let currentTimeout = null;
const defaultSyncThreshold = 1000;
const syncThreshold = process.env.MEILI_SYNC_THRESHOLD
? parseInt(process.env.MEILI_SYNC_THRESHOLD, 10)
: defaultSyncThreshold;
class MeiliSearchClient {
static instance = null;
@ -221,25 +226,29 @@ async function performSync(flowManager, flowId, flowType) {
}
// Check if we need to sync messages
logger.info('[indexSync] Requesting message sync progress...');
const messageProgress = await Message.getSyncProgress();
if (!messageProgress.isComplete || settingsUpdated) {
logger.info(
`[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
);
// Check if we should do a full sync or incremental
const messageCount = await Message.countDocuments();
const messageCount = messageProgress.totalDocuments;
const messagesIndexed = messageProgress.totalProcessed;
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
const unindexedMessages = messageCount - messagesIndexed;
const noneIndexed = messagesIndexed === 0 && unindexedMessages > 0;
if (messageCount - messagesIndexed > syncThreshold) {
logger.info('[indexSync] Starting full message sync due to large difference');
await Message.syncWithMeili();
messagesSync = true;
} else if (messageCount !== messagesIndexed) {
logger.warn('[indexSync] Messages out of sync, performing incremental sync');
if (settingsUpdated || noneIndexed || unindexedMessages > syncThreshold) {
if (noneIndexed && !settingsUpdated) {
logger.info('[indexSync] No messages marked as indexed, forcing full sync');
}
logger.info(`[indexSync] Starting message sync (${unindexedMessages} unindexed)`);
await Message.syncWithMeili();
messagesSync = true;
} else if (unindexedMessages > 0) {
logger.info(
`[indexSync] ${unindexedMessages} messages unindexed (below threshold: ${syncThreshold}, skipping)`,
);
}
} else {
logger.info(
@ -254,18 +263,22 @@ async function performSync(flowManager, flowId, flowType) {
`[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
);
const convoCount = await Conversation.countDocuments();
const convoCount = convoProgress.totalDocuments;
const convosIndexed = convoProgress.totalProcessed;
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
const unindexedConvos = convoCount - convosIndexed;
const noneConvosIndexed = convosIndexed === 0 && unindexedConvos > 0;
if (convoCount - convosIndexed > syncThreshold) {
logger.info('[indexSync] Starting full conversation sync due to large difference');
await Conversation.syncWithMeili();
convosSync = true;
} else if (convoCount !== convosIndexed) {
logger.warn('[indexSync] Convos out of sync, performing incremental sync');
if (settingsUpdated || noneConvosIndexed || unindexedConvos > syncThreshold) {
if (noneConvosIndexed && !settingsUpdated) {
logger.info('[indexSync] No conversations marked as indexed, forcing full sync');
}
logger.info(`[indexSync] Starting convos sync (${unindexedConvos} unindexed)`);
await Conversation.syncWithMeili();
convosSync = true;
} else if (unindexedConvos > 0) {
logger.info(
`[indexSync] ${unindexedConvos} convos unindexed (below threshold: ${syncThreshold}, skipping)`,
);
}
} else {
logger.info(

530
api/db/indexSync.spec.js Normal file
View file

@ -0,0 +1,530 @@
/**
* Unit tests for performSync() function in indexSync.js
*
* Tests use real mongoose with mocked model methods, only mocking external calls.
*/
const mongoose = require('mongoose');
// Mock only external dependencies (not internal classes/models)
const mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
const mockMeiliHealth = jest.fn();
const mockMeiliIndex = jest.fn();
const mockBatchResetMeiliFlags = jest.fn();
const mockIsEnabled = jest.fn();
const mockGetLogStores = jest.fn();
// Create mock models that will be reused
const createMockModel = (collectionName) => ({
collection: { name: collectionName },
getSyncProgress: jest.fn(),
syncWithMeili: jest.fn(),
countDocuments: jest.fn(),
});
const originalMessageModel = mongoose.models.Message;
const originalConversationModel = mongoose.models.Conversation;
// Mock external modules
jest.mock('@librechat/data-schemas', () => ({
logger: mockLogger,
}));
jest.mock('meilisearch', () => ({
MeiliSearch: jest.fn(() => ({
health: mockMeiliHealth,
index: mockMeiliIndex,
})),
}));
jest.mock('./utils', () => ({
batchResetMeiliFlags: mockBatchResetMeiliFlags,
}));
jest.mock('@librechat/api', () => ({
isEnabled: mockIsEnabled,
FlowStateManager: jest.fn(),
}));
jest.mock('~/cache', () => ({
getLogStores: mockGetLogStores,
}));
// Set environment before module load
process.env.MEILI_HOST = 'http://localhost:7700';
process.env.MEILI_MASTER_KEY = 'test-key';
process.env.SEARCH = 'true';
process.env.MEILI_SYNC_THRESHOLD = '1000'; // Set threshold before module loads
describe('performSync() - syncThreshold logic', () => {
const ORIGINAL_ENV = process.env;
let Message;
let Conversation;
beforeAll(() => {
Message = createMockModel('messages');
Conversation = createMockModel('conversations');
mongoose.models.Message = Message;
mongoose.models.Conversation = Conversation;
});
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Reset modules to ensure fresh load of indexSync.js and its top-level consts (like syncThreshold)
jest.resetModules();
// Set up environment
process.env = { ...ORIGINAL_ENV };
process.env.MEILI_HOST = 'http://localhost:7700';
process.env.MEILI_MASTER_KEY = 'test-key';
process.env.SEARCH = 'true';
delete process.env.MEILI_NO_SYNC;
// Re-ensure models are available in mongoose after resetModules
// We must require mongoose again to get the fresh instance that indexSync will use
const mongoose = require('mongoose');
mongoose.models.Message = Message;
mongoose.models.Conversation = Conversation;
// Mock isEnabled
mockIsEnabled.mockImplementation((val) => val === 'true' || val === true);
// Mock MeiliSearch client responses
mockMeiliHealth.mockResolvedValue({ status: 'available' });
mockMeiliIndex.mockReturnValue({
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: ['user'] }),
updateSettings: jest.fn().mockResolvedValue({}),
search: jest.fn().mockResolvedValue({ hits: [] }),
});
mockBatchResetMeiliFlags.mockResolvedValue(undefined);
});
afterEach(() => {
process.env = ORIGINAL_ENV;
});
afterAll(() => {
mongoose.models.Message = originalMessageModel;
mongoose.models.Conversation = originalConversationModel;
});
test('triggers sync when unindexed messages exceed syncThreshold', async () => {
// Arrange: Set threshold before module load
process.env.MEILI_SYNC_THRESHOLD = '1000';
// Arrange: 1050 unindexed messages > 1000 threshold
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 100,
totalDocuments: 1150, // 1050 unindexed
isComplete: false,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 50,
totalDocuments: 50,
isComplete: true,
});
Message.syncWithMeili.mockResolvedValue(undefined);
// Act
const indexSync = require('./indexSync');
await indexSync();
// Assert: No countDocuments calls
expect(Message.countDocuments).not.toHaveBeenCalled();
expect(Conversation.countDocuments).not.toHaveBeenCalled();
// Assert: Message sync triggered because 1050 > 1000
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Messages need syncing: 100/1150 indexed',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Starting message sync (1050 unindexed)',
);
// Assert: Conversation sync NOT triggered (already complete)
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
});
test('skips sync when unindexed messages are below syncThreshold', async () => {
// Arrange: 50 unindexed messages < 1000 threshold
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 100,
totalDocuments: 150, // 50 unindexed
isComplete: false,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 50,
totalDocuments: 50,
isComplete: true,
});
process.env.MEILI_SYNC_THRESHOLD = '1000';
// Act
const indexSync = require('./indexSync');
await indexSync();
// Assert: No countDocuments calls
expect(Message.countDocuments).not.toHaveBeenCalled();
expect(Conversation.countDocuments).not.toHaveBeenCalled();
// Assert: Message sync NOT triggered because 50 < 1000
expect(Message.syncWithMeili).not.toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Messages need syncing: 100/150 indexed',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] 50 messages unindexed (below threshold: 1000, skipping)',
);
// Assert: Conversation sync NOT triggered (already complete)
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
});
test('respects syncThreshold at boundary (exactly at threshold)', async () => {
// Arrange: 1000 unindexed messages = 1000 threshold (NOT greater than)
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 100,
totalDocuments: 1100, // 1000 unindexed
isComplete: false,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 0,
totalDocuments: 0,
isComplete: true,
});
process.env.MEILI_SYNC_THRESHOLD = '1000';
// Act
const indexSync = require('./indexSync');
await indexSync();
// Assert: No countDocuments calls
expect(Message.countDocuments).not.toHaveBeenCalled();
// Assert: Message sync NOT triggered because 1000 is NOT > 1000
expect(Message.syncWithMeili).not.toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Messages need syncing: 100/1100 indexed',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] 1000 messages unindexed (below threshold: 1000, skipping)',
);
});
test('triggers sync when unindexed is threshold + 1', async () => {
// Arrange: 1001 unindexed messages > 1000 threshold
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 100,
totalDocuments: 1101, // 1001 unindexed
isComplete: false,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 0,
totalDocuments: 0,
isComplete: true,
});
Message.syncWithMeili.mockResolvedValue(undefined);
process.env.MEILI_SYNC_THRESHOLD = '1000';
// Act
const indexSync = require('./indexSync');
await indexSync();
// Assert: No countDocuments calls
expect(Message.countDocuments).not.toHaveBeenCalled();
// Assert: Message sync triggered because 1001 > 1000
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Messages need syncing: 100/1101 indexed',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Starting message sync (1001 unindexed)',
);
});
test('uses totalDocuments from convoProgress for conversation sync decisions', async () => {
// Arrange: Messages complete, conversations need sync
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 100,
totalDocuments: 100,
isComplete: true,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 50,
totalDocuments: 1100, // 1050 unindexed > 1000 threshold
isComplete: false,
});
Conversation.syncWithMeili.mockResolvedValue(undefined);
process.env.MEILI_SYNC_THRESHOLD = '1000';
// Act
const indexSync = require('./indexSync');
await indexSync();
// Assert: No countDocuments calls (the optimization)
expect(Message.countDocuments).not.toHaveBeenCalled();
expect(Conversation.countDocuments).not.toHaveBeenCalled();
// Assert: Only conversation sync triggered
expect(Message.syncWithMeili).not.toHaveBeenCalled();
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Conversations need syncing: 50/1100 indexed',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Starting convos sync (1050 unindexed)',
);
});
test('skips sync when collections are fully synced', async () => {
// Arrange: Everything already synced
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 100,
totalDocuments: 100,
isComplete: true,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 50,
totalDocuments: 50,
isComplete: true,
});
// Act
const indexSync = require('./indexSync');
await indexSync();
// Assert: No countDocuments calls
expect(Message.countDocuments).not.toHaveBeenCalled();
expect(Conversation.countDocuments).not.toHaveBeenCalled();
// Assert: No sync triggered
expect(Message.syncWithMeili).not.toHaveBeenCalled();
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
// Assert: Correct logs
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Messages are fully synced: 100/100');
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Conversations are fully synced: 50/50',
);
});
test('triggers message sync when settingsUpdated even if below syncThreshold', async () => {
// Arrange: Only 50 unindexed messages (< 1000 threshold), but settings were updated
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 100,
totalDocuments: 150, // 50 unindexed
isComplete: false,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 50,
totalDocuments: 50,
isComplete: true,
});
Message.syncWithMeili.mockResolvedValue(undefined);
// Mock settings update scenario
mockMeiliIndex.mockReturnValue({
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
updateSettings: jest.fn().mockResolvedValue({}),
search: jest.fn().mockResolvedValue({ hits: [] }),
});
process.env.MEILI_SYNC_THRESHOLD = '1000';
// Act
const indexSync = require('./indexSync');
await indexSync();
// Assert: Flags were reset due to settings update
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
// Assert: Message sync triggered despite being below threshold (50 < 1000)
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Starting message sync (50 unindexed)',
);
});
test('triggers conversation sync when settingsUpdated even if below syncThreshold', async () => {
// Arrange: Messages complete, conversations have 50 unindexed (< 1000 threshold), but settings were updated
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 100,
totalDocuments: 100,
isComplete: true,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 50,
totalDocuments: 100, // 50 unindexed
isComplete: false,
});
Conversation.syncWithMeili.mockResolvedValue(undefined);
// Mock settings update scenario
mockMeiliIndex.mockReturnValue({
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
updateSettings: jest.fn().mockResolvedValue({}),
search: jest.fn().mockResolvedValue({ hits: [] }),
});
process.env.MEILI_SYNC_THRESHOLD = '1000';
// Act
const indexSync = require('./indexSync');
await indexSync();
// Assert: Flags were reset due to settings update
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
// Assert: Conversation sync triggered despite being below threshold (50 < 1000)
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
);
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)');
});
test('triggers both message and conversation sync when settingsUpdated even if both below syncThreshold', async () => {
// Arrange: Set threshold before module load
process.env.MEILI_SYNC_THRESHOLD = '1000';
// Arrange: Both have documents below threshold (50 each), but settings were updated
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 100,
totalDocuments: 150, // 50 unindexed
isComplete: false,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 50,
totalDocuments: 100, // 50 unindexed
isComplete: false,
});
Message.syncWithMeili.mockResolvedValue(undefined);
Conversation.syncWithMeili.mockResolvedValue(undefined);
// Mock settings update scenario
mockMeiliIndex.mockReturnValue({
getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field
updateSettings: jest.fn().mockResolvedValue({}),
search: jest.fn().mockResolvedValue({ hits: [] }),
});
// Act
const indexSync = require('./indexSync');
await indexSync();
// Assert: Flags were reset due to settings update
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection);
expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection);
// Assert: Both syncs triggered despite both being below threshold
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Starting message sync (50 unindexed)',
);
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)');
});
test('forces sync when zero documents indexed (reset scenario) even if below threshold', async () => {
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 0,
totalDocuments: 680,
isComplete: false,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 0,
totalDocuments: 76,
isComplete: false,
});
Message.syncWithMeili.mockResolvedValue(undefined);
Conversation.syncWithMeili.mockResolvedValue(undefined);
const indexSync = require('./indexSync');
await indexSync();
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] No messages marked as indexed, forcing full sync',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Starting message sync (680 unindexed)',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] No conversations marked as indexed, forcing full sync',
);
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (76 unindexed)');
});
test('does NOT force sync when some documents already indexed and below threshold', async () => {
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 630,
totalDocuments: 680,
isComplete: false,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 70,
totalDocuments: 76,
isComplete: false,
});
const indexSync = require('./indexSync');
await indexSync();
expect(Message.syncWithMeili).not.toHaveBeenCalled();
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
expect(mockLogger.info).not.toHaveBeenCalledWith(
'[indexSync] No messages marked as indexed, forcing full sync',
);
expect(mockLogger.info).not.toHaveBeenCalledWith(
'[indexSync] No conversations marked as indexed, forcing full sync',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] 50 messages unindexed (below threshold: 1000, skipping)',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] 6 convos unindexed (below threshold: 1000, skipping)',
);
});
});

View file

@ -26,7 +26,7 @@ async function batchResetMeiliFlags(collection) {
try {
while (hasMore) {
const docs = await collection
.find({ expiredAt: null, _meiliIndex: true }, { projection: { _id: 1 } })
.find({ expiredAt: null, _meiliIndex: { $ne: false } }, { projection: { _id: 1 } })
.limit(BATCH_SIZE)
.toArray();

View file

@ -265,8 +265,8 @@ describe('batchResetMeiliFlags', () => {
const result = await batchResetMeiliFlags(testCollection);
// Only one document has _meiliIndex: true
expect(result).toBe(1);
// both documents should be updated
expect(result).toBe(2);
});
it('should handle mixed document states correctly', async () => {
@ -275,16 +275,18 @@ describe('batchResetMeiliFlags', () => {
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: false },
{ _id: new mongoose.Types.ObjectId(), expiredAt: new Date(), _meiliIndex: true },
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: true },
{ _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: null },
{ _id: new mongoose.Types.ObjectId(), expiredAt: null },
]);
const result = await batchResetMeiliFlags(testCollection);
expect(result).toBe(2);
expect(result).toBe(4);
const flaggedDocs = await testCollection
.find({ expiredAt: null, _meiliIndex: false })
.toArray();
expect(flaggedDocs).toHaveLength(3); // 2 were updated, 1 was already false
expect(flaggedDocs).toHaveLength(5); // 4 were updated, 1 was already false
});
});

View file

@ -3,12 +3,13 @@ module.exports = {
clearMocks: true,
roots: ['<rootDir>'],
coverageDirectory: 'coverage',
maxWorkers: '50%',
testTimeout: 30000, // 30 seconds timeout for all tests
setupFiles: ['./test/jestSetup.js', './test/__mocks__/logger.js'],
moduleNameMapper: {
'~/(.*)': '<rootDir>/$1',
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js', // Mock for the passport strategy part
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js',
'^openid-client$': '<rootDir>/test/__mocks__/openid-client.js',
},
transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'],

View file

@ -11,17 +11,15 @@ const {
isEphemeralAgentId,
encodeEphemeralAgentId,
} = require('librechat-data-provider');
const { GLOBAL_PROJECT_NAME, mcp_all, mcp_delimiter } =
require('librechat-data-provider').Constants;
const { mcp_all, mcp_delimiter } = require('librechat-data-provider').Constants;
const {
removeAgentFromAllProjects,
removeAgentIdsFromProject,
addAgentIdsToProject,
getProjectByName,
} = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService');
const { getMCPServerTools } = require('~/server/services/Config');
const { Agent, AclEntry } = require('~/db/models');
const { Agent, AclEntry, User } = require('~/db/models');
const { getActions } = require('./Action');
/**
@ -591,15 +589,29 @@ const deleteAgent = async (searchParameter) => {
const agent = await Agent.findOneAndDelete(searchParameter);
if (agent) {
await removeAgentFromAllProjects(agent.id);
await removeAllPermissions({
resourceType: ResourceType.AGENT,
resourceId: agent._id,
});
await Promise.all([
removeAllPermissions({
resourceType: ResourceType.AGENT,
resourceId: agent._id,
}),
removeAllPermissions({
resourceType: ResourceType.REMOTE_AGENT,
resourceId: agent._id,
}),
]);
try {
await Agent.updateMany({ 'edges.to': agent.id }, { $pull: { edges: { to: agent.id } } });
} catch (error) {
logger.error('[deleteAgent] Error removing agent from handoff edges', error);
}
try {
await User.updateMany(
{ 'favorites.agentId': agent.id },
{ $pull: { favorites: { agentId: agent.id } } },
);
} catch (error) {
logger.error('[deleteAgent] Error removing agent from user favorites', error);
}
}
return agent;
};
@ -625,10 +637,19 @@ const deleteUserAgents = async (userId) => {
}
await AclEntry.deleteMany({
resourceType: ResourceType.AGENT,
resourceType: { $in: [ResourceType.AGENT, ResourceType.REMOTE_AGENT] },
resourceId: { $in: agentObjectIds },
});
try {
await User.updateMany(
{ 'favorites.agentId': { $in: agentIds } },
{ $pull: { favorites: { agentId: { $in: agentIds } } } },
);
} catch (error) {
logger.error('[deleteUserAgents] Error removing agents from user favorites', error);
}
await Agent.deleteMany({ author: userId });
} catch (error) {
logger.error('[deleteUserAgents] General error:', error);
@ -735,59 +756,6 @@ const getListAgentsByAccess = async ({
};
};
/**
* Get all agents.
* @deprecated Use getListAgentsByAccess for ACL-aware agent listing
* @param {Object} searchParameter - The search parameters to find matching agents.
* @param {string} searchParameter.author - The user ID of the agent's author.
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
*/
const getListAgents = async (searchParameter) => {
const { author, ...otherParams } = searchParameter;
let query = Object.assign({ author }, otherParams);
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, ['agentIds']);
if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) {
const globalQuery = { id: { $in: globalProject.agentIds }, ...otherParams };
delete globalQuery.author;
query = { $or: [globalQuery, query] };
}
const agents = (
await Agent.find(query, {
id: 1,
_id: 1,
name: 1,
avatar: 1,
author: 1,
projectIds: 1,
description: 1,
// @deprecated - isCollaborative replaced by ACL permissions
isCollaborative: 1,
category: 1,
}).lean()
).map((agent) => {
if (agent.author?.toString() !== author) {
delete agent.author;
}
if (agent.author) {
agent.author = agent.author.toString();
}
return agent;
});
const hasMore = agents.length > 0;
const firstId = agents.length > 0 ? agents[0].id : null;
const lastId = agents.length > 0 ? agents[agents.length - 1].id : null;
return {
data: agents,
has_more: hasMore,
first_id: firstId,
last_id: lastId,
};
};
/**
* Updates the projects associated with an agent, adding and removing project IDs as specified.
* This function also updates the corresponding projects to include or exclude the agent ID.
@ -953,12 +921,11 @@ module.exports = {
updateAgent,
deleteAgent,
deleteUserAgents,
getListAgents,
revertAgentVersion,
updateAgentProjects,
countPromotedAgents,
addAgentResourceFile,
getListAgentsByAccess,
removeAgentResourceFiles,
generateActionMetadataHash,
countPromotedAgents,
};

View file

@ -22,17 +22,17 @@ const {
createAgent,
updateAgent,
deleteAgent,
getListAgents,
getListAgentsByAccess,
deleteUserAgents,
revertAgentVersion,
updateAgentProjects,
addAgentResourceFile,
getListAgentsByAccess,
removeAgentResourceFiles,
generateActionMetadataHash,
} = require('./Agent');
const permissionService = require('~/server/services/PermissionService');
const { getCachedTools, getMCPServerTools } = require('~/server/services/Config');
const { AclEntry } = require('~/db/models');
const { AclEntry, User } = require('~/db/models');
/**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
@ -59,6 +59,7 @@ describe('models/Agent', () => {
beforeEach(async () => {
await Agent.deleteMany({});
await User.deleteMany({});
});
test('should add tool_resource to tools if missing', async () => {
@ -575,43 +576,488 @@ describe('models/Agent', () => {
expect(sourceAgentAfter.edges).toHaveLength(0);
});
test('should list agents by author', async () => {
test('should remove agent from user favorites when agent is deleted', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
// Create agent
await createAgent({
id: agentId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create user with the agent in favorites
await User.create({
_id: userId,
name: 'Test User',
email: `test-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agentId }, { model: 'gpt-4', endpoint: 'openAI' }],
});
// Verify user has agent in favorites
const userBefore = await User.findById(userId);
expect(userBefore.favorites).toHaveLength(2);
expect(userBefore.favorites.some((f) => f.agentId === agentId)).toBe(true);
// Delete the agent
await deleteAgent({ id: agentId });
// Verify agent is deleted
const agentAfterDelete = await getAgent({ id: agentId });
expect(agentAfterDelete).toBeNull();
// Verify agent is removed from user favorites
const userAfter = await User.findById(userId);
expect(userAfter.favorites).toHaveLength(1);
expect(userAfter.favorites.some((f) => f.agentId === agentId)).toBe(false);
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
});
test('should remove agent from multiple users favorites when agent is deleted', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const user1Id = new mongoose.Types.ObjectId();
const user2Id = new mongoose.Types.ObjectId();
// Create agent
await createAgent({
id: agentId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create two users with the agent in favorites
await User.create({
_id: user1Id,
name: 'Test User 1',
email: `test1-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agentId }],
});
await User.create({
_id: user2Id,
name: 'Test User 2',
email: `test2-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agentId }, { agentId: `agent_${uuidv4()}` }],
});
// Delete the agent
await deleteAgent({ id: agentId });
// Verify agent is removed from both users' favorites
const user1After = await User.findById(user1Id);
const user2After = await User.findById(user2Id);
expect(user1After.favorites).toHaveLength(0);
expect(user2After.favorites).toHaveLength(1);
expect(user2After.favorites.some((f) => f.agentId === agentId)).toBe(false);
});
test('should preserve other agents in database when one agent is deleted', async () => {
const agentToDeleteId = `agent_${uuidv4()}`;
const agentToKeep1Id = `agent_${uuidv4()}`;
const agentToKeep2Id = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create multiple agents
await createAgent({
id: agentToDeleteId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: agentToKeep1Id,
name: 'Agent To Keep 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: agentToKeep2Id,
name: 'Agent To Keep 2',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Verify all agents exist
expect(await getAgent({ id: agentToDeleteId })).not.toBeNull();
expect(await getAgent({ id: agentToKeep1Id })).not.toBeNull();
expect(await getAgent({ id: agentToKeep2Id })).not.toBeNull();
// Delete one agent
await deleteAgent({ id: agentToDeleteId });
// Verify only the deleted agent is removed, others remain intact
expect(await getAgent({ id: agentToDeleteId })).toBeNull();
const keptAgent1 = await getAgent({ id: agentToKeep1Id });
const keptAgent2 = await getAgent({ id: agentToKeep2Id });
expect(keptAgent1).not.toBeNull();
expect(keptAgent1.name).toBe('Agent To Keep 1');
expect(keptAgent2).not.toBeNull();
expect(keptAgent2.name).toBe('Agent To Keep 2');
});
test('should preserve other agents in user favorites when one agent is deleted', async () => {
const agentToDeleteId = `agent_${uuidv4()}`;
const agentToKeep1Id = `agent_${uuidv4()}`;
const agentToKeep2Id = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
// Create multiple agents
await createAgent({
id: agentToDeleteId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: agentToKeep1Id,
name: 'Agent To Keep 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: agentToKeep2Id,
name: 'Agent To Keep 2',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create user with all three agents in favorites
await User.create({
_id: userId,
name: 'Test User',
email: `test-${uuidv4()}@example.com`,
provider: 'local',
favorites: [
{ agentId: agentToDeleteId },
{ agentId: agentToKeep1Id },
{ agentId: agentToKeep2Id },
],
});
// Verify user has all three agents in favorites
const userBefore = await User.findById(userId);
expect(userBefore.favorites).toHaveLength(3);
// Delete one agent
await deleteAgent({ id: agentToDeleteId });
// Verify only the deleted agent is removed from favorites
const userAfter = await User.findById(userId);
expect(userAfter.favorites).toHaveLength(2);
expect(userAfter.favorites.some((f) => f.agentId === agentToDeleteId)).toBe(false);
expect(userAfter.favorites.some((f) => f.agentId === agentToKeep1Id)).toBe(true);
expect(userAfter.favorites.some((f) => f.agentId === agentToKeep2Id)).toBe(true);
});
test('should not affect users who do not have deleted agent in favorites', async () => {
const agentToDeleteId = `agent_${uuidv4()}`;
const otherAgentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
const userWithDeletedAgentId = new mongoose.Types.ObjectId();
const userWithoutDeletedAgentId = new mongoose.Types.ObjectId();
// Create agents
await createAgent({
id: agentToDeleteId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: otherAgentId,
name: 'Other Agent',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create user with the agent to be deleted
await User.create({
_id: userWithDeletedAgentId,
name: 'User With Deleted Agent',
email: `user1-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agentToDeleteId }, { model: 'gpt-4', endpoint: 'openAI' }],
});
// Create user without the agent to be deleted
await User.create({
_id: userWithoutDeletedAgentId,
name: 'User Without Deleted Agent',
email: `user2-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: otherAgentId }, { model: 'claude-3', endpoint: 'anthropic' }],
});
// Delete the agent
await deleteAgent({ id: agentToDeleteId });
// Verify user with deleted agent has it removed
const userWithDeleted = await User.findById(userWithDeletedAgentId);
expect(userWithDeleted.favorites).toHaveLength(1);
expect(userWithDeleted.favorites.some((f) => f.agentId === agentToDeleteId)).toBe(false);
expect(userWithDeleted.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
// Verify user without deleted agent is completely unaffected
const userWithoutDeleted = await User.findById(userWithoutDeletedAgentId);
expect(userWithoutDeleted.favorites).toHaveLength(2);
expect(userWithoutDeleted.favorites.some((f) => f.agentId === otherAgentId)).toBe(true);
expect(userWithoutDeleted.favorites.some((f) => f.model === 'claude-3')).toBe(true);
});
test('should remove all user agents from favorites when deleteUserAgents is called', async () => {
const authorId = new mongoose.Types.ObjectId();
const otherAuthorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentIds = [];
for (let i = 0; i < 5; i++) {
const id = `agent_${uuidv4()}`;
agentIds.push(id);
await createAgent({
id,
name: `Agent ${i}`,
provider: 'test',
model: 'test-model',
author: authorId,
});
}
const agent1Id = `agent_${uuidv4()}`;
const agent2Id = `agent_${uuidv4()}`;
const otherAuthorAgentId = `agent_${uuidv4()}`;
for (let i = 0; i < 3; i++) {
await createAgent({
id: `other_agent_${uuidv4()}`,
name: `Other Agent ${i}`,
provider: 'test',
model: 'test-model',
author: otherAuthorId,
});
}
// Create agents by the author to be deleted
await createAgent({
id: agent1Id,
name: 'Author Agent 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
const result = await getListAgents({ author: authorId.toString() });
await createAgent({
id: agent2Id,
name: 'Author Agent 2',
provider: 'test',
model: 'test-model',
author: authorId,
});
expect(result).toBeDefined();
expect(result.data).toBeDefined();
expect(result.data).toHaveLength(5);
expect(result.has_more).toBe(true);
// Create agent by different author (should not be deleted)
await createAgent({
id: otherAuthorAgentId,
name: 'Other Author Agent',
provider: 'test',
model: 'test-model',
author: otherAuthorId,
});
for (const agent of result.data) {
expect(agent.author).toBe(authorId.toString());
}
// Create user with all agents in favorites
await User.create({
_id: userId,
name: 'Test User',
email: `test-${uuidv4()}@example.com`,
provider: 'local',
favorites: [
{ agentId: agent1Id },
{ agentId: agent2Id },
{ agentId: otherAuthorAgentId },
{ model: 'gpt-4', endpoint: 'openAI' },
],
});
// Verify user has all favorites
const userBefore = await User.findById(userId);
expect(userBefore.favorites).toHaveLength(4);
// Delete all agents by the author
await deleteUserAgents(authorId.toString());
// Verify author's agents are deleted from database
expect(await getAgent({ id: agent1Id })).toBeNull();
expect(await getAgent({ id: agent2Id })).toBeNull();
// Verify other author's agent still exists
expect(await getAgent({ id: otherAuthorAgentId })).not.toBeNull();
// Verify user favorites: author's agents removed, others remain
const userAfter = await User.findById(userId);
expect(userAfter.favorites).toHaveLength(2);
expect(userAfter.favorites.some((f) => f.agentId === agent1Id)).toBe(false);
expect(userAfter.favorites.some((f) => f.agentId === agent2Id)).toBe(false);
expect(userAfter.favorites.some((f) => f.agentId === otherAuthorAgentId)).toBe(true);
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
});
test('should handle deleteUserAgents when agents are in multiple users favorites', async () => {
const authorId = new mongoose.Types.ObjectId();
const user1Id = new mongoose.Types.ObjectId();
const user2Id = new mongoose.Types.ObjectId();
const user3Id = new mongoose.Types.ObjectId();
const agent1Id = `agent_${uuidv4()}`;
const agent2Id = `agent_${uuidv4()}`;
const unrelatedAgentId = `agent_${uuidv4()}`;
// Create agents by the author
await createAgent({
id: agent1Id,
name: 'Author Agent 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: agent2Id,
name: 'Author Agent 2',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create users with various favorites configurations
await User.create({
_id: user1Id,
name: 'User 1',
email: `user1-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agent1Id }, { agentId: agent2Id }],
});
await User.create({
_id: user2Id,
name: 'User 2',
email: `user2-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: agent1Id }, { model: 'claude-3', endpoint: 'anthropic' }],
});
await User.create({
_id: user3Id,
name: 'User 3',
email: `user3-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: unrelatedAgentId }, { model: 'gpt-4', endpoint: 'openAI' }],
});
// Delete all agents by the author
await deleteUserAgents(authorId.toString());
// Verify all users' favorites are correctly updated
const user1After = await User.findById(user1Id);
expect(user1After.favorites).toHaveLength(0);
const user2After = await User.findById(user2Id);
expect(user2After.favorites).toHaveLength(1);
expect(user2After.favorites.some((f) => f.agentId === agent1Id)).toBe(false);
expect(user2After.favorites.some((f) => f.model === 'claude-3')).toBe(true);
// User 3 should be completely unaffected
const user3After = await User.findById(user3Id);
expect(user3After.favorites).toHaveLength(2);
expect(user3After.favorites.some((f) => f.agentId === unrelatedAgentId)).toBe(true);
expect(user3After.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
});
test('should handle deleteUserAgents when user has no agents', async () => {
const authorWithNoAgentsId = new mongoose.Types.ObjectId();
const otherAuthorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const existingAgentId = `agent_${uuidv4()}`;
// Create agent by different author
await createAgent({
id: existingAgentId,
name: 'Existing Agent',
provider: 'test',
model: 'test-model',
author: otherAuthorId,
});
// Create user with favorites
await User.create({
_id: userId,
name: 'Test User',
email: `test-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ agentId: existingAgentId }, { model: 'gpt-4', endpoint: 'openAI' }],
});
// Delete agents for user with no agents (should be a no-op)
await deleteUserAgents(authorWithNoAgentsId.toString());
// Verify existing agent still exists
expect(await getAgent({ id: existingAgentId })).not.toBeNull();
// Verify user favorites are unchanged
const userAfter = await User.findById(userId);
expect(userAfter.favorites).toHaveLength(2);
expect(userAfter.favorites.some((f) => f.agentId === existingAgentId)).toBe(true);
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
});
test('should handle deleteUserAgents when agents are not in any favorites', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agent1Id = `agent_${uuidv4()}`;
const agent2Id = `agent_${uuidv4()}`;
// Create agents by the author
await createAgent({
id: agent1Id,
name: 'Agent 1',
provider: 'test',
model: 'test-model',
author: authorId,
});
await createAgent({
id: agent2Id,
name: 'Agent 2',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create user with favorites that don't include these agents
await User.create({
_id: userId,
name: 'Test User',
email: `test-${uuidv4()}@example.com`,
provider: 'local',
favorites: [{ model: 'gpt-4', endpoint: 'openAI' }],
});
// Verify agents exist
expect(await getAgent({ id: agent1Id })).not.toBeNull();
expect(await getAgent({ id: agent2Id })).not.toBeNull();
// Delete all agents by the author
await deleteUserAgents(authorId.toString());
// Verify agents are deleted
expect(await getAgent({ id: agent1Id })).toBeNull();
expect(await getAgent({ id: agent2Id })).toBeNull();
// Verify user favorites are unchanged
const userAfter = await User.findById(userId);
expect(userAfter.favorites).toHaveLength(1);
expect(userAfter.favorites.some((f) => f.model === 'gpt-4')).toBe(true);
});
test('should update agent projects', async () => {
@ -733,26 +1179,6 @@ describe('models/Agent', () => {
expect(result).toBe(expected);
});
test('should handle getListAgents with invalid author format', async () => {
try {
const result = await getListAgents({ author: 'invalid-object-id' });
expect(result.data).toEqual([]);
} catch (error) {
expect(error).toBeDefined();
}
});
test('should handle getListAgents with no agents', async () => {
const authorId = new mongoose.Types.ObjectId();
const result = await getListAgents({ author: authorId.toString() });
expect(result).toBeDefined();
expect(result.data).toEqual([]);
expect(result.has_more).toBe(false);
expect(result.first_id).toBeNull();
expect(result.last_id).toBeNull();
});
test('should handle updateAgentProjects with non-existent agent', async () => {
const nonExistentId = `agent_${uuidv4()}`;
const userId = new mongoose.Types.ObjectId();
@ -2366,17 +2792,6 @@ describe('models/Agent', () => {
expect(result).toBeNull();
});
test('should handle getListAgents with no agents', async () => {
const authorId = new mongoose.Types.ObjectId();
const result = await getListAgents({ author: authorId.toString() });
expect(result).toBeDefined();
expect(result.data).toEqual([]);
expect(result.has_more).toBe(false);
expect(result.first_id).toBeNull();
expect(result.last_id).toBeNull();
});
test('should handle updateAgent with MongoDB operators mixed with direct updates', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();

View file

@ -124,10 +124,15 @@ module.exports = {
updateOperation,
{
new: true,
upsert: true,
upsert: metadata?.noUpsert !== true,
},
);
if (!conversation) {
logger.debug('[saveConvo] Conversation not found, skipping update');
return null;
}
return conversation.toObject();
} catch (error) {
logger.error('[saveConvo] Error saving conversation', error);
@ -223,7 +228,7 @@ module.exports = {
},
],
};
} catch (err) {
} catch (_err) {
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
}
if (cursorFilter) {
@ -356,6 +361,7 @@ module.exports = {
const deleteMessagesResult = await deleteMessages({
conversationId: { $in: conversationIds },
user,
});
return { ...deleteConvoResult, messages: deleteMessagesResult };

View file

@ -106,6 +106,47 @@ describe('Conversation Operations', () => {
expect(result.conversationId).toBe(newConversationId);
});
it('should not create a conversation when noUpsert is true and conversation does not exist', async () => {
const nonExistentId = uuidv4();
const result = await saveConvo(
mockReq,
{ conversationId: nonExistentId, title: 'Ghost Title' },
{ noUpsert: true },
);
expect(result).toBeNull();
const dbConvo = await Conversation.findOne({ conversationId: nonExistentId });
expect(dbConvo).toBeNull();
});
it('should update an existing conversation when noUpsert is true', async () => {
await saveConvo(mockReq, mockConversationData);
const result = await saveConvo(
mockReq,
{ conversationId: mockConversationData.conversationId, title: 'Updated Title' },
{ noUpsert: true },
);
expect(result).not.toBeNull();
expect(result.title).toBe('Updated Title');
expect(result.conversationId).toBe(mockConversationData.conversationId);
});
it('should still upsert by default when noUpsert is not provided', async () => {
const newId = uuidv4();
const result = await saveConvo(mockReq, {
conversationId: newId,
title: 'New Conversation',
endpoint: EModelEndpoint.openAI,
});
expect(result).not.toBeNull();
expect(result.conversationId).toBe(newId);
expect(result.title).toBe('New Conversation');
});
it('should handle unsetFields metadata', async () => {
const metadata = {
unsetFields: { someField: 1 },
@ -122,7 +163,6 @@ describe('Conversation Operations', () => {
describe('isTemporary conversation handling', () => {
it('should save a conversation with expiredAt when isTemporary is true', async () => {
// Mock app config with 24 hour retention
mockReq.config.interfaceConfig.temporaryChatRetention = 24;
mockReq.body = { isTemporary: true };
@ -135,7 +175,6 @@ describe('Conversation Operations', () => {
expect(result.expiredAt).toBeDefined();
expect(result.expiredAt).toBeInstanceOf(Date);
// Verify expiredAt is approximately 24 hours in the future
const expectedExpirationTime = new Date(beforeSave.getTime() + 24 * 60 * 60 * 1000);
const actualExpirationTime = new Date(result.expiredAt);
@ -157,7 +196,6 @@ describe('Conversation Operations', () => {
});
it('should save a conversation without expiredAt when isTemporary is not provided', async () => {
// No isTemporary in body
mockReq.body = {};
const result = await saveConvo(mockReq, mockConversationData);
@ -167,7 +205,6 @@ describe('Conversation Operations', () => {
});
it('should use custom retention period from config', async () => {
// Mock app config with 48 hour retention
mockReq.config.interfaceConfig.temporaryChatRetention = 48;
mockReq.body = { isTemporary: true };
@ -512,6 +549,7 @@ describe('Conversation Operations', () => {
expect(result.messages.deletedCount).toBe(5);
expect(deleteMessages).toHaveBeenCalledWith({
conversationId: { $in: [mockConversationData.conversationId] },
user: 'user123',
});
// Verify conversation was deleted

View file

@ -26,7 +26,8 @@ const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
};
/**
* Retrieves tool files (files that are embedded or have a fileIdentifier) from an array of file IDs
* Retrieves tool files (files that are embedded or have a fileIdentifier) from an array of file IDs.
* Note: execute_code files are handled separately by getCodeGeneratedFiles.
* @param {string[]} fileIds - Array of file_id strings to search for
* @param {Set<EToolResources>} toolResourceSet - Optional filter for tool resources
* @returns {Promise<Array<MongoFile>>} Files that match the criteria
@ -37,21 +38,25 @@ const getToolFilesByIds = async (fileIds, toolResourceSet) => {
}
try {
const filter = {
file_id: { $in: fileIds },
$or: [],
};
const orConditions = [];
if (toolResourceSet.has(EToolResources.context)) {
filter.$or.push({ text: { $exists: true, $ne: null }, context: FileContext.agents });
orConditions.push({ text: { $exists: true, $ne: null }, context: FileContext.agents });
}
if (toolResourceSet.has(EToolResources.file_search)) {
filter.$or.push({ embedded: true });
orConditions.push({ embedded: true });
}
if (toolResourceSet.has(EToolResources.execute_code)) {
filter.$or.push({ 'metadata.fileIdentifier': { $exists: true } });
if (orConditions.length === 0) {
return [];
}
const filter = {
file_id: { $in: fileIds },
context: { $ne: FileContext.execute_code }, // Exclude code-generated files
$or: orConditions,
};
const selectFields = { text: 0 };
const sortOptions = { updatedAt: -1 };
@ -62,6 +67,70 @@ const getToolFilesByIds = async (fileIds, toolResourceSet) => {
}
};
/**
* Retrieves files generated by code execution for a given conversation.
* These files are stored locally with fileIdentifier metadata for code env re-upload.
* @param {string} conversationId - The conversation ID to search for
* @param {string[]} [messageIds] - Optional array of messageIds to filter by (for linear thread filtering)
* @returns {Promise<Array<MongoFile>>} Files generated by code execution in the conversation
*/
const getCodeGeneratedFiles = async (conversationId, messageIds) => {
if (!conversationId) {
return [];
}
/** messageIds are required for proper thread filtering of code-generated files */
if (!messageIds || messageIds.length === 0) {
return [];
}
try {
const filter = {
conversationId,
context: FileContext.execute_code,
messageId: { $exists: true, $in: messageIds },
'metadata.fileIdentifier': { $exists: true },
};
const selectFields = { text: 0 };
const sortOptions = { createdAt: 1 };
return await getFiles(filter, sortOptions, selectFields);
} catch (error) {
logger.error('[getCodeGeneratedFiles] Error retrieving code generated files:', error);
return [];
}
};
/**
* Retrieves user-uploaded execute_code files (not code-generated) by their file IDs.
* These are files with fileIdentifier metadata but context is NOT execute_code (e.g., agents or message_attachment).
* File IDs should be collected from message.files arrays in the current thread.
* @param {string[]} fileIds - Array of file IDs to fetch (from message.files in the thread)
* @returns {Promise<Array<MongoFile>>} User-uploaded execute_code files
*/
const getUserCodeFiles = async (fileIds) => {
if (!fileIds || fileIds.length === 0) {
return [];
}
try {
const filter = {
file_id: { $in: fileIds },
context: { $ne: FileContext.execute_code },
'metadata.fileIdentifier': { $exists: true },
};
const selectFields = { text: 0 };
const sortOptions = { createdAt: 1 };
return await getFiles(filter, sortOptions, selectFields);
} catch (error) {
logger.error('[getUserCodeFiles] Error retrieving user code files:', error);
return [];
}
};
/**
* Creates a new file with a TTL of 1 hour.
* @param {MongoFile} data - The file data to be created, must contain file_id.
@ -169,6 +238,8 @@ module.exports = {
findFileById,
getFiles,
getToolFilesByIds,
getCodeGeneratedFiles,
getUserCodeFiles,
createFile,
updateFile,
updateFileUsage,

View file

@ -114,6 +114,28 @@ async function updateAccessPermissions(roleName, permissionsUpdate, roleData) {
}
}
// Migrate legacy SHARED_GLOBAL → SHARE for PROMPTS and AGENTS.
// SHARED_GLOBAL was removed in favour of SHARE in PR #11283. If the DB still has
// SHARED_GLOBAL but not SHARE, inherit the value so sharing intent is preserved.
const legacySharedGlobalTypes = ['PROMPTS', 'AGENTS'];
for (const legacyPermType of legacySharedGlobalTypes) {
const existingTypePerms = currentPermissions[legacyPermType];
if (
existingTypePerms &&
'SHARED_GLOBAL' in existingTypePerms &&
!('SHARE' in existingTypePerms) &&
updates[legacyPermType] &&
// Don't override an explicit SHARE value the caller already provided
!('SHARE' in updates[legacyPermType])
) {
const inheritedValue = existingTypePerms['SHARED_GLOBAL'];
updates[legacyPermType]['SHARE'] = inheritedValue;
logger.info(
`Migrating '${roleName}' role ${legacyPermType}.SHARED_GLOBAL=${inheritedValue} → SHARE`,
);
}
}
for (const [permissionType, permissions] of Object.entries(updates)) {
const currentTypePermissions = currentPermissions[permissionType] || {};
updatedPermissions[permissionType] = { ...currentTypePermissions };
@ -129,6 +151,32 @@ async function updateAccessPermissions(roleName, permissionsUpdate, roleData) {
}
}
// Clean up orphaned SHARED_GLOBAL fields left in DB after the schema rename.
// Since we $set the full permissions object, deleting from updatedPermissions
// is sufficient to remove the field from MongoDB.
for (const legacyPermType of legacySharedGlobalTypes) {
const existingTypePerms = currentPermissions[legacyPermType];
if (existingTypePerms && 'SHARED_GLOBAL' in existingTypePerms) {
if (!updates[legacyPermType]) {
// permType wasn't in the update payload so the migration block above didn't run.
// Create a writable copy and handle the SHARED_GLOBAL → SHARE inheritance here
// to avoid removing SHARED_GLOBAL without writing SHARE (data loss).
updatedPermissions[legacyPermType] = { ...existingTypePerms };
if (!('SHARE' in existingTypePerms)) {
updatedPermissions[legacyPermType]['SHARE'] = existingTypePerms['SHARED_GLOBAL'];
logger.info(
`Migrating '${roleName}' role ${legacyPermType}.SHARED_GLOBAL=${existingTypePerms['SHARED_GLOBAL']} → SHARE`,
);
}
}
delete updatedPermissions[legacyPermType]['SHARED_GLOBAL'];
hasChanges = true;
logger.info(
`Removed legacy SHARED_GLOBAL field from '${roleName}' role ${legacyPermType} permissions`,
);
}
}
if (hasChanges) {
const updateObj = { permissions: updatedPermissions };

View file

@ -46,7 +46,7 @@ describe('updateAccessPermissions', () => {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
SHARE: false,
},
},
}).save();
@ -55,7 +55,7 @@ describe('updateAccessPermissions', () => {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: true,
SHARE: true,
},
});
@ -63,7 +63,7 @@ describe('updateAccessPermissions', () => {
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: true,
SHARE: true,
});
});
@ -74,7 +74,7 @@ describe('updateAccessPermissions', () => {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
SHARE: false,
},
},
}).save();
@ -83,7 +83,7 @@ describe('updateAccessPermissions', () => {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
SHARE: false,
},
});
@ -91,7 +91,7 @@ describe('updateAccessPermissions', () => {
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
SHARE: false,
});
});
@ -110,20 +110,20 @@ describe('updateAccessPermissions', () => {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
SHARE: false,
},
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { SHARED_GLOBAL: true },
[PermissionTypes.PROMPTS]: { SHARE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: true,
SHARE: true,
});
});
@ -134,7 +134,7 @@ describe('updateAccessPermissions', () => {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
SHARE: false,
},
},
}).save();
@ -147,7 +147,7 @@ describe('updateAccessPermissions', () => {
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARED_GLOBAL: false,
SHARE: false,
});
});
@ -155,13 +155,13 @@ describe('updateAccessPermissions', () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
[PermissionTypes.BOOKMARKS]: { USE: true },
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
[PermissionTypes.PROMPTS]: { USE: false, SHARE: true },
[PermissionTypes.BOOKMARKS]: { USE: false },
});
@ -169,7 +169,7 @@ describe('updateAccessPermissions', () => {
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARED_GLOBAL: true,
SHARE: true,
});
expect(updatedRole.permissions[PermissionTypes.BOOKMARKS]).toEqual({ USE: false });
});
@ -178,19 +178,19 @@ describe('updateAccessPermissions', () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
[PermissionTypes.PROMPTS]: { USE: false, SHARE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARED_GLOBAL: true,
SHARE: true,
});
});
@ -214,13 +214,13 @@ describe('updateAccessPermissions', () => {
await new Role({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
[PermissionTypes.MULTI_CONVO]: { USE: false },
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { SHARED_GLOBAL: true },
[PermissionTypes.PROMPTS]: { SHARE: true },
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
@ -228,11 +228,117 @@ describe('updateAccessPermissions', () => {
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: true,
SHARE: true,
});
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
});
it('should inherit SHARED_GLOBAL value into SHARE when SHARE is absent from both DB and update', async () => {
// Simulates the startup backfill path: caller sends SHARE_PUBLIC but not SHARE;
// migration should inherit SHARED_GLOBAL to preserve the deployment's sharing intent.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { USE: true, CREATE: true, SHARED_GLOBAL: true },
[PermissionTypes.AGENTS]: { USE: true, CREATE: true, SHARED_GLOBAL: false },
},
});
await updateAccessPermissions(SystemRoles.USER, {
// No explicit SHARE — migration should inherit from SHARED_GLOBAL
[PermissionTypes.PROMPTS]: { SHARE_PUBLIC: false },
[PermissionTypes.AGENTS]: { SHARE_PUBLIC: false },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
// SHARED_GLOBAL=true → SHARE=true (inherited)
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
// SHARED_GLOBAL=false → SHARE=false (inherited)
expect(updatedRole.permissions[PermissionTypes.AGENTS].SHARE).toBe(false);
// SHARED_GLOBAL cleaned up
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
expect(updatedRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeUndefined();
});
it('should respect explicit SHARE in update payload and not override it with SHARED_GLOBAL', async () => {
// Caller explicitly passes SHARE: false even though SHARED_GLOBAL=true in DB.
// The explicit intent must win; migration must not silently overwrite it.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { USE: true, SHARED_GLOBAL: true },
},
});
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { SHARE: false }, // explicit false — should be preserved
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(false);
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
});
it('should migrate SHARED_GLOBAL to SHARE even when the permType is not in the update payload', async () => {
// Bug #2 regression: cleanup block removes SHARED_GLOBAL but migration block only
// runs when the permType is in the update payload. Without the fix, SHARE would be
// lost when any other permType (e.g. MULTI_CONVO) is the only thing being updated.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: {
USE: true,
SHARED_GLOBAL: true, // legacy — NO SHARE present
},
[PermissionTypes.MULTI_CONVO]: { USE: false },
},
});
// Only update MULTI_CONVO — PROMPTS is intentionally absent from the payload
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
// SHARE should have been inherited from SHARED_GLOBAL, not silently dropped
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
// SHARED_GLOBAL should be removed
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
// Original USE should be untouched
expect(updatedRole.permissions[PermissionTypes.PROMPTS].USE).toBe(true);
// The actual update should have applied
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(true);
});
it('should remove orphaned SHARED_GLOBAL when SHARE already exists and permType is not in update', async () => {
// Safe cleanup case: SHARE already set, SHARED_GLOBAL is just orphaned noise.
// SHARE must not be changed; SHARED_GLOBAL must be removed.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: {
USE: true,
SHARE: true, // already migrated
SHARED_GLOBAL: true, // orphaned
},
[PermissionTypes.MULTI_CONVO]: { USE: false },
},
});
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(true);
});
it('should not update MULTI_CONVO permissions when no changes are needed', async () => {
await new Role({
name: SystemRoles.USER,
@ -271,7 +377,7 @@ describe('initializeRoles', () => {
});
// Example: Check default values for ADMIN role
expect(adminRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBe(true);
expect(adminRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
expect(adminRole.permissions[PermissionTypes.BOOKMARKS].USE).toBe(true);
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBe(true);
});
@ -283,7 +389,7 @@ describe('initializeRoles', () => {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.SHARE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
},
@ -320,7 +426,7 @@ describe('initializeRoles', () => {
expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
expect(userRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
expect(userRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
expect(userRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
expect(userRole.permissions[PermissionTypes.AGENTS].SHARE).toBeDefined();
});
it('should handle multiple runs without duplicating or modifying data', async () => {
@ -348,7 +454,7 @@ describe('initializeRoles', () => {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.SHARE]: false,
},
[PermissionTypes.BOOKMARKS]:
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.BOOKMARKS],
@ -365,7 +471,7 @@ describe('initializeRoles', () => {
expect(adminRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
expect(adminRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
expect(adminRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
expect(adminRole.permissions[PermissionTypes.AGENTS].SHARE).toBeDefined();
});
it('should include MULTI_CONVO permissions when creating default roles', async () => {

View file

@ -1,153 +1,19 @@
const { logger } = require('@librechat/data-schemas');
const { logger, CANCEL_RATE } = require('@librechat/data-schemas');
const { getMultiplier, getCacheMultiplier } = require('./tx');
const { Transaction, Balance } = require('~/db/models');
const cancelRate = 1.15;
/**
* Updates a user's token balance based on a transaction using optimistic concurrency control
* without schema changes. Compatible with DocumentDB.
* @async
* @function
* @param {Object} params - The function parameters.
* @param {string|mongoose.Types.ObjectId} params.user - The user ID.
* @param {number} params.incrementValue - The value to increment the balance by (can be negative).
* @param {import('mongoose').UpdateQuery<import('@librechat/data-schemas').IBalance>['$set']} [params.setValues] - Optional additional fields to set.
* @returns {Promise<Object>} Returns the updated balance document (lean).
* @throws {Error} Throws an error if the update fails after multiple retries.
*/
const updateBalance = async ({ user, incrementValue, setValues }) => {
let maxRetries = 10; // Number of times to retry on conflict
let delay = 50; // Initial retry delay in ms
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
let currentBalanceDoc;
try {
// 1. Read the current document state
currentBalanceDoc = await Balance.findOne({ user }).lean();
const currentCredits = currentBalanceDoc ? currentBalanceDoc.tokenCredits : 0;
// 2. Calculate the desired new state
const potentialNewCredits = currentCredits + incrementValue;
const newCredits = Math.max(0, potentialNewCredits); // Ensure balance doesn't go below zero
// 3. Prepare the update payload
const updatePayload = {
$set: {
tokenCredits: newCredits,
...(setValues || {}), // Merge other values to set
},
};
// 4. Attempt the conditional update or upsert
let updatedBalance = null;
if (currentBalanceDoc) {
// --- Document Exists: Perform Conditional Update ---
// Try to update only if the tokenCredits match the value we read (currentCredits)
updatedBalance = await Balance.findOneAndUpdate(
{
user: user,
tokenCredits: currentCredits, // Optimistic lock: condition based on the read value
},
updatePayload,
{
new: true, // Return the modified document
// lean: true, // .lean() is applied after query execution in Mongoose >= 6
},
).lean(); // Use lean() for plain JS object
if (updatedBalance) {
// Success! The update was applied based on the expected current state.
return updatedBalance;
}
// If updatedBalance is null, it means tokenCredits changed between read and write (conflict).
lastError = new Error(`Concurrency conflict for user ${user} on attempt ${attempt}.`);
// Proceed to retry logic below.
} else {
// --- Document Does Not Exist: Perform Conditional Upsert ---
// Try to insert the document, but only if it still doesn't exist.
// Using tokenCredits: {$exists: false} helps prevent race conditions where
// another process creates the doc between our findOne and findOneAndUpdate.
try {
updatedBalance = await Balance.findOneAndUpdate(
{
user: user,
// Attempt to match only if the document doesn't exist OR was just created
// without tokenCredits (less likely but possible). A simple { user } filter
// might also work, relying on the retry for conflicts.
// Let's use a simpler filter and rely on retry for races.
// tokenCredits: { $exists: false } // This condition might be too strict if doc exists with 0 credits
},
updatePayload,
{
upsert: true, // Create if doesn't exist
new: true, // Return the created/updated document
// setDefaultsOnInsert: true, // Ensure schema defaults are applied on insert
// lean: true,
},
).lean();
if (updatedBalance) {
// Upsert succeeded (likely created the document)
return updatedBalance;
}
// If null, potentially a rare race condition during upsert. Retry should handle it.
lastError = new Error(
`Upsert race condition suspected for user ${user} on attempt ${attempt}.`,
);
} catch (error) {
if (error.code === 11000) {
// E11000 duplicate key error on index
// This means another process created the document *just* before our upsert.
// It's a concurrency conflict during creation. We should retry.
lastError = error; // Store the error
// Proceed to retry logic below.
} else {
// Different error, rethrow
throw error;
}
}
} // End if/else (document exists?)
} catch (error) {
// Catch errors from findOne or unexpected findOneAndUpdate errors
logger.error(`[updateBalance] Error during attempt ${attempt} for user ${user}:`, error);
lastError = error; // Store the error
// Consider stopping retries for non-transient errors, but for now, we retry.
}
// If we reached here, it means the update failed (conflict or error), wait and retry
if (attempt < maxRetries) {
const jitter = Math.random() * delay * 0.5; // Add jitter to delay
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
delay = Math.min(delay * 2, 2000); // Exponential backoff with cap
}
} // End for loop (retries)
// If loop finishes without success, throw the last encountered error or a generic one
logger.error(
`[updateBalance] Failed to update balance for user ${user} after ${maxRetries} attempts.`,
);
throw (
lastError ||
new Error(
`Failed to update balance for user ${user} after maximum retries due to persistent conflicts.`,
)
);
};
const { Transaction } = require('~/db/models');
const { updateBalance } = require('~/models');
/** Method to calculate and set the tokenValue for a transaction */
function calculateTokenValue(txn) {
if (!txn.valueKey || !txn.tokenType) {
txn.tokenValue = txn.rawAmount;
}
const { valueKey, tokenType, model, endpointTokenConfig } = txn;
const multiplier = Math.abs(getMultiplier({ valueKey, tokenType, model, endpointTokenConfig }));
const { valueKey, tokenType, model, endpointTokenConfig, inputTokenCount } = txn;
const multiplier = Math.abs(
getMultiplier({ valueKey, tokenType, model, endpointTokenConfig, inputTokenCount }),
);
txn.rate = multiplier;
txn.tokenValue = txn.rawAmount * multiplier;
if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') {
txn.tokenValue = Math.ceil(txn.tokenValue * cancelRate);
txn.rate *= cancelRate;
txn.tokenValue = Math.ceil(txn.tokenValue * CANCEL_RATE);
txn.rate *= CANCEL_RATE;
}
}
@ -166,6 +32,7 @@ async function createAutoRefillTransaction(txData) {
}
const transaction = new Transaction(txData);
transaction.endpointTokenConfig = txData.endpointTokenConfig;
transaction.inputTokenCount = txData.inputTokenCount;
calculateTokenValue(transaction);
await transaction.save();
@ -200,6 +67,7 @@ async function createTransaction(_txData) {
const transaction = new Transaction(txData);
transaction.endpointTokenConfig = txData.endpointTokenConfig;
transaction.inputTokenCount = txData.inputTokenCount;
calculateTokenValue(transaction);
await transaction.save();
@ -231,10 +99,9 @@ async function createStructuredTransaction(_txData) {
return;
}
const transaction = new Transaction({
...txData,
endpointTokenConfig: txData.endpointTokenConfig,
});
const transaction = new Transaction(txData);
transaction.endpointTokenConfig = txData.endpointTokenConfig;
transaction.inputTokenCount = txData.inputTokenCount;
calculateStructuredTokenValue(transaction);
@ -266,10 +133,15 @@ function calculateStructuredTokenValue(txn) {
return;
}
const { model, endpointTokenConfig } = txn;
const { model, endpointTokenConfig, inputTokenCount } = txn;
if (txn.tokenType === 'prompt') {
const inputMultiplier = getMultiplier({ tokenType: 'prompt', model, endpointTokenConfig });
const inputMultiplier = getMultiplier({
tokenType: 'prompt',
model,
endpointTokenConfig,
inputTokenCount,
});
const writeMultiplier =
getCacheMultiplier({ cacheType: 'write', model, endpointTokenConfig }) ?? inputMultiplier;
const readMultiplier =
@ -304,18 +176,23 @@ function calculateStructuredTokenValue(txn) {
txn.rawAmount = -totalPromptTokens;
} else if (txn.tokenType === 'completion') {
const multiplier = getMultiplier({ tokenType: txn.tokenType, model, endpointTokenConfig });
const multiplier = getMultiplier({
tokenType: txn.tokenType,
model,
endpointTokenConfig,
inputTokenCount,
});
txn.rate = Math.abs(multiplier);
txn.tokenValue = -Math.abs(txn.rawAmount) * multiplier;
txn.rawAmount = -Math.abs(txn.rawAmount);
}
if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') {
txn.tokenValue = Math.ceil(txn.tokenValue * cancelRate);
txn.rate *= cancelRate;
txn.tokenValue = Math.ceil(txn.tokenValue * CANCEL_RATE);
txn.rate *= CANCEL_RATE;
if (txn.rateDetail) {
txn.rateDetail = Object.fromEntries(
Object.entries(txn.rateDetail).map(([k, v]) => [k, v * cancelRate]),
Object.entries(txn.rateDetail).map(([k, v]) => [k, v * CANCEL_RATE]),
);
}
}

View file

@ -1,8 +1,10 @@
const mongoose = require('mongoose');
const { recordCollectedUsage } = require('@librechat/api');
const { createMethods } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
const { getMultiplier, getCacheMultiplier } = require('./tx');
const { getMultiplier, getCacheMultiplier, premiumTokenValues, tokenValues } = require('./tx');
const { createTransaction, createStructuredTransaction } = require('./Transaction');
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
const { Balance, Transaction } = require('~/db/models');
let mongoServer;
@ -564,3 +566,760 @@ describe('Transactions Config Tests', () => {
expect(balance.tokenCredits).toBe(initialBalance);
});
});
describe('calculateTokenValue Edge Cases', () => {
test('should derive multiplier from model when valueKey is not provided', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-4';
const promptTokens = 1000;
const result = await createTransaction({
user: userId,
conversationId: 'test-no-valuekey',
model,
tokenType: 'prompt',
rawAmount: -promptTokens,
context: 'test',
balance: { enabled: true },
});
const expectedRate = getMultiplier({ model, tokenType: 'prompt' });
expect(result.rate).toBe(expectedRate);
const tx = await Transaction.findOne({ user: userId });
expect(tx.tokenValue).toBe(-promptTokens * expectedRate);
expect(tx.rate).toBe(expectedRate);
});
test('should derive valueKey and apply correct rate for an unknown model with tokenType', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
await createTransaction({
user: userId,
conversationId: 'test-unknown-model',
model: 'some-unrecognized-model-xyz',
tokenType: 'prompt',
rawAmount: -500,
context: 'test',
balance: { enabled: true },
});
const tx = await Transaction.findOne({ user: userId });
expect(tx.rate).toBeDefined();
expect(tx.rate).toBeGreaterThan(0);
expect(tx.tokenValue).toBe(tx.rawAmount * tx.rate);
});
test('should correctly apply model-derived multiplier without valueKey for completion', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-opus-4-6';
const completionTokens = 500;
const result = await createTransaction({
user: userId,
conversationId: 'test-completion-no-valuekey',
model,
tokenType: 'completion',
rawAmount: -completionTokens,
context: 'test',
balance: { enabled: true },
});
const expectedRate = getMultiplier({ model, tokenType: 'completion' });
expect(expectedRate).toBe(tokenValues[model].completion);
expect(result.rate).toBe(expectedRate);
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(
initialBalance - completionTokens * expectedRate,
0,
);
});
});
describe('Premium Token Pricing Integration Tests', () => {
test('spendTokens should apply standard pricing when prompt tokens are below premium threshold', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-opus-4-6';
const promptTokens = 100000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-premium-below',
model,
context: 'test',
endpointTokenConfig: null,
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const standardPromptRate = tokenValues[model].prompt;
const standardCompletionRate = tokenValues[model].completion;
const expectedCost =
promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
test('spendTokens should apply premium pricing when prompt tokens exceed premium threshold', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-opus-4-6';
const promptTokens = 250000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-premium-above',
model,
context: 'test',
endpointTokenConfig: null,
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const premiumPromptRate = premiumTokenValues[model].prompt;
const premiumCompletionRate = premiumTokenValues[model].completion;
const expectedCost =
promptTokens * premiumPromptRate + completionTokens * premiumCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
test('spendTokens should apply standard pricing at exactly the premium threshold', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-opus-4-6';
const promptTokens = premiumTokenValues[model].threshold;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-premium-exact',
model,
context: 'test',
endpointTokenConfig: null,
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const standardPromptRate = tokenValues[model].prompt;
const standardCompletionRate = tokenValues[model].completion;
const expectedCost =
promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
test('spendStructuredTokens should apply premium pricing when total input tokens exceed threshold', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-opus-4-6';
const txData = {
user: userId,
conversationId: 'test-structured-premium',
model,
context: 'message',
endpointTokenConfig: null,
balance: { enabled: true },
};
const tokenUsage = {
promptTokens: {
input: 200000,
write: 10000,
read: 5000,
},
completionTokens: 1000,
};
const totalInput =
tokenUsage.promptTokens.input + tokenUsage.promptTokens.write + tokenUsage.promptTokens.read;
await spendStructuredTokens(txData, tokenUsage);
const premiumPromptRate = premiumTokenValues[model].prompt;
const premiumCompletionRate = premiumTokenValues[model].completion;
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
const expectedPromptCost =
tokenUsage.promptTokens.input * premiumPromptRate +
tokenUsage.promptTokens.write * writeMultiplier +
tokenUsage.promptTokens.read * readMultiplier;
const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate;
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
const updatedBalance = await Balance.findOne({ user: userId });
expect(totalInput).toBeGreaterThan(premiumTokenValues[model].threshold);
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0);
});
test('spendStructuredTokens should apply standard pricing when total input tokens are below threshold', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-opus-4-6';
const txData = {
user: userId,
conversationId: 'test-structured-standard',
model,
context: 'message',
endpointTokenConfig: null,
balance: { enabled: true },
};
const tokenUsage = {
promptTokens: {
input: 50000,
write: 10000,
read: 5000,
},
completionTokens: 1000,
};
const totalInput =
tokenUsage.promptTokens.input + tokenUsage.promptTokens.write + tokenUsage.promptTokens.read;
await spendStructuredTokens(txData, tokenUsage);
const standardPromptRate = tokenValues[model].prompt;
const standardCompletionRate = tokenValues[model].completion;
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
const expectedPromptCost =
tokenUsage.promptTokens.input * standardPromptRate +
tokenUsage.promptTokens.write * writeMultiplier +
tokenUsage.promptTokens.read * readMultiplier;
const expectedCompletionCost = tokenUsage.completionTokens * standardCompletionRate;
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
const updatedBalance = await Balance.findOne({ user: userId });
expect(totalInput).toBeLessThanOrEqual(premiumTokenValues[model].threshold);
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0);
});
test('spendTokens should apply standard pricing for gemini-3.1-pro-preview below threshold', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gemini-3.1-pro-preview';
const promptTokens = 100000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-gemini31-below',
model,
context: 'test',
endpointTokenConfig: null,
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const standardPromptRate = tokenValues['gemini-3.1'].prompt;
const standardCompletionRate = tokenValues['gemini-3.1'].completion;
const expectedCost =
promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
test('spendTokens should apply premium pricing for gemini-3.1-pro-preview above threshold', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gemini-3.1-pro-preview';
const promptTokens = 250000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-gemini31-above',
model,
context: 'test',
endpointTokenConfig: null,
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const premiumPromptRate = premiumTokenValues['gemini-3.1'].prompt;
const premiumCompletionRate = premiumTokenValues['gemini-3.1'].completion;
const expectedCost =
promptTokens * premiumPromptRate + completionTokens * premiumCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
test('spendTokens should apply standard pricing for gemini-3.1-pro-preview at exactly the threshold', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gemini-3.1-pro-preview';
const promptTokens = premiumTokenValues['gemini-3.1'].threshold;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-gemini31-exact',
model,
context: 'test',
endpointTokenConfig: null,
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const standardPromptRate = tokenValues['gemini-3.1'].prompt;
const standardCompletionRate = tokenValues['gemini-3.1'].completion;
const expectedCost =
promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
test('spendStructuredTokens should apply premium pricing for gemini-3.1 when total input exceeds threshold', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gemini-3.1-pro-preview';
const txData = {
user: userId,
conversationId: 'test-gemini31-structured-premium',
model,
context: 'message',
endpointTokenConfig: null,
balance: { enabled: true },
};
const tokenUsage = {
promptTokens: {
input: 200000,
write: 10000,
read: 5000,
},
completionTokens: 1000,
};
const totalInput =
tokenUsage.promptTokens.input + tokenUsage.promptTokens.write + tokenUsage.promptTokens.read;
await spendStructuredTokens(txData, tokenUsage);
const premiumPromptRate = premiumTokenValues['gemini-3.1'].prompt;
const premiumCompletionRate = premiumTokenValues['gemini-3.1'].completion;
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
const expectedPromptCost =
tokenUsage.promptTokens.input * premiumPromptRate +
tokenUsage.promptTokens.write * writeMultiplier +
tokenUsage.promptTokens.read * readMultiplier;
const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate;
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
const updatedBalance = await Balance.findOne({ user: userId });
expect(totalInput).toBeGreaterThan(premiumTokenValues['gemini-3.1'].threshold);
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0);
});
test('non-premium models should not be affected by inputTokenCount regardless of prompt size', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-opus-4-5';
const promptTokens = 300000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-no-premium',
model,
context: 'test',
endpointTokenConfig: null,
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const standardPromptRate = getMultiplier({ model, tokenType: 'prompt' });
const standardCompletionRate = getMultiplier({ model, tokenType: 'completion' });
const expectedCost =
promptTokens * standardPromptRate + completionTokens * standardCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
});
describe('Bulk path parity', () => {
/**
* Each test here mirrors an existing legacy test above, replacing spendTokens/
* spendStructuredTokens with recordCollectedUsage + bulk deps.
* The balance deduction and transaction document fields must be numerically identical.
*/
let bulkDeps;
let methods;
beforeEach(() => {
methods = createMethods(mongoose);
bulkDeps = {
spendTokens: () => Promise.resolve(),
spendStructuredTokens: () => Promise.resolve(),
pricing: { getMultiplier, getCacheMultiplier },
bulkWriteOps: {
insertMany: methods.bulkInsertTransactions,
updateBalance: methods.updateBalance,
},
};
});
test('balance should decrease when spending tokens via bulk path', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
const promptTokens = 100;
const completionTokens = 50;
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model,
context: 'test',
balance: { enabled: true },
transactions: { enabled: true },
collectedUsage: [{ input_tokens: promptTokens, output_tokens: completionTokens, model }],
});
const updatedBalance = await Balance.findOne({ user: userId });
const promptMultiplier = getMultiplier({
model,
tokenType: 'prompt',
inputTokenCount: promptTokens,
});
const completionMultiplier = getMultiplier({
model,
tokenType: 'completion',
inputTokenCount: promptTokens,
});
const expectedTotalCost =
promptTokens * promptMultiplier + completionTokens * completionMultiplier;
const expectedBalance = initialBalance - expectedTotalCost;
expect(updatedBalance.tokenCredits).toBeCloseTo(expectedBalance, 0);
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(2);
});
test('bulk path should not update balance when balance.enabled is false', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model,
context: 'test',
balance: { enabled: false },
transactions: { enabled: true },
collectedUsage: [{ input_tokens: 100, output_tokens: 50, model }],
});
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBe(initialBalance);
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(2); // transactions still recorded
});
test('bulk path should not insert when transactions.enabled is false', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model: 'gpt-3.5-turbo',
context: 'test',
balance: { enabled: true },
transactions: { enabled: false },
collectedUsage: [{ input_tokens: 100, output_tokens: 50, model: 'gpt-3.5-turbo' }],
});
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(0);
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
test('bulk path handles incomplete context for completion tokens — same CANCEL_RATE as legacy', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 17613154.55;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-3-5-sonnet';
const promptTokens = 10;
const completionTokens = 50;
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-convo',
model,
context: 'incomplete',
balance: { enabled: true },
transactions: { enabled: true },
collectedUsage: [{ input_tokens: promptTokens, output_tokens: completionTokens, model }],
});
const txns = await Transaction.find({ user: userId }).lean();
const completionTx = txns.find((t) => t.tokenType === 'completion');
const completionMultiplier = getMultiplier({
model,
tokenType: 'completion',
inputTokenCount: promptTokens,
});
expect(completionTx.tokenValue).toBeCloseTo(-completionTokens * completionMultiplier * 1.15, 0);
});
test('bulk path structured tokens — balance deduction matches legacy spendStructuredTokens', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 17613154.55;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-3-5-sonnet';
const promptInput = 11;
const promptWrite = 140522;
const promptRead = 0;
const completionTokens = 5;
const totalInput = promptInput + promptWrite + promptRead;
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-convo',
model,
context: 'message',
balance: { enabled: true },
transactions: { enabled: true },
collectedUsage: [
{
input_tokens: promptInput,
output_tokens: completionTokens,
model,
input_token_details: { cache_creation: promptWrite, cache_read: promptRead },
},
],
});
const promptMultiplier = getMultiplier({
model,
tokenType: 'prompt',
inputTokenCount: totalInput,
});
const completionMultiplier = getMultiplier({
model,
tokenType: 'completion',
inputTokenCount: totalInput,
});
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }) ?? promptMultiplier;
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }) ?? promptMultiplier;
const expectedPromptCost =
promptInput * promptMultiplier + promptWrite * writeMultiplier + promptRead * readMultiplier;
const expectedCompletionCost = completionTokens * completionMultiplier;
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
const expectedBalance = initialBalance - expectedTotalCost;
const updatedBalance = await Balance.findOne({ user: userId });
expect(Math.abs(updatedBalance.tokenCredits - expectedBalance)).toBeLessThan(100);
});
test('premium pricing above threshold via bulk path — same balance as legacy', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-opus-4-6';
const promptTokens = 250000;
const completionTokens = 500;
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-premium',
model,
context: 'test',
balance: { enabled: true },
transactions: { enabled: true },
collectedUsage: [{ input_tokens: promptTokens, output_tokens: completionTokens, model }],
});
const premiumPromptRate = premiumTokenValues[model].prompt;
const premiumCompletionRate = premiumTokenValues[model].completion;
const expectedCost =
promptTokens * premiumPromptRate + completionTokens * premiumCompletionRate;
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
test('real-world multi-entry batch: 5 sequential tool calls — same total deduction as 5 legacy spendTokens calls', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 100000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-opus-4-5-20251101';
const calls = [
{ input_tokens: 31596, output_tokens: 151 },
{ input_tokens: 35368, output_tokens: 150 },
{ input_tokens: 58362, output_tokens: 295 },
{ input_tokens: 112604, output_tokens: 193 },
{ input_tokens: 257440, output_tokens: 2217 },
];
let expectedTotalCost = 0;
for (const { input_tokens, output_tokens } of calls) {
const pm = getMultiplier({ model, tokenType: 'prompt', inputTokenCount: input_tokens });
const cm = getMultiplier({ model, tokenType: 'completion', inputTokenCount: input_tokens });
expectedTotalCost += input_tokens * pm + output_tokens * cm;
}
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-sequential',
model,
context: 'message',
balance: { enabled: true },
transactions: { enabled: true },
collectedUsage: calls.map((c) => ({ ...c, model })),
});
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(10); // 5 calls × 2 docs (prompt + completion)
const updatedBalance = await Balance.findOne({ user: userId });
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0);
});
test('bulk path should save transaction but not update balance when balance disabled, transactions enabled', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model: 'gpt-3.5-turbo',
context: 'test',
balance: { enabled: false },
transactions: { enabled: true },
collectedUsage: [{ input_tokens: 100, output_tokens: 50, model: 'gpt-3.5-turbo' }],
});
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(2);
expect(txns[0].rawAmount).toBeDefined();
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
test('bulk path structured tokens should not save when transactions.enabled is false', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model: 'claude-3-5-sonnet',
context: 'message',
balance: { enabled: true },
transactions: { enabled: false },
collectedUsage: [
{
input_tokens: 10,
output_tokens: 5,
model: 'claude-3-5-sonnet',
input_token_details: { cache_creation: 100, cache_read: 5 },
},
],
});
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(0);
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
test('bulk path structured tokens should save but not update balance when balance disabled', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000;
await Balance.create({ user: userId, tokenCredits: initialBalance });
await recordCollectedUsage(bulkDeps, {
user: userId.toString(),
conversationId: 'test-conversation-id',
model: 'claude-3-5-sonnet',
context: 'message',
balance: { enabled: false },
transactions: { enabled: true },
collectedUsage: [
{
input_tokens: 10,
output_tokens: 5,
model: 'claude-3-5-sonnet',
input_token_details: { cache_creation: 100, cache_read: 5 },
},
],
});
const txns = await Transaction.find({ user: userId }).lean();
expect(txns).toHaveLength(2);
const promptTx = txns.find((t) => t.tokenType === 'prompt');
expect(promptTx.inputTokens).toBe(-10);
expect(promptTx.writeTokens).toBe(-100);
expect(promptTx.readTokens).toBe(-5);
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBe(initialBalance);
});
});

View file

@ -24,12 +24,14 @@ const spendTokens = async (txData, tokenUsage) => {
},
);
let prompt, completion;
const normalizedPromptTokens = Math.max(promptTokens ?? 0, 0);
try {
if (promptTokens !== undefined) {
prompt = await createTransaction({
...txData,
tokenType: 'prompt',
rawAmount: promptTokens === 0 ? 0 : -Math.max(promptTokens, 0),
rawAmount: promptTokens === 0 ? 0 : -normalizedPromptTokens,
inputTokenCount: normalizedPromptTokens,
});
}
@ -38,6 +40,7 @@ const spendTokens = async (txData, tokenUsage) => {
...txData,
tokenType: 'completion',
rawAmount: completionTokens === 0 ? 0 : -Math.max(completionTokens, 0),
inputTokenCount: normalizedPromptTokens,
});
}
@ -87,21 +90,31 @@ const spendStructuredTokens = async (txData, tokenUsage) => {
let prompt, completion;
try {
if (promptTokens) {
const { input = 0, write = 0, read = 0 } = promptTokens;
const input = Math.max(promptTokens.input ?? 0, 0);
const write = Math.max(promptTokens.write ?? 0, 0);
const read = Math.max(promptTokens.read ?? 0, 0);
const totalInputTokens = input + write + read;
prompt = await createStructuredTransaction({
...txData,
tokenType: 'prompt',
inputTokens: -input,
writeTokens: -write,
readTokens: -read,
inputTokenCount: totalInputTokens,
});
}
if (completionTokens) {
const totalInputTokens = promptTokens
? Math.max(promptTokens.input ?? 0, 0) +
Math.max(promptTokens.write ?? 0, 0) +
Math.max(promptTokens.read ?? 0, 0)
: undefined;
completion = await createTransaction({
...txData,
tokenType: 'completion',
rawAmount: -completionTokens,
rawAmount: -Math.max(completionTokens, 0),
inputTokenCount: totalInputTokens,
});
}

View file

@ -1,7 +1,8 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
const { createTransaction, createAutoRefillTransaction } = require('./Transaction');
const { tokenValues, premiumTokenValues, getCacheMultiplier } = require('./tx');
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
require('~/db/models');
@ -734,4 +735,457 @@ describe('spendTokens', () => {
expect(balance).toBeDefined();
expect(balance.tokenCredits).toBeLessThan(10000); // Balance should be reduced
});
describe('premium token pricing', () => {
it('should charge standard rates for claude-opus-4-6 when prompt tokens are below threshold', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'claude-opus-4-6';
const promptTokens = 100000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-standard-pricing',
model,
context: 'test',
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const expectedCost =
promptTokens * tokenValues[model].prompt + completionTokens * tokenValues[model].completion;
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
it('should charge premium rates for claude-opus-4-6 when prompt tokens exceed threshold', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'claude-opus-4-6';
const promptTokens = 250000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-premium-pricing',
model,
context: 'test',
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const expectedCost =
promptTokens * premiumTokenValues[model].prompt +
completionTokens * premiumTokenValues[model].completion;
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
it('should charge premium rates for both prompt and completion in structured tokens when above threshold', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'claude-opus-4-6';
const txData = {
user: userId,
conversationId: 'test-structured-premium',
model,
context: 'test',
balance: { enabled: true },
};
const tokenUsage = {
promptTokens: {
input: 200000,
write: 10000,
read: 5000,
},
completionTokens: 1000,
};
const result = await spendStructuredTokens(txData, tokenUsage);
const premiumPromptRate = premiumTokenValues[model].prompt;
const premiumCompletionRate = premiumTokenValues[model].completion;
const writeRate = getCacheMultiplier({ model, cacheType: 'write' });
const readRate = getCacheMultiplier({ model, cacheType: 'read' });
const expectedPromptCost =
tokenUsage.promptTokens.input * premiumPromptRate +
tokenUsage.promptTokens.write * writeRate +
tokenUsage.promptTokens.read * readRate;
const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate;
expect(result.prompt.prompt).toBeCloseTo(-expectedPromptCost, 0);
expect(result.completion.completion).toBeCloseTo(-expectedCompletionCost, 0);
});
it('should charge standard rates for structured tokens when below threshold', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'claude-opus-4-6';
const txData = {
user: userId,
conversationId: 'test-structured-standard',
model,
context: 'test',
balance: { enabled: true },
};
const tokenUsage = {
promptTokens: {
input: 50000,
write: 10000,
read: 5000,
},
completionTokens: 1000,
};
const result = await spendStructuredTokens(txData, tokenUsage);
const standardPromptRate = tokenValues[model].prompt;
const standardCompletionRate = tokenValues[model].completion;
const writeRate = getCacheMultiplier({ model, cacheType: 'write' });
const readRate = getCacheMultiplier({ model, cacheType: 'read' });
const expectedPromptCost =
tokenUsage.promptTokens.input * standardPromptRate +
tokenUsage.promptTokens.write * writeRate +
tokenUsage.promptTokens.read * readRate;
const expectedCompletionCost = tokenUsage.completionTokens * standardCompletionRate;
expect(result.prompt.prompt).toBeCloseTo(-expectedPromptCost, 0);
expect(result.completion.completion).toBeCloseTo(-expectedCompletionCost, 0);
});
it('should charge standard rates for gemini-3.1-pro-preview when prompt tokens are below threshold', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'gemini-3.1-pro-preview';
const promptTokens = 100000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-gemini31-standard-pricing',
model,
context: 'test',
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const expectedCost =
promptTokens * tokenValues['gemini-3.1'].prompt +
completionTokens * tokenValues['gemini-3.1'].completion;
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
it('should charge premium rates for gemini-3.1-pro-preview when prompt tokens exceed threshold', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'gemini-3.1-pro-preview';
const promptTokens = 250000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-gemini31-premium-pricing',
model,
context: 'test',
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const expectedCost =
promptTokens * premiumTokenValues['gemini-3.1'].prompt +
completionTokens * premiumTokenValues['gemini-3.1'].completion;
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
it('should charge premium rates for gemini-3.1-pro-preview-customtools when prompt tokens exceed threshold', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'gemini-3.1-pro-preview-customtools';
const promptTokens = 250000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-gemini31-customtools-premium',
model,
context: 'test',
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const expectedCost =
promptTokens * premiumTokenValues['gemini-3.1'].prompt +
completionTokens * premiumTokenValues['gemini-3.1'].completion;
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
it('should charge premium rates for structured gemini-3.1 tokens when total input exceeds threshold', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'gemini-3.1-pro-preview';
const txData = {
user: userId,
conversationId: 'test-gemini31-structured-premium',
model,
context: 'test',
balance: { enabled: true },
};
const tokenUsage = {
promptTokens: {
input: 200000,
write: 10000,
read: 5000,
},
completionTokens: 1000,
};
const result = await spendStructuredTokens(txData, tokenUsage);
const premiumPromptRate = premiumTokenValues['gemini-3.1'].prompt;
const premiumCompletionRate = premiumTokenValues['gemini-3.1'].completion;
const writeRate = getCacheMultiplier({ model, cacheType: 'write' });
const readRate = getCacheMultiplier({ model, cacheType: 'read' });
const expectedPromptCost =
tokenUsage.promptTokens.input * premiumPromptRate +
tokenUsage.promptTokens.write * writeRate +
tokenUsage.promptTokens.read * readRate;
const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate;
expect(result.prompt.prompt).toBeCloseTo(-expectedPromptCost, 0);
expect(result.completion.completion).toBeCloseTo(-expectedCompletionCost, 0);
});
it('should not apply premium pricing to non-premium models regardless of prompt size', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'claude-opus-4-5';
const promptTokens = 300000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-no-premium',
model,
context: 'test',
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const expectedCost =
promptTokens * tokenValues[model].prompt + completionTokens * tokenValues[model].completion;
const balance = await Balance.findOne({ user: userId });
expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
});
describe('inputTokenCount Normalization', () => {
it('should normalize negative promptTokens to zero for inputTokenCount', async () => {
await Balance.create({
user: userId,
tokenCredits: 100000000,
});
const txData = {
user: userId,
conversationId: 'test-negative-prompt',
model: 'claude-opus-4-6',
context: 'test',
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens: -500, completionTokens: 100 });
const transactions = await Transaction.find({ user: userId }).sort({ tokenType: 1 });
const completionTx = transactions.find((t) => t.tokenType === 'completion');
const promptTx = transactions.find((t) => t.tokenType === 'prompt');
expect(Math.abs(promptTx.rawAmount)).toBe(0);
expect(completionTx.rawAmount).toBe(-100);
const standardCompletionRate = tokenValues['claude-opus-4-6'].completion;
expect(completionTx.rate).toBe(standardCompletionRate);
});
it('should use normalized inputTokenCount for premium threshold check on completion', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'claude-opus-4-6';
const promptTokens = 250000;
const completionTokens = 500;
const txData = {
user: userId,
conversationId: 'test-normalized-premium',
model,
context: 'test',
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens, completionTokens });
const transactions = await Transaction.find({ user: userId }).sort({ tokenType: 1 });
const completionTx = transactions.find((t) => t.tokenType === 'completion');
const promptTx = transactions.find((t) => t.tokenType === 'prompt');
const premiumPromptRate = premiumTokenValues[model].prompt;
const premiumCompletionRate = premiumTokenValues[model].completion;
expect(promptTx.rate).toBe(premiumPromptRate);
expect(completionTx.rate).toBe(premiumCompletionRate);
});
it('should keep inputTokenCount as zero when promptTokens is zero', async () => {
await Balance.create({
user: userId,
tokenCredits: 100000000,
});
const txData = {
user: userId,
conversationId: 'test-zero-prompt',
model: 'claude-opus-4-6',
context: 'test',
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens: 0, completionTokens: 100 });
const transactions = await Transaction.find({ user: userId }).sort({ tokenType: 1 });
const completionTx = transactions.find((t) => t.tokenType === 'completion');
const promptTx = transactions.find((t) => t.tokenType === 'prompt');
expect(Math.abs(promptTx.rawAmount)).toBe(0);
const standardCompletionRate = tokenValues['claude-opus-4-6'].completion;
expect(completionTx.rate).toBe(standardCompletionRate);
});
it('should not trigger premium pricing with negative promptTokens on premium model', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'claude-opus-4-6';
const txData = {
user: userId,
conversationId: 'test-negative-no-premium',
model,
context: 'test',
balance: { enabled: true },
};
await spendTokens(txData, { promptTokens: -300000, completionTokens: 500 });
const transactions = await Transaction.find({ user: userId }).sort({ tokenType: 1 });
const completionTx = transactions.find((t) => t.tokenType === 'completion');
const standardCompletionRate = tokenValues[model].completion;
expect(completionTx.rate).toBe(standardCompletionRate);
});
it('should normalize negative structured token values to zero in spendStructuredTokens', async () => {
const initialBalance = 100000000;
await Balance.create({
user: userId,
tokenCredits: initialBalance,
});
const model = 'claude-opus-4-6';
const txData = {
user: userId,
conversationId: 'test-negative-structured',
model,
context: 'test',
balance: { enabled: true },
};
const tokenUsage = {
promptTokens: { input: -100, write: 50, read: -30 },
completionTokens: -200,
};
await spendStructuredTokens(txData, tokenUsage);
const transactions = await Transaction.find({
user: userId,
conversationId: 'test-negative-structured',
}).sort({ tokenType: 1 });
const completionTx = transactions.find((t) => t.tokenType === 'completion');
const promptTx = transactions.find((t) => t.tokenType === 'prompt');
expect(Math.abs(promptTx.inputTokens)).toBe(0);
expect(promptTx.writeTokens).toBe(-50);
expect(Math.abs(promptTx.readTokens)).toBe(0);
expect(Math.abs(completionTx.rawAmount)).toBe(0);
const standardRate = tokenValues[model].completion;
expect(completionTx.rate).toBe(standardRate);
});
});
});

View file

@ -1,10 +1,27 @@
const { matchModelName, findMatchingPattern } = require('@librechat/api');
const defaultRate = 6;
/**
* Token Pricing Configuration
*
* Pattern Matching
* ================
* `findMatchingPattern` (from @librechat/api) uses `modelName.includes(key)` and selects
* the LONGEST matching key. If a key's length equals the model name's length (exact match),
* it returns immediately. Definition order does NOT affect correctness.
*
* Key ordering matters only for:
* 1. Performance: list older/less common models first so newer/common models
* are found earlier in the reverse scan.
* 2. Same-length tie-breaking: the last-defined key wins on equal-length matches.
*
* This applies to BOTH `tokenValues` and `cacheTokenValues` objects.
*/
/**
* AWS Bedrock pricing
* source: https://aws.amazon.com/bedrock/pricing/
* */
*/
const bedrockValues = {
// Basic llama2 patterns (base defaults to smallest variant)
llama2: { prompt: 0.75, completion: 1.0 },
@ -80,6 +97,11 @@ const bedrockValues = {
'nova-pro': { prompt: 0.8, completion: 3.2 },
'nova-premier': { prompt: 2.5, completion: 12.5 },
'deepseek.r1': { prompt: 1.35, completion: 5.4 },
// Moonshot/Kimi models on Bedrock
'moonshot.kimi': { prompt: 0.6, completion: 2.5 },
'moonshot.kimi-k2': { prompt: 0.6, completion: 2.5 },
'moonshot.kimi-k2.5': { prompt: 0.6, completion: 3.0 },
'moonshot.kimi-k2-thinking': { prompt: 0.6, completion: 2.5 },
};
/**
@ -115,9 +137,14 @@ const tokenValues = Object.assign(
'gpt-5': { prompt: 1.25, completion: 10 },
'gpt-5.1': { prompt: 1.25, completion: 10 },
'gpt-5.2': { prompt: 1.75, completion: 14 },
'gpt-5.3': { prompt: 1.75, completion: 14 },
'gpt-5.4': { prompt: 2.5, completion: 15 },
// TODO: gpt-5.4-pro pricing not yet officially published — verify before release
'gpt-5.4-pro': { prompt: 5, completion: 30 },
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
'gpt-5-mini': { prompt: 0.25, completion: 2 },
'gpt-5-pro': { prompt: 15, completion: 120 },
'gpt-5.2-pro': { prompt: 21, completion: 168 },
o1: { prompt: 15, completion: 60 },
'o1-mini': { prompt: 1.1, completion: 4.4 },
'o1-preview': { prompt: 15, completion: 60 },
@ -139,7 +166,9 @@ const tokenValues = Object.assign(
'claude-haiku-4-5': { prompt: 1, completion: 5 },
'claude-opus-4': { prompt: 15, completion: 75 },
'claude-opus-4-5': { prompt: 5, completion: 25 },
'claude-opus-4-6': { prompt: 5, completion: 25 },
'claude-sonnet-4': { prompt: 3, completion: 15 },
'claude-sonnet-4-6': { prompt: 3, completion: 15 },
'command-r': { prompt: 0.5, completion: 1.5 },
'command-r-plus': { prompt: 3, completion: 15 },
'command-text': { prompt: 1.5, completion: 2.0 },
@ -163,6 +192,8 @@ const tokenValues = Object.assign(
'gemini-2.5-flash-image': { prompt: 0.15, completion: 30 },
'gemini-3': { prompt: 2, completion: 12 },
'gemini-3-pro-image': { prompt: 2, completion: 120 },
'gemini-3.1': { prompt: 2, completion: 12 },
'gemini-3.1-flash-lite': { prompt: 0.25, completion: 1.5 },
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
'grok-beta': { prompt: 5.0, completion: 15.0 },
@ -189,7 +220,31 @@ const tokenValues = Object.assign(
'pixtral-large': { prompt: 2.0, completion: 6.0 },
'mistral-large': { prompt: 2.0, completion: 6.0 },
'mixtral-8x22b': { prompt: 0.65, completion: 0.65 },
kimi: { prompt: 0.14, completion: 2.49 }, // Base pattern (using kimi-k2 pricing)
// Moonshot/Kimi models (base patterns first, specific patterns last for correct matching)
kimi: { prompt: 0.6, completion: 2.5 }, // Base pattern
moonshot: { prompt: 2.0, completion: 5.0 }, // Base pattern (using 128k pricing)
'kimi-latest': { prompt: 0.2, completion: 2.0 }, // Uses 8k/32k/128k pricing dynamically
'kimi-k2': { prompt: 0.6, completion: 2.5 },
'kimi-k2.5': { prompt: 0.6, completion: 3.0 },
'kimi-k2-turbo': { prompt: 1.15, completion: 8.0 },
'kimi-k2-turbo-preview': { prompt: 1.15, completion: 8.0 },
'kimi-k2-0905': { prompt: 0.6, completion: 2.5 },
'kimi-k2-0905-preview': { prompt: 0.6, completion: 2.5 },
'kimi-k2-0711': { prompt: 0.6, completion: 2.5 },
'kimi-k2-0711-preview': { prompt: 0.6, completion: 2.5 },
'kimi-k2-thinking': { prompt: 0.6, completion: 2.5 },
'kimi-k2-thinking-turbo': { prompt: 1.15, completion: 8.0 },
'moonshot-v1': { prompt: 2.0, completion: 5.0 },
'moonshot-v1-auto': { prompt: 2.0, completion: 5.0 },
'moonshot-v1-8k': { prompt: 0.2, completion: 2.0 },
'moonshot-v1-8k-vision': { prompt: 0.2, completion: 2.0 },
'moonshot-v1-8k-vision-preview': { prompt: 0.2, completion: 2.0 },
'moonshot-v1-32k': { prompt: 1.0, completion: 3.0 },
'moonshot-v1-32k-vision': { prompt: 1.0, completion: 3.0 },
'moonshot-v1-32k-vision-preview': { prompt: 1.0, completion: 3.0 },
'moonshot-v1-128k': { prompt: 2.0, completion: 5.0 },
'moonshot-v1-128k-vision': { prompt: 2.0, completion: 5.0 },
'moonshot-v1-128k-vision-preview': { prompt: 2.0, completion: 5.0 },
// GPT-OSS models (specific sizes)
'gpt-oss:20b': { prompt: 0.05, completion: 0.2 },
'gpt-oss-20b': { prompt: 0.05, completion: 0.2 },
@ -249,12 +304,64 @@ const cacheTokenValues = {
'claude-3-haiku': { write: 0.3, read: 0.03 },
'claude-haiku-4-5': { write: 1.25, read: 0.1 },
'claude-sonnet-4': { write: 3.75, read: 0.3 },
'claude-sonnet-4-6': { write: 3.75, read: 0.3 },
'claude-opus-4': { write: 18.75, read: 1.5 },
'claude-opus-4-5': { write: 6.25, read: 0.5 },
'claude-opus-4-6': { write: 6.25, read: 0.5 },
// OpenAI models — cached input discount varies by family:
// gpt-4o (incl. mini), o1 (incl. mini/preview): 50% off
// gpt-4.1 (incl. mini/nano), o3 (incl. mini), o4-mini: 75% off
// gpt-5.x (excl. pro variants): 90% off
// gpt-5-pro, gpt-5.2-pro, gpt-5.4-pro: no caching
'gpt-4o': { write: 2.5, read: 1.25 },
'gpt-4o-mini': { write: 0.15, read: 0.075 },
'gpt-4.1': { write: 2, read: 0.5 },
'gpt-4.1-mini': { write: 0.4, read: 0.1 },
'gpt-4.1-nano': { write: 0.1, read: 0.025 },
'gpt-5': { write: 1.25, read: 0.125 },
'gpt-5.1': { write: 1.25, read: 0.125 },
'gpt-5.2': { write: 1.75, read: 0.175 },
'gpt-5.3': { write: 1.75, read: 0.175 },
'gpt-5.4': { write: 2.5, read: 0.25 },
'gpt-5-mini': { write: 0.25, read: 0.025 },
'gpt-5-nano': { write: 0.05, read: 0.005 },
o1: { write: 15, read: 7.5 },
'o1-mini': { write: 1.1, read: 0.55 },
'o1-preview': { write: 15, read: 7.5 },
o3: { write: 2, read: 0.5 },
'o3-mini': { write: 1.1, read: 0.275 },
'o4-mini': { write: 1.1, read: 0.275 },
// DeepSeek models - cache hit: $0.028/1M, cache miss: $0.28/1M
deepseek: { write: 0.28, read: 0.028 },
'deepseek-chat': { write: 0.28, read: 0.028 },
'deepseek-reasoner': { write: 0.28, read: 0.028 },
// Moonshot/Kimi models - cache hit: $0.15/1M (k2) or $0.10/1M (k2.5), cache miss: $0.60/1M
kimi: { write: 0.6, read: 0.15 },
'kimi-k2': { write: 0.6, read: 0.15 },
'kimi-k2.5': { write: 0.6, read: 0.1 },
'kimi-k2-turbo': { write: 1.15, read: 0.15 },
'kimi-k2-turbo-preview': { write: 1.15, read: 0.15 },
'kimi-k2-0905': { write: 0.6, read: 0.15 },
'kimi-k2-0905-preview': { write: 0.6, read: 0.15 },
'kimi-k2-0711': { write: 0.6, read: 0.15 },
'kimi-k2-0711-preview': { write: 0.6, read: 0.15 },
'kimi-k2-thinking': { write: 0.6, read: 0.15 },
'kimi-k2-thinking-turbo': { write: 1.15, read: 0.15 },
// Gemini 3.1 Pro - cache write: $2.00/1M, cache read: $0.20/1M
'gemini-3.1': { write: 2, read: 0.2 },
// Gemini 3.1 Flash-Lite - cache write: $0.25/1M, cache read: $0.025/1M
'gemini-3.1-flash-lite': { write: 0.25, read: 0.025 },
};
/**
* Premium (tiered) pricing for models whose rates change based on prompt size.
* Each entry specifies the token threshold and the rates that apply above it.
* @type {Object.<string, {threshold: number, prompt: number, completion: number}>}
*/
const premiumTokenValues = {
'claude-opus-4-6': { threshold: 200000, prompt: 10, completion: 37.5 },
'claude-sonnet-4-6': { threshold: 200000, prompt: 6, completion: 22.5 },
'gemini-3.1': { threshold: 200000, prompt: 4, completion: 18 },
};
/**
@ -313,15 +420,27 @@ const getValueKey = (model, endpoint) => {
* @param {string} [params.model] - The model name to derive the value key from if not provided.
* @param {string} [params.endpoint] - The endpoint name to derive the value key from if not provided.
* @param {EndpointTokenConfig} [params.endpointTokenConfig] - The token configuration for the endpoint.
* @param {number} [params.inputTokenCount] - Total input token count for tiered pricing.
* @returns {number} The multiplier for the given parameters, or a default value if not found.
*/
const getMultiplier = ({ valueKey, tokenType, model, endpoint, endpointTokenConfig }) => {
const getMultiplier = ({
model,
valueKey,
endpoint,
tokenType,
inputTokenCount,
endpointTokenConfig,
}) => {
if (endpointTokenConfig) {
return endpointTokenConfig?.[model]?.[tokenType] ?? defaultRate;
}
if (valueKey && tokenType) {
return tokenValues[valueKey][tokenType] ?? defaultRate;
const premiumRate = getPremiumRate(valueKey, tokenType, inputTokenCount);
if (premiumRate != null) {
return premiumRate;
}
return tokenValues[valueKey]?.[tokenType] ?? defaultRate;
}
if (!tokenType || !model) {
@ -333,10 +452,33 @@ const getMultiplier = ({ valueKey, tokenType, model, endpoint, endpointTokenConf
return defaultRate;
}
// If we got this far, and values[tokenType] is undefined somehow, return a rough average of default multipliers
const premiumRate = getPremiumRate(valueKey, tokenType, inputTokenCount);
if (premiumRate != null) {
return premiumRate;
}
return tokenValues[valueKey]?.[tokenType] ?? defaultRate;
};
/**
* Checks if premium (tiered) pricing applies and returns the premium rate.
* Each model defines its own threshold in `premiumTokenValues`.
* @param {string} valueKey
* @param {string} tokenType
* @param {number} [inputTokenCount]
* @returns {number|null}
*/
const getPremiumRate = (valueKey, tokenType, inputTokenCount) => {
if (inputTokenCount == null) {
return null;
}
const premiumEntry = premiumTokenValues[valueKey];
if (!premiumEntry || inputTokenCount <= premiumEntry.threshold) {
return null;
}
return premiumEntry[tokenType] ?? null;
};
/**
* Retrieves the cache multiplier for a given value key and token type. If no value key is provided,
* it attempts to derive it from the model name.
@ -373,8 +515,10 @@ const getCacheMultiplier = ({ valueKey, cacheType, model, endpoint, endpointToke
module.exports = {
tokenValues,
premiumTokenValues,
getValueKey,
getMultiplier,
getPremiumRate,
getCacheMultiplier,
defaultRate,
cacheTokenValues,

View file

@ -1,3 +1,4 @@
/** Note: No hard-coded values should be used in this file. */
const { maxTokensMap } = require('@librechat/api');
const { EModelEndpoint } = require('librechat-data-provider');
const {
@ -5,8 +6,10 @@ const {
tokenValues,
getValueKey,
getMultiplier,
getPremiumRate,
cacheTokenValues,
getCacheMultiplier,
premiumTokenValues,
} = require('./tx');
describe('getValueKey', () => {
@ -49,6 +52,24 @@ describe('getValueKey', () => {
expect(getValueKey('openai/gpt-5.2')).toBe('gpt-5.2');
});
it('should return "gpt-5.3" for model name containing "gpt-5.3"', () => {
expect(getValueKey('gpt-5.3')).toBe('gpt-5.3');
expect(getValueKey('gpt-5.3-chat-latest')).toBe('gpt-5.3');
expect(getValueKey('gpt-5.3-codex')).toBe('gpt-5.3');
expect(getValueKey('openai/gpt-5.3')).toBe('gpt-5.3');
});
it('should return "gpt-5.4" for model name containing "gpt-5.4"', () => {
expect(getValueKey('gpt-5.4')).toBe('gpt-5.4');
expect(getValueKey('gpt-5.4-thinking')).toBe('gpt-5.4');
expect(getValueKey('openai/gpt-5.4')).toBe('gpt-5.4');
});
it('should return "gpt-5.4-pro" for model name containing "gpt-5.4-pro"', () => {
expect(getValueKey('gpt-5.4-pro')).toBe('gpt-5.4-pro');
expect(getValueKey('openai/gpt-5.4-pro')).toBe('gpt-5.4-pro');
});
it('should return "gpt-3.5-turbo-1106" for model name containing "gpt-3.5-turbo-1106"', () => {
expect(getValueKey('gpt-3.5-turbo-1106-some-other-info')).toBe('gpt-3.5-turbo-1106');
expect(getValueKey('openai/gpt-3.5-turbo-1106')).toBe('gpt-3.5-turbo-1106');
@ -135,6 +156,12 @@ describe('getValueKey', () => {
expect(getValueKey('gpt-5-pro-preview')).toBe('gpt-5-pro');
});
it('should return "gpt-5.2-pro" for model name containing "gpt-5.2-pro"', () => {
expect(getValueKey('gpt-5.2-pro')).toBe('gpt-5.2-pro');
expect(getValueKey('gpt-5.2-pro-2025-03-01')).toBe('gpt-5.2-pro');
expect(getValueKey('openai/gpt-5.2-pro')).toBe('gpt-5.2-pro');
});
it('should return "gpt-4o" for model type of "gpt-4o"', () => {
expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o');
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o');
@ -239,6 +266,15 @@ describe('getMultiplier', () => {
expect(getMultiplier({ valueKey: '8k', tokenType: 'unknownType' })).toBe(defaultRate);
});
it('should return defaultRate if valueKey does not exist in tokenValues', () => {
expect(getMultiplier({ valueKey: 'non-existent-model', tokenType: 'prompt' })).toBe(
defaultRate,
);
expect(getMultiplier({ valueKey: 'non-existent-model', tokenType: 'completion' })).toBe(
defaultRate,
);
});
it('should derive the valueKey from the model if not provided', () => {
expect(getMultiplier({ tokenType: 'prompt', model: 'gpt-4-some-other-info' })).toBe(
tokenValues['8k'].prompt,
@ -324,6 +360,18 @@ describe('getMultiplier', () => {
);
});
it('should return the correct multiplier for gpt-5.2-pro', () => {
expect(getMultiplier({ model: 'gpt-5.2-pro', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5.2-pro'].prompt,
);
expect(getMultiplier({ model: 'gpt-5.2-pro', tokenType: 'completion' })).toBe(
tokenValues['gpt-5.2-pro'].completion,
);
expect(getMultiplier({ model: 'openai/gpt-5.2-pro', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5.2-pro'].prompt,
);
});
it('should return the correct multiplier for gpt-5.1', () => {
expect(getMultiplier({ model: 'gpt-5.1', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5.1'].prompt,
@ -334,8 +382,6 @@ describe('getMultiplier', () => {
expect(getMultiplier({ model: 'openai/gpt-5.1', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5.1'].prompt,
);
expect(tokenValues['gpt-5.1'].prompt).toBe(1.25);
expect(tokenValues['gpt-5.1'].completion).toBe(10);
});
it('should return the correct multiplier for gpt-5.2', () => {
@ -348,8 +394,48 @@ describe('getMultiplier', () => {
expect(getMultiplier({ model: 'openai/gpt-5.2', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5.2'].prompt,
);
expect(tokenValues['gpt-5.2'].prompt).toBe(1.75);
expect(tokenValues['gpt-5.2'].completion).toBe(14);
});
it('should return the correct multiplier for gpt-5.3', () => {
expect(getMultiplier({ model: 'gpt-5.3', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5.3'].prompt,
);
expect(getMultiplier({ model: 'gpt-5.3', tokenType: 'completion' })).toBe(
tokenValues['gpt-5.3'].completion,
);
expect(getMultiplier({ model: 'gpt-5.3-codex', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5.3'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-5.3', tokenType: 'completion' })).toBe(
tokenValues['gpt-5.3'].completion,
);
});
it('should return the correct multiplier for gpt-5.4', () => {
expect(getMultiplier({ model: 'gpt-5.4', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5.4'].prompt,
);
expect(getMultiplier({ model: 'gpt-5.4', tokenType: 'completion' })).toBe(
tokenValues['gpt-5.4'].completion,
);
expect(getMultiplier({ model: 'gpt-5.4-thinking', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5.4'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-5.4', tokenType: 'completion' })).toBe(
tokenValues['gpt-5.4'].completion,
);
});
it('should return the correct multiplier for gpt-5.4-pro', () => {
expect(getMultiplier({ model: 'gpt-5.4-pro', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5.4-pro'].prompt,
);
expect(getMultiplier({ model: 'gpt-5.4-pro', tokenType: 'completion' })).toBe(
tokenValues['gpt-5.4-pro'].completion,
);
expect(getMultiplier({ model: 'openai/gpt-5.4-pro', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5.4-pro'].prompt,
);
});
it('should return the correct multiplier for gpt-4o', () => {
@ -815,8 +901,6 @@ describe('Deepseek Model Tests', () => {
expect(getMultiplier({ model: 'deepseek-chat', tokenType: 'completion' })).toBe(
tokenValues['deepseek-chat'].completion,
);
expect(tokenValues['deepseek-chat'].prompt).toBe(0.28);
expect(tokenValues['deepseek-chat'].completion).toBe(0.42);
});
it('should return correct pricing for deepseek-reasoner', () => {
@ -826,8 +910,6 @@ describe('Deepseek Model Tests', () => {
expect(getMultiplier({ model: 'deepseek-reasoner', tokenType: 'completion' })).toBe(
tokenValues['deepseek-reasoner'].completion,
);
expect(tokenValues['deepseek-reasoner'].prompt).toBe(0.28);
expect(tokenValues['deepseek-reasoner'].completion).toBe(0.42);
});
it('should handle DeepSeek model name variations with provider prefixes', () => {
@ -840,8 +922,8 @@ describe('Deepseek Model Tests', () => {
modelVariations.forEach((model) => {
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
expect(promptMultiplier).toBe(0.28);
expect(completionMultiplier).toBe(0.42);
expect(promptMultiplier).toBe(tokenValues['deepseek-chat'].prompt);
expect(completionMultiplier).toBe(tokenValues['deepseek-chat'].completion);
});
});
@ -860,13 +942,13 @@ describe('Deepseek Model Tests', () => {
);
});
it('should return correct cache pricing values for DeepSeek models', () => {
expect(cacheTokenValues['deepseek-chat'].write).toBe(0.28);
expect(cacheTokenValues['deepseek-chat'].read).toBe(0.028);
expect(cacheTokenValues['deepseek-reasoner'].write).toBe(0.28);
expect(cacheTokenValues['deepseek-reasoner'].read).toBe(0.028);
expect(cacheTokenValues['deepseek'].write).toBe(0.28);
expect(cacheTokenValues['deepseek'].read).toBe(0.028);
it('should have consistent cache pricing across DeepSeek model variants', () => {
expect(cacheTokenValues['deepseek'].write).toBe(cacheTokenValues['deepseek-chat'].write);
expect(cacheTokenValues['deepseek'].read).toBe(cacheTokenValues['deepseek-chat'].read);
expect(cacheTokenValues['deepseek-reasoner'].write).toBe(
cacheTokenValues['deepseek-chat'].write,
);
expect(cacheTokenValues['deepseek-reasoner'].read).toBe(cacheTokenValues['deepseek-chat'].read);
});
it('should handle DeepSeek cache multipliers with model variations', () => {
@ -875,8 +957,195 @@ describe('Deepseek Model Tests', () => {
modelVariations.forEach((model) => {
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
expect(writeMultiplier).toBe(0.28);
expect(readMultiplier).toBe(0.028);
expect(writeMultiplier).toBe(cacheTokenValues['deepseek-chat'].write);
expect(readMultiplier).toBe(cacheTokenValues['deepseek-chat'].read);
});
});
});
describe('Moonshot/Kimi Model Tests - Pricing', () => {
describe('Kimi Models', () => {
it('should return correct pricing for kimi base pattern', () => {
expect(getMultiplier({ model: 'kimi', tokenType: 'prompt' })).toBe(
tokenValues['kimi'].prompt,
);
expect(getMultiplier({ model: 'kimi', tokenType: 'completion' })).toBe(
tokenValues['kimi'].completion,
);
});
it('should return correct pricing for kimi-k2.5', () => {
expect(getMultiplier({ model: 'kimi-k2.5', tokenType: 'prompt' })).toBe(
tokenValues['kimi-k2.5'].prompt,
);
expect(getMultiplier({ model: 'kimi-k2.5', tokenType: 'completion' })).toBe(
tokenValues['kimi-k2.5'].completion,
);
});
it('should return correct pricing for kimi-k2 series', () => {
expect(getMultiplier({ model: 'kimi-k2', tokenType: 'prompt' })).toBe(
tokenValues['kimi-k2'].prompt,
);
expect(getMultiplier({ model: 'kimi-k2', tokenType: 'completion' })).toBe(
tokenValues['kimi-k2'].completion,
);
});
it('should return correct pricing for kimi-k2-turbo (higher pricing)', () => {
expect(getMultiplier({ model: 'kimi-k2-turbo', tokenType: 'prompt' })).toBe(
tokenValues['kimi-k2-turbo'].prompt,
);
expect(getMultiplier({ model: 'kimi-k2-turbo', tokenType: 'completion' })).toBe(
tokenValues['kimi-k2-turbo'].completion,
);
});
it('should return correct pricing for kimi-k2-thinking models', () => {
expect(getMultiplier({ model: 'kimi-k2-thinking', tokenType: 'prompt' })).toBe(
tokenValues['kimi-k2-thinking'].prompt,
);
expect(getMultiplier({ model: 'kimi-k2-thinking', tokenType: 'completion' })).toBe(
tokenValues['kimi-k2-thinking'].completion,
);
expect(getMultiplier({ model: 'kimi-k2-thinking-turbo', tokenType: 'prompt' })).toBe(
tokenValues['kimi-k2-thinking-turbo'].prompt,
);
expect(getMultiplier({ model: 'kimi-k2-thinking-turbo', tokenType: 'completion' })).toBe(
tokenValues['kimi-k2-thinking-turbo'].completion,
);
});
it('should handle Kimi model variations with provider prefixes', () => {
const modelVariations = ['openrouter/kimi-k2', 'openrouter/kimi-k2.5', 'openrouter/kimi'];
modelVariations.forEach((model) => {
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
expect(promptMultiplier).toBe(tokenValues['kimi'].prompt);
expect([tokenValues['kimi'].completion, tokenValues['kimi-k2.5'].completion]).toContain(
completionMultiplier,
);
});
});
});
describe('Moonshot Models', () => {
it('should return correct pricing for moonshot base pattern (128k pricing)', () => {
expect(getMultiplier({ model: 'moonshot', tokenType: 'prompt' })).toBe(
tokenValues['moonshot'].prompt,
);
expect(getMultiplier({ model: 'moonshot', tokenType: 'completion' })).toBe(
tokenValues['moonshot'].completion,
);
});
it('should return correct pricing for moonshot-v1-8k', () => {
expect(getMultiplier({ model: 'moonshot-v1-8k', tokenType: 'prompt' })).toBe(
tokenValues['moonshot-v1-8k'].prompt,
);
expect(getMultiplier({ model: 'moonshot-v1-8k', tokenType: 'completion' })).toBe(
tokenValues['moonshot-v1-8k'].completion,
);
});
it('should return correct pricing for moonshot-v1-32k', () => {
expect(getMultiplier({ model: 'moonshot-v1-32k', tokenType: 'prompt' })).toBe(
tokenValues['moonshot-v1-32k'].prompt,
);
expect(getMultiplier({ model: 'moonshot-v1-32k', tokenType: 'completion' })).toBe(
tokenValues['moonshot-v1-32k'].completion,
);
});
it('should return correct pricing for moonshot-v1-128k', () => {
expect(getMultiplier({ model: 'moonshot-v1-128k', tokenType: 'prompt' })).toBe(
tokenValues['moonshot-v1-128k'].prompt,
);
expect(getMultiplier({ model: 'moonshot-v1-128k', tokenType: 'completion' })).toBe(
tokenValues['moonshot-v1-128k'].completion,
);
});
it('should return correct pricing for moonshot-v1 vision models', () => {
expect(getMultiplier({ model: 'moonshot-v1-8k-vision', tokenType: 'prompt' })).toBe(
tokenValues['moonshot-v1-8k-vision'].prompt,
);
expect(getMultiplier({ model: 'moonshot-v1-8k-vision', tokenType: 'completion' })).toBe(
tokenValues['moonshot-v1-8k-vision'].completion,
);
expect(getMultiplier({ model: 'moonshot-v1-32k-vision', tokenType: 'prompt' })).toBe(
tokenValues['moonshot-v1-32k-vision'].prompt,
);
expect(getMultiplier({ model: 'moonshot-v1-32k-vision', tokenType: 'completion' })).toBe(
tokenValues['moonshot-v1-32k-vision'].completion,
);
expect(getMultiplier({ model: 'moonshot-v1-128k-vision', tokenType: 'prompt' })).toBe(
tokenValues['moonshot-v1-128k-vision'].prompt,
);
expect(getMultiplier({ model: 'moonshot-v1-128k-vision', tokenType: 'completion' })).toBe(
tokenValues['moonshot-v1-128k-vision'].completion,
);
});
});
describe('Kimi Cache Multipliers', () => {
it('should return correct cache multipliers for kimi-k2 models', () => {
expect(getCacheMultiplier({ model: 'kimi', cacheType: 'write' })).toBe(
cacheTokenValues['kimi'].write,
);
expect(getCacheMultiplier({ model: 'kimi', cacheType: 'read' })).toBe(
cacheTokenValues['kimi'].read,
);
});
it('should return correct cache multipliers for kimi-k2.5 (lower read price)', () => {
expect(getCacheMultiplier({ model: 'kimi-k2.5', cacheType: 'write' })).toBe(
cacheTokenValues['kimi-k2.5'].write,
);
expect(getCacheMultiplier({ model: 'kimi-k2.5', cacheType: 'read' })).toBe(
cacheTokenValues['kimi-k2.5'].read,
);
});
it('should return correct cache multipliers for kimi-k2-turbo', () => {
expect(getCacheMultiplier({ model: 'kimi-k2-turbo', cacheType: 'write' })).toBe(
cacheTokenValues['kimi-k2-turbo'].write,
);
expect(getCacheMultiplier({ model: 'kimi-k2-turbo', cacheType: 'read' })).toBe(
cacheTokenValues['kimi-k2-turbo'].read,
);
});
it('should handle Kimi cache multipliers with model variations', () => {
const modelVariations = ['openrouter/kimi-k2', 'openrouter/kimi'];
modelVariations.forEach((model) => {
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
expect(writeMultiplier).toBe(cacheTokenValues['kimi'].write);
expect(readMultiplier).toBe(cacheTokenValues['kimi'].read);
});
});
});
describe('Bedrock Moonshot Models', () => {
it('should return correct pricing for Bedrock moonshot models', () => {
expect(getMultiplier({ model: 'moonshot.kimi', tokenType: 'prompt' })).toBe(
tokenValues['moonshot.kimi'].prompt,
);
expect(getMultiplier({ model: 'moonshot.kimi', tokenType: 'completion' })).toBe(
tokenValues['moonshot.kimi'].completion,
);
expect(getMultiplier({ model: 'moonshot.kimi-k2', tokenType: 'prompt' })).toBe(
tokenValues['moonshot.kimi-k2'].prompt,
);
expect(getMultiplier({ model: 'moonshot.kimi-k2.5', tokenType: 'prompt' })).toBe(
tokenValues['moonshot.kimi-k2.5'].prompt,
);
expect(getMultiplier({ model: 'moonshot.kimi-k2.5', tokenType: 'completion' })).toBe(
tokenValues['moonshot.kimi-k2.5'].completion,
);
});
});
});
@ -1135,6 +1404,73 @@ describe('getCacheMultiplier', () => {
).toBeNull();
});
it('should return correct cache multipliers for OpenAI models', () => {
const openaiCacheModels = [
'gpt-4o',
'gpt-4o-mini',
'gpt-4.1',
'gpt-4.1-mini',
'gpt-4.1-nano',
'gpt-5',
'gpt-5.1',
'gpt-5.2',
'gpt-5.3',
'gpt-5.4',
'gpt-5-mini',
'gpt-5-nano',
'o1',
'o1-mini',
'o1-preview',
'o3',
'o3-mini',
'o4-mini',
];
for (const model of openaiCacheModels) {
expect(getCacheMultiplier({ model, cacheType: 'write' })).toBe(cacheTokenValues[model].write);
expect(getCacheMultiplier({ model, cacheType: 'read' })).toBe(cacheTokenValues[model].read);
}
});
it('should return correct cache multipliers for OpenAI dated variants', () => {
expect(getCacheMultiplier({ model: 'gpt-4o-2024-08-06', cacheType: 'read' })).toBe(
cacheTokenValues['gpt-4o'].read,
);
expect(getCacheMultiplier({ model: 'gpt-4.1-2026-01-01', cacheType: 'read' })).toBe(
cacheTokenValues['gpt-4.1'].read,
);
expect(getCacheMultiplier({ model: 'gpt-5.3-codex', cacheType: 'read' })).toBe(
cacheTokenValues['gpt-5.3'].read,
);
expect(getCacheMultiplier({ model: 'openai/gpt-5.3', cacheType: 'write' })).toBe(
cacheTokenValues['gpt-5.3'].write,
);
});
it('should return null for pro models that do not support caching', () => {
expect(getCacheMultiplier({ model: 'gpt-5-pro', cacheType: 'read' })).toBeNull();
expect(getCacheMultiplier({ model: 'gpt-5-pro', cacheType: 'write' })).toBeNull();
expect(getCacheMultiplier({ model: 'gpt-5.2-pro', cacheType: 'read' })).toBeNull();
expect(getCacheMultiplier({ model: 'gpt-5.2-pro', cacheType: 'write' })).toBeNull();
expect(getCacheMultiplier({ model: 'gpt-5.4-pro', cacheType: 'read' })).toBeNull();
expect(getCacheMultiplier({ model: 'gpt-5.4-pro', cacheType: 'write' })).toBeNull();
});
it('should have consistent 10% cache read pricing for gpt-5.x models', () => {
const gpt5CacheModels = [
'gpt-5',
'gpt-5.1',
'gpt-5.2',
'gpt-5.3',
'gpt-5.4',
'gpt-5-mini',
'gpt-5-nano',
];
for (const model of gpt5CacheModels) {
expect(cacheTokenValues[model].read).toBeCloseTo(cacheTokenValues[model].write * 0.1, 10);
}
});
it('should handle models with "bedrock/" prefix', () => {
expect(
getCacheMultiplier({
@ -1154,6 +1490,9 @@ describe('getCacheMultiplier', () => {
describe('Google Model Tests', () => {
const googleModels = [
'gemini-3',
'gemini-3.1-pro-preview',
'gemini-3.1-pro-preview-customtools',
'gemini-3.1-flash-lite-preview',
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
@ -1198,6 +1537,9 @@ describe('Google Model Tests', () => {
it('should map to the correct model keys', () => {
const expected = {
'gemini-3': 'gemini-3',
'gemini-3.1-pro-preview': 'gemini-3.1',
'gemini-3.1-pro-preview-customtools': 'gemini-3.1',
'gemini-3.1-flash-lite-preview': 'gemini-3.1-flash-lite',
'gemini-2.5-pro': 'gemini-2.5-pro',
'gemini-2.5-flash': 'gemini-2.5-flash',
'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite',
@ -1241,6 +1583,190 @@ describe('Google Model Tests', () => {
).toBe(tokenValues[expected].completion);
});
});
it('should return correct prompt and completion rates for Gemini 3.1', () => {
expect(
getMultiplier({
model: 'gemini-3.1-pro-preview',
tokenType: 'prompt',
endpoint: EModelEndpoint.google,
}),
).toBe(tokenValues['gemini-3.1'].prompt);
expect(
getMultiplier({
model: 'gemini-3.1-pro-preview',
tokenType: 'completion',
endpoint: EModelEndpoint.google,
}),
).toBe(tokenValues['gemini-3.1'].completion);
expect(
getMultiplier({
model: 'gemini-3.1-pro-preview-customtools',
tokenType: 'prompt',
endpoint: EModelEndpoint.google,
}),
).toBe(tokenValues['gemini-3.1'].prompt);
expect(
getMultiplier({
model: 'gemini-3.1-pro-preview-customtools',
tokenType: 'completion',
endpoint: EModelEndpoint.google,
}),
).toBe(tokenValues['gemini-3.1'].completion);
});
it('should return correct cache rates for Gemini 3.1', () => {
['gemini-3.1-pro-preview', 'gemini-3.1-pro-preview-customtools'].forEach((model) => {
expect(getCacheMultiplier({ model, cacheType: 'write' })).toBe(
cacheTokenValues['gemini-3.1'].write,
);
expect(getCacheMultiplier({ model, cacheType: 'read' })).toBe(
cacheTokenValues['gemini-3.1'].read,
);
});
});
it('should return correct rates for Gemini 3.1 Flash-Lite', () => {
const model = 'gemini-3.1-flash-lite-preview';
expect(getMultiplier({ model, tokenType: 'prompt', endpoint: EModelEndpoint.google })).toBe(
tokenValues['gemini-3.1-flash-lite'].prompt,
);
expect(getMultiplier({ model, tokenType: 'completion', endpoint: EModelEndpoint.google })).toBe(
tokenValues['gemini-3.1-flash-lite'].completion,
);
expect(getCacheMultiplier({ model, cacheType: 'write' })).toBe(
cacheTokenValues['gemini-3.1-flash-lite'].write,
);
expect(getCacheMultiplier({ model, cacheType: 'read' })).toBe(
cacheTokenValues['gemini-3.1-flash-lite'].read,
);
});
});
describe('Gemini 3.1 Premium Token Pricing', () => {
const premiumKey = 'gemini-3.1';
const premiumEntry = premiumTokenValues[premiumKey];
const { threshold } = premiumEntry;
const belowThreshold = threshold - 1;
const aboveThreshold = threshold + 1;
const wellAboveThreshold = threshold * 2;
it('should have premium pricing defined for gemini-3.1', () => {
expect(premiumEntry).toBeDefined();
expect(premiumEntry.threshold).toBeDefined();
expect(premiumEntry.prompt).toBeDefined();
expect(premiumEntry.completion).toBeDefined();
expect(premiumEntry.prompt).toBeGreaterThan(tokenValues[premiumKey].prompt);
expect(premiumEntry.completion).toBeGreaterThan(tokenValues[premiumKey].completion);
});
it('should return null from getPremiumRate when inputTokenCount is below or at threshold', () => {
expect(getPremiumRate(premiumKey, 'prompt', belowThreshold)).toBeNull();
expect(getPremiumRate(premiumKey, 'completion', belowThreshold)).toBeNull();
expect(getPremiumRate(premiumKey, 'prompt', threshold)).toBeNull();
});
it('should return premium rate from getPremiumRate when inputTokenCount exceeds threshold', () => {
expect(getPremiumRate(premiumKey, 'prompt', aboveThreshold)).toBe(premiumEntry.prompt);
expect(getPremiumRate(premiumKey, 'completion', aboveThreshold)).toBe(premiumEntry.completion);
expect(getPremiumRate(premiumKey, 'prompt', wellAboveThreshold)).toBe(premiumEntry.prompt);
});
it('should return null from getPremiumRate when inputTokenCount is undefined or null', () => {
expect(getPremiumRate(premiumKey, 'prompt', undefined)).toBeNull();
expect(getPremiumRate(premiumKey, 'prompt', null)).toBeNull();
});
it('should return standard rate from getMultiplier when inputTokenCount is below threshold', () => {
expect(
getMultiplier({
model: 'gemini-3.1-pro-preview',
tokenType: 'prompt',
inputTokenCount: belowThreshold,
}),
).toBe(tokenValues[premiumKey].prompt);
expect(
getMultiplier({
model: 'gemini-3.1-pro-preview',
tokenType: 'completion',
inputTokenCount: belowThreshold,
}),
).toBe(tokenValues[premiumKey].completion);
});
it('should return premium rate from getMultiplier when inputTokenCount exceeds threshold', () => {
expect(
getMultiplier({
model: 'gemini-3.1-pro-preview',
tokenType: 'prompt',
inputTokenCount: aboveThreshold,
}),
).toBe(premiumEntry.prompt);
expect(
getMultiplier({
model: 'gemini-3.1-pro-preview',
tokenType: 'completion',
inputTokenCount: aboveThreshold,
}),
).toBe(premiumEntry.completion);
});
it('should return standard rate from getMultiplier when inputTokenCount is exactly at threshold', () => {
expect(
getMultiplier({
model: 'gemini-3.1-pro-preview',
tokenType: 'prompt',
inputTokenCount: threshold,
}),
).toBe(tokenValues[premiumKey].prompt);
});
it('should apply premium pricing to customtools variant above threshold', () => {
expect(
getMultiplier({
model: 'gemini-3.1-pro-preview-customtools',
tokenType: 'prompt',
inputTokenCount: aboveThreshold,
}),
).toBe(premiumEntry.prompt);
expect(
getMultiplier({
model: 'gemini-3.1-pro-preview-customtools',
tokenType: 'completion',
inputTokenCount: aboveThreshold,
}),
).toBe(premiumEntry.completion);
});
it('should use standard rate when inputTokenCount is not provided', () => {
expect(getMultiplier({ model: 'gemini-3.1-pro-preview', tokenType: 'prompt' })).toBe(
tokenValues[premiumKey].prompt,
);
expect(getMultiplier({ model: 'gemini-3.1-pro-preview', tokenType: 'completion' })).toBe(
tokenValues[premiumKey].completion,
);
});
it('should apply premium pricing through getMultiplier with valueKey path', () => {
const valueKey = getValueKey('gemini-3.1-pro-preview');
expect(valueKey).toBe(premiumKey);
expect(getMultiplier({ valueKey, tokenType: 'prompt', inputTokenCount: aboveThreshold })).toBe(
premiumEntry.prompt,
);
expect(
getMultiplier({ valueKey, tokenType: 'completion', inputTokenCount: aboveThreshold }),
).toBe(premiumEntry.completion);
});
it('should apply standard pricing through getMultiplier with valueKey path when below threshold', () => {
const valueKey = getValueKey('gemini-3.1-pro-preview');
expect(getMultiplier({ valueKey, tokenType: 'prompt', inputTokenCount: belowThreshold })).toBe(
tokenValues[premiumKey].prompt,
);
expect(
getMultiplier({ valueKey, tokenType: 'completion', inputTokenCount: belowThreshold }),
).toBe(tokenValues[premiumKey].completion);
});
});
describe('Grok Model Tests - Pricing', () => {
@ -1689,6 +2215,201 @@ describe('Claude Model Tests', () => {
);
});
});
it('should return correct prompt and completion rates for Claude Opus 4.6', () => {
expect(getMultiplier({ model: 'claude-opus-4-6', tokenType: 'prompt' })).toBe(
tokenValues['claude-opus-4-6'].prompt,
);
expect(getMultiplier({ model: 'claude-opus-4-6', tokenType: 'completion' })).toBe(
tokenValues['claude-opus-4-6'].completion,
);
});
it('should handle Claude Opus 4.6 model name variations', () => {
const modelVariations = [
'claude-opus-4-6',
'claude-opus-4-6-20250801',
'claude-opus-4-6-latest',
'anthropic/claude-opus-4-6',
'claude-opus-4-6/anthropic',
'claude-opus-4-6-preview',
];
modelVariations.forEach((model) => {
const valueKey = getValueKey(model);
expect(valueKey).toBe('claude-opus-4-6');
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
tokenValues['claude-opus-4-6'].prompt,
);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
tokenValues['claude-opus-4-6'].completion,
);
});
});
it('should return correct cache rates for Claude Opus 4.6', () => {
expect(getCacheMultiplier({ model: 'claude-opus-4-6', cacheType: 'write' })).toBe(
cacheTokenValues['claude-opus-4-6'].write,
);
expect(getCacheMultiplier({ model: 'claude-opus-4-6', cacheType: 'read' })).toBe(
cacheTokenValues['claude-opus-4-6'].read,
);
});
it('should handle Claude Opus 4.6 cache rates with model name variations', () => {
const modelVariations = [
'claude-opus-4-6',
'claude-opus-4-6-20250801',
'claude-opus-4-6-latest',
'anthropic/claude-opus-4-6',
'claude-opus-4-6/anthropic',
'claude-opus-4-6-preview',
];
modelVariations.forEach((model) => {
expect(getCacheMultiplier({ model, cacheType: 'write' })).toBe(
cacheTokenValues['claude-opus-4-6'].write,
);
expect(getCacheMultiplier({ model, cacheType: 'read' })).toBe(
cacheTokenValues['claude-opus-4-6'].read,
);
});
});
});
describe('Premium Token Pricing', () => {
const premiumModel = 'claude-opus-4-6';
const premiumEntry = premiumTokenValues[premiumModel];
const { threshold } = premiumEntry;
const belowThreshold = threshold - 1;
const aboveThreshold = threshold + 1;
const wellAboveThreshold = threshold * 2;
it('should have premium pricing defined for claude-opus-4-6', () => {
expect(premiumEntry).toBeDefined();
expect(premiumEntry.threshold).toBeDefined();
expect(premiumEntry.prompt).toBeDefined();
expect(premiumEntry.completion).toBeDefined();
expect(premiumEntry.prompt).toBeGreaterThan(tokenValues[premiumModel].prompt);
expect(premiumEntry.completion).toBeGreaterThan(tokenValues[premiumModel].completion);
});
it('should return null from getPremiumRate when inputTokenCount is below threshold', () => {
expect(getPremiumRate(premiumModel, 'prompt', belowThreshold)).toBeNull();
expect(getPremiumRate(premiumModel, 'completion', belowThreshold)).toBeNull();
expect(getPremiumRate(premiumModel, 'prompt', threshold)).toBeNull();
});
it('should return premium rate from getPremiumRate when inputTokenCount exceeds threshold', () => {
expect(getPremiumRate(premiumModel, 'prompt', aboveThreshold)).toBe(premiumEntry.prompt);
expect(getPremiumRate(premiumModel, 'completion', aboveThreshold)).toBe(
premiumEntry.completion,
);
expect(getPremiumRate(premiumModel, 'prompt', wellAboveThreshold)).toBe(premiumEntry.prompt);
});
it('should return null from getPremiumRate when inputTokenCount is undefined or null', () => {
expect(getPremiumRate(premiumModel, 'prompt', undefined)).toBeNull();
expect(getPremiumRate(premiumModel, 'prompt', null)).toBeNull();
});
it('should return null from getPremiumRate for models without premium pricing', () => {
expect(getPremiumRate('claude-opus-4-5', 'prompt', wellAboveThreshold)).toBeNull();
expect(getPremiumRate('claude-sonnet-4', 'prompt', wellAboveThreshold)).toBeNull();
expect(getPremiumRate('gpt-4o', 'prompt', wellAboveThreshold)).toBeNull();
});
it('should return standard rate from getMultiplier when inputTokenCount is below threshold', () => {
expect(
getMultiplier({
model: premiumModel,
tokenType: 'prompt',
inputTokenCount: belowThreshold,
}),
).toBe(tokenValues[premiumModel].prompt);
expect(
getMultiplier({
model: premiumModel,
tokenType: 'completion',
inputTokenCount: belowThreshold,
}),
).toBe(tokenValues[premiumModel].completion);
});
it('should return premium rate from getMultiplier when inputTokenCount exceeds threshold', () => {
expect(
getMultiplier({
model: premiumModel,
tokenType: 'prompt',
inputTokenCount: aboveThreshold,
}),
).toBe(premiumEntry.prompt);
expect(
getMultiplier({
model: premiumModel,
tokenType: 'completion',
inputTokenCount: aboveThreshold,
}),
).toBe(premiumEntry.completion);
});
it('should return standard rate from getMultiplier when inputTokenCount is exactly at threshold', () => {
expect(
getMultiplier({ model: premiumModel, tokenType: 'prompt', inputTokenCount: threshold }),
).toBe(tokenValues[premiumModel].prompt);
});
it('should return premium rate from getMultiplier when inputTokenCount is one above threshold', () => {
expect(
getMultiplier({ model: premiumModel, tokenType: 'prompt', inputTokenCount: aboveThreshold }),
).toBe(premiumEntry.prompt);
});
it('should not apply premium pricing to models without premium entries', () => {
expect(
getMultiplier({
model: 'claude-opus-4-5',
tokenType: 'prompt',
inputTokenCount: wellAboveThreshold,
}),
).toBe(tokenValues['claude-opus-4-5'].prompt);
expect(
getMultiplier({
model: 'claude-sonnet-4',
tokenType: 'prompt',
inputTokenCount: wellAboveThreshold,
}),
).toBe(tokenValues['claude-sonnet-4'].prompt);
});
it('should use standard rate when inputTokenCount is not provided', () => {
expect(getMultiplier({ model: premiumModel, tokenType: 'prompt' })).toBe(
tokenValues[premiumModel].prompt,
);
expect(getMultiplier({ model: premiumModel, tokenType: 'completion' })).toBe(
tokenValues[premiumModel].completion,
);
});
it('should apply premium pricing through getMultiplier with valueKey path', () => {
const valueKey = getValueKey(premiumModel);
expect(getMultiplier({ valueKey, tokenType: 'prompt', inputTokenCount: aboveThreshold })).toBe(
premiumEntry.prompt,
);
expect(
getMultiplier({ valueKey, tokenType: 'completion', inputTokenCount: aboveThreshold }),
).toBe(premiumEntry.completion);
});
it('should apply standard pricing through getMultiplier with valueKey path when below threshold', () => {
const valueKey = getValueKey(premiumModel);
expect(getMultiplier({ valueKey, tokenType: 'prompt', inputTokenCount: belowThreshold })).toBe(
tokenValues[premiumModel].prompt,
);
expect(
getMultiplier({ valueKey, tokenType: 'completion', inputTokenCount: belowThreshold }),
).toBe(tokenValues[premiumModel].completion);
});
});
describe('tokens.ts and tx.js sync validation', () => {

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.2-rc2",
"version": "v0.8.3",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@ -34,26 +34,25 @@
},
"homepage": "https://librechat.ai",
"dependencies": {
"@anthropic-ai/sdk": "^0.71.0",
"@anthropic-ai/vertex-sdk": "^0.14.0",
"@aws-sdk/client-bedrock-runtime": "^3.941.0",
"@aws-sdk/client-s3": "^3.758.0",
"@anthropic-ai/vertex-sdk": "^0.14.3",
"@aws-sdk/client-bedrock-runtime": "^3.980.0",
"@aws-sdk/client-s3": "^3.980.0",
"@aws-sdk/s3-request-presigner": "^3.758.0",
"@azure/identity": "^4.7.0",
"@azure/search-documents": "^12.0.0",
"@azure/storage-blob": "^12.27.0",
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^1.19.0",
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.0.66",
"@librechat/agents": "^3.1.55",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@modelcontextprotocol/sdk": "^1.25.2",
"@modelcontextprotocol/sdk": "^1.27.1",
"@node-saml/passport-saml": "^5.1.0",
"@smithy/node-http-handler": "^4.4.5",
"axios": "^1.12.1",
"ai-tokenizer": "^1.0.6",
"axios": "^1.13.5",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"connect-redis": "^8.1.0",
@ -65,10 +64,10 @@
"eventsource": "^3.0.2",
"express": "^5.2.1",
"express-mongo-sanitize": "^2.2.0",
"express-rate-limit": "^8.2.1",
"express-rate-limit": "^8.3.0",
"express-session": "^1.18.2",
"express-static-gzip": "^2.2.0",
"file-type": "^18.7.0",
"file-type": "^21.3.2",
"firebase": "^11.0.2",
"form-data": "^4.0.4",
"handlebars": "^4.7.7",
@ -81,14 +80,15 @@
"keyv-file": "^5.1.2",
"klona": "^2.0.6",
"librechat-data-provider": "*",
"lodash": "^4.17.21",
"lodash": "^4.17.23",
"mammoth": "^1.11.0",
"mathjs": "^15.1.0",
"meilisearch": "^0.38.0",
"memorystore": "^1.6.7",
"mime": "^3.0.0",
"module-alias": "^2.2.3",
"mongoose": "^8.12.1",
"multer": "^2.0.2",
"multer": "^2.1.1",
"nanoid": "^3.3.7",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.11",
@ -104,15 +104,15 @@
"passport-jwt": "^4.0.1",
"passport-ldapauth": "^3.0.1",
"passport-local": "^1.0.0",
"pdfjs-dist": "^5.4.624",
"rate-limit-redis": "^4.2.0",
"sharp": "^0.33.5",
"tiktoken": "^1.0.15",
"traverse": "^0.6.7",
"ua-parser-js": "^1.0.36",
"undici": "^7.10.0",
"undici": "^7.24.1",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^5.0.0",
"youtube-transcript": "^1.2.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.22.4"
},
"devDependencies": {

View file

@ -35,7 +35,6 @@ const graphPropsToClean = [
'tools',
'signal',
'config',
'agentContexts',
'messages',
'contentData',
'stepKeyIds',
@ -277,7 +276,16 @@ function disposeClient(client) {
if (client.run) {
if (client.run.Graph) {
client.run.Graph.resetValues();
if (typeof client.run.Graph.clearHeavyState === 'function') {
client.run.Graph.clearHeavyState();
} else {
client.run.Graph.resetValues();
}
if (client.run.Graph.agentContexts) {
client.run.Graph.agentContexts.clear();
client.run.Graph.agentContexts = null;
}
graphPropsToClean.forEach((prop) => {
if (client.run.Graph[prop] !== undefined) {

View file

@ -18,8 +18,7 @@ const {
findUser,
} = require('~/models');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { getOAuthReconnectionManager } = require('~/config');
const { getOpenIdConfig } = require('~/strategies');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const registrationController = async (req, res) => {
try {
@ -79,11 +78,16 @@ const refreshController = async (req, res) => {
try {
const openIdConfig = getOpenIdConfig();
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {};
const tokenset = await openIdClient.refreshTokenGrant(
openIdConfig,
refreshToken,
refreshParams,
);
const claims = tokenset.claims();
const { user, error, migration } = await findOpenIDUser({
findUser,
email: claims.email,
email: getOpenIdEmail(claims),
openidId: claims.sub,
idOnTheSource: claims.oid,
strategyName: 'refreshController',
@ -161,17 +165,6 @@ const refreshController = async (req, res) => {
if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, session);
// trigger OAuth MCP server reconnection asynchronously (best effort)
try {
void getOAuthReconnectionManager()
.reconnectServers(userId)
.catch((err) => {
logger.error('[refreshController] Error reconnecting OAuth MCP servers:', err);
});
} catch (err) {
logger.warn(`[refreshController] Cannot attempt OAuth MCP servers reconnection:`, err);
}
res.status(200).send({ token, user });
} else if (req?.query?.retry) {
// Retrying from a refresh token request that failed (401)
@ -203,15 +196,6 @@ const graphTokenController = async (req, res) => {
});
}
// Extract access token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
message: 'Valid authorization token required',
});
}
// Get scopes from query parameters
const scopes = req.query.scopes;
if (!scopes) {
return res.status(400).json({
@ -219,7 +203,13 @@ const graphTokenController = async (req, res) => {
});
}
const accessToken = authHeader.substring(7); // Remove 'Bearer ' prefix
const accessToken = req.user.federatedTokens?.access_token;
if (!accessToken) {
return res.status(401).json({
message: 'No federated access token available for token exchange',
});
}
const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes);
res.json(tokenResponse);

View file

@ -0,0 +1,302 @@
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() },
}));
jest.mock('~/server/services/GraphTokenService', () => ({
getGraphApiToken: jest.fn(),
}));
jest.mock('~/server/services/AuthService', () => ({
requestPasswordReset: jest.fn(),
setOpenIDAuthTokens: jest.fn(),
resetPassword: jest.fn(),
setAuthTokens: jest.fn(),
registerUser: jest.fn(),
}));
jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn(), getOpenIdEmail: jest.fn() }));
jest.mock('openid-client', () => ({ refreshTokenGrant: jest.fn() }));
jest.mock('~/models', () => ({
deleteAllUserSessions: jest.fn(),
getUserById: jest.fn(),
findSession: jest.fn(),
updateUser: jest.fn(),
findUser: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(),
findOpenIDUser: jest.fn(),
}));
const openIdClient = require('openid-client');
const { isEnabled, findOpenIDUser } = require('@librechat/api');
const { graphTokenController, refreshController } = require('./AuthController');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const { updateUser } = require('~/models');
describe('graphTokenController', () => {
let req, res;
beforeEach(() => {
jest.clearAllMocks();
isEnabled.mockReturnValue(true);
req = {
user: {
openidId: 'oid-123',
provider: 'openid',
federatedTokens: {
access_token: 'federated-access-token',
id_token: 'federated-id-token',
},
},
headers: { authorization: 'Bearer app-jwt-which-is-id-token' },
query: { scopes: 'https://graph.microsoft.com/.default' },
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
getGraphApiToken.mockResolvedValue({
access_token: 'graph-access-token',
token_type: 'Bearer',
expires_in: 3600,
});
});
it('should pass federatedTokens.access_token as OBO assertion, not the auth header bearer token', async () => {
await graphTokenController(req, res);
expect(getGraphApiToken).toHaveBeenCalledWith(
req.user,
'federated-access-token',
'https://graph.microsoft.com/.default',
);
expect(getGraphApiToken).not.toHaveBeenCalledWith(
expect.anything(),
'app-jwt-which-is-id-token',
expect.anything(),
);
});
it('should return the graph token response on success', async () => {
await graphTokenController(req, res);
expect(res.json).toHaveBeenCalledWith({
access_token: 'graph-access-token',
token_type: 'Bearer',
expires_in: 3600,
});
});
it('should return 403 when user is not authenticated via Entra ID', async () => {
req.user.provider = 'google';
req.user.openidId = undefined;
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 403 when OPENID_REUSE_TOKENS is not enabled', async () => {
isEnabled.mockReturnValue(false);
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 400 when scopes query param is missing', async () => {
req.query.scopes = undefined;
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 401 when federatedTokens.access_token is missing', async () => {
req.user.federatedTokens = {};
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 401 when federatedTokens is absent entirely', async () => {
req.user.federatedTokens = undefined;
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(getGraphApiToken).not.toHaveBeenCalled();
});
it('should return 500 when getGraphApiToken throws', async () => {
getGraphApiToken.mockRejectedValue(new Error('OBO exchange failed'));
await graphTokenController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
message: 'Failed to obtain Microsoft Graph token',
});
});
});
describe('refreshController OpenID path', () => {
const mockTokenset = {
claims: jest.fn(),
access_token: 'new-access',
id_token: 'new-id',
refresh_token: 'new-refresh',
};
const baseClaims = {
sub: 'oidc-sub-123',
oid: 'oid-456',
email: 'user@example.com',
exp: 9999999999,
};
let req, res;
beforeEach(() => {
jest.clearAllMocks();
isEnabled.mockReturnValue(true);
getOpenIdConfig.mockReturnValue({ some: 'config' });
openIdClient.refreshTokenGrant.mockResolvedValue(mockTokenset);
mockTokenset.claims.mockReturnValue(baseClaims);
getOpenIdEmail.mockReturnValue(baseClaims.email);
setOpenIDAuthTokens.mockReturnValue('new-app-token');
updateUser.mockResolvedValue({});
req = {
headers: { cookie: 'token_provider=openid; refreshToken=stored-refresh' },
session: {},
};
res = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
redirect: jest.fn(),
};
});
it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => {
const user = {
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
};
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
await refreshController(req, res);
expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({ email: baseClaims.email }),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('should use OPENID_EMAIL_CLAIM-resolved value when claim is present in token', async () => {
const claimsWithUpn = { ...baseClaims, upn: 'user@corp.example.com' };
mockTokenset.claims.mockReturnValue(claimsWithUpn);
getOpenIdEmail.mockReturnValue('user@corp.example.com');
const user = {
_id: 'user-db-id',
email: 'user@corp.example.com',
openidId: baseClaims.sub,
};
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
await refreshController(req, res);
expect(getOpenIdEmail).toHaveBeenCalledWith(claimsWithUpn);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'user@corp.example.com' }),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('should fall back to claims.email when configured claim is absent from token claims', async () => {
getOpenIdEmail.mockReturnValue(baseClaims.email);
const user = {
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
};
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
await refreshController(req, res);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({ email: baseClaims.email }),
);
});
it('should update openidId when migration is triggered on refresh', async () => {
const user = { _id: 'user-db-id', email: baseClaims.email, openidId: null };
findOpenIDUser.mockResolvedValue({ user, error: null, migration: true });
await refreshController(req, res);
expect(updateUser).toHaveBeenCalledWith(
'user-db-id',
expect.objectContaining({ provider: 'openid', openidId: baseClaims.sub }),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('should return 401 and redirect to /login when findOpenIDUser returns no user', async () => {
findOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
await refreshController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.redirect).toHaveBeenCalledWith('/login');
});
it('should return 401 and redirect when findOpenIDUser returns an error', async () => {
findOpenIDUser.mockResolvedValue({ user: null, error: 'AUTH_FAILED', migration: false });
await refreshController(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.redirect).toHaveBeenCalledWith('/login');
});
it('should skip OpenID path when token_provider is not openid', async () => {
req.headers.cookie = 'token_provider=local; refreshToken=some-token';
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('should skip OpenID path when OPENID_REUSE_TOKENS is disabled', async () => {
isEnabled.mockReturnValue(false);
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('should return 200 with token not provided when refresh token is absent', async () => {
req.headers.cookie = 'token_provider=openid';
req.session = {};
await refreshController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith('Refresh token not provided');
});
});

View file

@ -5,6 +5,7 @@
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
const { enrichRemoteAgentPrincipals, backfillRemoteAgentPermissions } = require('@librechat/api');
const {
bulkUpdateResourcePermissions,
ensureGroupPrincipalExists,
@ -14,7 +15,6 @@ const {
findAccessibleResources,
getResourcePermissionsMap,
} = require('~/server/services/PermissionService');
const { AclEntry } = require('~/db/models');
const {
searchPrincipals: searchLocalPrincipals,
sortPrincipalsByRelevance,
@ -24,6 +24,7 @@ const {
entraIdPrincipalFeatureEnabled,
searchEntraIdPrincipals,
} = require('~/server/services/GraphApiService');
const { AclEntry, AccessRole } = require('~/db/models');
/**
* Generic controller for resource permission endpoints
@ -234,7 +235,7 @@ const getResourcePermissions = async (req, res) => {
},
]);
const principals = [];
let principals = [];
let publicPermission = null;
// Process aggregation results
@ -280,6 +281,13 @@ const getResourcePermissions = async (req, res) => {
}
}
if (resourceType === ResourceType.REMOTE_AGENT) {
const enricherDeps = { AclEntry, AccessRole, logger };
const enrichResult = await enrichRemoteAgentPrincipals(enricherDeps, resourceId, principals);
principals = enrichResult.principals;
backfillRemoteAgentPermissions(enricherDeps, resourceId, enrichResult.entriesToBackfill);
}
// Return response in format expected by frontend
const response = {
resourceType,

View file

@ -8,7 +8,7 @@ const { getLogStores } = require('~/cache');
const getAvailablePluginsController = async (req, res) => {
try {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const cachedPlugins = await cache.get(CacheKeys.PLUGINS);
if (cachedPlugins) {
res.status(200).json(cachedPlugins);
@ -63,7 +63,7 @@ const getAvailableTools = async (req, res) => {
logger.warn('[getAvailableTools] User ID not found in request');
return res.status(401).json({ message: 'Unauthorized' });
}
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));

View file

@ -1,3 +1,4 @@
const { CacheKeys } = require('librechat-data-provider');
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
@ -63,6 +64,28 @@ describe('PluginController', () => {
});
});
describe('cache namespace', () => {
it('getAvailablePluginsController should use TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue([]);
await getAvailablePluginsController(mockReq, mockRes);
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
});
it('getAvailableTools should use TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue([]);
await getAvailableTools(mockReq, mockRes);
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
});
it('should NOT use CONFIG_STORE namespace for tool/plugin operations', async () => {
mockCache.get.mockResolvedValue([]);
await getAvailablePluginsController(mockReq, mockRes);
await getAvailableTools(mockReq, mockRes);
const allCalls = getLogStores.mock.calls.flat();
expect(allCalls).not.toContain(CacheKeys.CONFIG_STORE);
});
});
describe('getAvailablePluginsController', () => {
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
// Add plugins with duplicates to availableTools

View file

@ -1,5 +1,6 @@
const { encryptV3, logger } = require('@librechat/data-schemas');
const {
verifyOTPOrBackupCode,
generateBackupCodes,
generateTOTPSecret,
verifyBackupCode,
@ -13,24 +14,42 @@ const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
/**
* Enable 2FA for the user by generating a new TOTP secret and backup codes.
* The secret is encrypted and stored, and 2FA is marked as disabled until confirmed.
* If 2FA is already enabled, requires OTP or backup code verification to re-enroll.
*/
const enable2FA = async (req, res) => {
try {
const userId = req.user.id;
const existingUser = await getUserById(
userId,
'+totpSecret +backupCodes _id twoFactorEnabled email',
);
if (existingUser && existingUser.twoFactorEnabled) {
const { token, backupCode } = req.body;
const result = await verifyOTPOrBackupCode({
user: existingUser,
token,
backupCode,
persistBackupUse: false,
});
if (!result.verified) {
const msg = result.message ?? 'TOTP token or backup code is required to re-enroll 2FA';
return res.status(result.status ?? 400).json({ message: msg });
}
}
const secret = generateTOTPSecret();
const { plainCodes, codeObjects } = await generateBackupCodes();
// Encrypt the secret with v3 encryption before saving.
const encryptedSecret = encryptV3(secret);
// Update the user record: store the secret & backup codes and set twoFactorEnabled to false.
const user = await updateUser(userId, {
totpSecret: encryptedSecret,
backupCodes: codeObjects,
twoFactorEnabled: false,
pendingTotpSecret: encryptedSecret,
pendingBackupCodes: codeObjects,
});
const otpauthUrl = `otpauth://totp/${safeAppTitle}:${user.email}?secret=${secret}&issuer=${safeAppTitle}`;
const email = user.email || (existingUser && existingUser.email) || '';
const otpauthUrl = `otpauth://totp/${safeAppTitle}:${email}?secret=${secret}&issuer=${safeAppTitle}`;
return res.status(200).json({ otpauthUrl, backupCodes: plainCodes });
} catch (err) {
@ -46,13 +65,14 @@ const verify2FA = async (req, res) => {
try {
const userId = req.user.id;
const { token, backupCode } = req.body;
const user = await getUserById(userId, '_id totpSecret backupCodes');
const user = await getUserById(userId, '+totpSecret +pendingTotpSecret +backupCodes _id');
const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
if (!user || !user.totpSecret) {
if (!user || !secretSource) {
return res.status(400).json({ message: '2FA not initiated' });
}
const secret = await getTOTPSecret(user.totpSecret);
const secret = await getTOTPSecret(secretSource);
let isVerified = false;
if (token) {
@ -78,15 +98,28 @@ const confirm2FA = async (req, res) => {
try {
const userId = req.user.id;
const { token } = req.body;
const user = await getUserById(userId, '_id totpSecret');
const user = await getUserById(
userId,
'+totpSecret +pendingTotpSecret +pendingBackupCodes _id',
);
const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
if (!user || !user.totpSecret) {
if (!user || !secretSource) {
return res.status(400).json({ message: '2FA not initiated' });
}
const secret = await getTOTPSecret(user.totpSecret);
const secret = await getTOTPSecret(secretSource);
if (await verifyTOTP(secret, token)) {
await updateUser(userId, { twoFactorEnabled: true });
const update = {
totpSecret: user.pendingTotpSecret ?? user.totpSecret,
twoFactorEnabled: true,
pendingTotpSecret: null,
pendingBackupCodes: [],
};
if (user.pendingBackupCodes?.length) {
update.backupCodes = user.pendingBackupCodes;
}
await updateUser(userId, update);
return res.status(200).json();
}
return res.status(400).json({ message: 'Invalid token.' });
@ -104,31 +137,27 @@ const disable2FA = async (req, res) => {
try {
const userId = req.user.id;
const { token, backupCode } = req.body;
const user = await getUserById(userId, '_id totpSecret backupCodes');
const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
if (!user || !user.totpSecret) {
return res.status(400).json({ message: '2FA is not setup for this user' });
}
if (user.twoFactorEnabled) {
const secret = await getTOTPSecret(user.totpSecret);
let isVerified = false;
const result = await verifyOTPOrBackupCode({ user, token, backupCode });
if (token) {
isVerified = await verifyTOTP(secret, token);
} else if (backupCode) {
isVerified = await verifyBackupCode({ user, backupCode });
} else {
return res
.status(400)
.json({ message: 'Either token or backup code is required to disable 2FA' });
}
if (!isVerified) {
return res.status(401).json({ message: 'Invalid token or backup code' });
if (!result.verified) {
const msg = result.message ?? 'Either token or backup code is required to disable 2FA';
return res.status(result.status ?? 400).json({ message: msg });
}
}
await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false });
await updateUser(userId, {
totpSecret: null,
backupCodes: [],
twoFactorEnabled: false,
pendingTotpSecret: null,
pendingBackupCodes: [],
});
return res.status(200).json();
} catch (err) {
logger.error('[disable2FA]', err);
@ -138,10 +167,28 @@ const disable2FA = async (req, res) => {
/**
* Regenerate backup codes for the user.
* Requires OTP or backup code verification if 2FA is already enabled.
*/
const regenerateBackupCodes = async (req, res) => {
try {
const userId = req.user.id;
const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
if (user.twoFactorEnabled) {
const { token, backupCode } = req.body;
const result = await verifyOTPOrBackupCode({ user, token, backupCode });
if (!result.verified) {
const msg =
result.message ?? 'TOTP token or backup code is required to regenerate backup codes';
return res.status(result.status ?? 400).json({ message: msg });
}
}
const { plainCodes, codeObjects } = await generateBackupCodes();
await updateUser(userId, { backupCodes: codeObjects });
return res.status(200).json({

View file

@ -14,6 +14,7 @@ const {
deleteMessages,
deletePresets,
deleteUserKey,
getUserById,
deleteConvos,
deleteFiles,
updateUser,
@ -22,6 +23,7 @@ const {
} = require('~/models');
const {
ConversationTag,
AgentApiKey,
Transaction,
MemoryEntry,
Assistant,
@ -33,8 +35,10 @@ const {
User,
} = require('~/db/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { verifyOTPOrBackupCode } = require('~/server/services/twoFactorService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { getAppConfig } = require('~/server/services/Config');
@ -214,6 +218,7 @@ const updateUserPluginsController = async (req, res) => {
`[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`,
);
await mcpManager.disconnectUserConnection(user.id, serverName);
await invalidateCachedTools({ userId: user.id, serverName });
}
} catch (disconnectError) {
logger.error(
@ -238,6 +243,22 @@ const deleteUserController = async (req, res) => {
const { user } = req;
try {
const existingUser = await getUserById(
user.id,
'+totpSecret +backupCodes _id twoFactorEnabled',
);
if (existingUser && existingUser.twoFactorEnabled) {
const { token, backupCode } = req.body;
const result = await verifyOTPOrBackupCode({ user: existingUser, token, backupCode });
if (!result.verified) {
const msg =
result.message ??
'TOTP token or backup code is required to delete account with 2FA enabled';
return res.status(result.status ?? 400).json({ message: msg });
}
}
await deleteMessages({ user: user.id }); // delete user messages
await deleteAllUserSessions({ userId: user.id }); // delete user sessions
await Transaction.deleteMany({ user: user.id }); // delete user transactions
@ -256,6 +277,7 @@ const deleteUserController = async (req, res) => {
await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps
await deleteToolCalls(user.id); // delete user tool calls
await deleteUserAgents(user.id); // delete user agents
await AgentApiKey.deleteMany({ user: user._id }); // delete user agent API keys
await Assistant.deleteMany({ user: user.id }); // delete user assistants
await ConversationTag.deleteMany({ user: user.id }); // delete user conversation tags
await MemoryEntry.deleteMany({ userId: user.id }); // delete user memory entries

View file

@ -0,0 +1,264 @@
const mockGetUserById = jest.fn();
const mockUpdateUser = jest.fn();
const mockVerifyOTPOrBackupCode = jest.fn();
const mockGenerateTOTPSecret = jest.fn();
const mockGenerateBackupCodes = jest.fn();
const mockEncryptV3 = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
encryptV3: (...args) => mockEncryptV3(...args),
logger: { error: jest.fn() },
}));
jest.mock('~/server/services/twoFactorService', () => ({
verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
generateBackupCodes: (...args) => mockGenerateBackupCodes(...args),
generateTOTPSecret: (...args) => mockGenerateTOTPSecret(...args),
verifyBackupCode: jest.fn(),
getTOTPSecret: jest.fn(),
verifyTOTP: jest.fn(),
}));
jest.mock('~/models', () => ({
getUserById: (...args) => mockGetUserById(...args),
updateUser: (...args) => mockUpdateUser(...args),
}));
const { enable2FA, regenerateBackupCodes } = require('~/server/controllers/TwoFactorController');
function createRes() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
const PLAIN_CODES = ['code1', 'code2', 'code3'];
const CODE_OBJECTS = [
{ codeHash: 'h1', used: false, usedAt: null },
{ codeHash: 'h2', used: false, usedAt: null },
{ codeHash: 'h3', used: false, usedAt: null },
];
beforeEach(() => {
jest.clearAllMocks();
mockGenerateTOTPSecret.mockReturnValue('NEWSECRET');
mockGenerateBackupCodes.mockResolvedValue({ plainCodes: PLAIN_CODES, codeObjects: CODE_OBJECTS });
mockEncryptV3.mockReturnValue('encrypted-secret');
});
describe('enable2FA', () => {
it('allows first-time setup without token — writes to pending fields', async () => {
const req = { user: { id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false, email: 'a@b.com' });
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
await enable2FA(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ otpauthUrl: expect.any(String), backupCodes: PLAIN_CODES }),
);
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
const updateCall = mockUpdateUser.mock.calls[0][1];
expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
expect(updateCall).not.toHaveProperty('twoFactorEnabled');
expect(updateCall).not.toHaveProperty('totpSecret');
expect(updateCall).not.toHaveProperty('backupCodes');
});
it('re-enrollment writes to pending fields, leaving live 2FA intact', async () => {
const req = { user: { id: 'user1' }, body: { token: '123456' } };
const res = createRes();
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
email: 'a@b.com',
};
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
await enable2FA(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
user: existingUser,
token: '123456',
backupCode: undefined,
persistBackupUse: false,
});
expect(res.status).toHaveBeenCalledWith(200);
const updateCall = mockUpdateUser.mock.calls[0][1];
expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
expect(updateCall).not.toHaveProperty('twoFactorEnabled');
expect(updateCall).not.toHaveProperty('totpSecret');
});
it('allows re-enrollment with valid backup code (persistBackupUse: false)', async () => {
const req = { user: { id: 'user1' }, body: { backupCode: 'backup123' } };
const res = createRes();
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
email: 'a@b.com',
};
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
await enable2FA(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith(
expect.objectContaining({ persistBackupUse: false }),
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('returns error when no token provided and 2FA is enabled', async () => {
const req = { user: { id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
await enable2FA(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(mockUpdateUser).not.toHaveBeenCalled();
});
it('returns 401 when invalid token provided and 2FA is enabled', async () => {
const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({
verified: false,
status: 401,
message: 'Invalid token or backup code',
});
await enable2FA(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
expect(mockUpdateUser).not.toHaveBeenCalled();
});
});
describe('regenerateBackupCodes', () => {
it('returns 404 when user not found', async () => {
const req = { user: { id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue(null);
await regenerateBackupCodes(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ message: 'User not found' });
});
it('requires OTP when 2FA is enabled', async () => {
const req = { user: { id: 'user1' }, body: { token: '123456' } };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
mockUpdateUser.mockResolvedValue({});
await regenerateBackupCodes(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
backupCodes: PLAIN_CODES,
backupCodesHash: CODE_OBJECTS,
});
});
it('returns error when no token provided and 2FA is enabled', async () => {
const req = { user: { id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
await regenerateBackupCodes(req, res);
expect(res.status).toHaveBeenCalledWith(400);
});
it('returns 401 when invalid token provided and 2FA is enabled', async () => {
const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({
verified: false,
status: 401,
message: 'Invalid token or backup code',
});
await regenerateBackupCodes(req, res);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
});
it('includes backupCodesHash in response', async () => {
const req = { user: { id: 'user1' }, body: { token: '123456' } };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
mockUpdateUser.mockResolvedValue({});
await regenerateBackupCodes(req, res);
const responseBody = res.json.mock.calls[0][0];
expect(responseBody).toHaveProperty('backupCodesHash', CODE_OBJECTS);
expect(responseBody).toHaveProperty('backupCodes', PLAIN_CODES);
});
it('allows regeneration without token when 2FA is not enabled', async () => {
const req = { user: { id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: false,
});
mockUpdateUser.mockResolvedValue({});
await regenerateBackupCodes(req, res);
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
backupCodes: PLAIN_CODES,
backupCodesHash: CODE_OBJECTS,
});
});
});

View file

@ -0,0 +1,302 @@
const mockGetUserById = jest.fn();
const mockDeleteMessages = jest.fn();
const mockDeleteAllUserSessions = jest.fn();
const mockDeleteUserById = jest.fn();
const mockDeleteAllSharedLinks = jest.fn();
const mockDeletePresets = jest.fn();
const mockDeleteUserKey = jest.fn();
const mockDeleteConvos = jest.fn();
const mockDeleteFiles = jest.fn();
const mockGetFiles = jest.fn();
const mockUpdateUserPlugins = jest.fn();
const mockUpdateUser = jest.fn();
const mockFindToken = jest.fn();
const mockVerifyOTPOrBackupCode = jest.fn();
const mockDeleteUserPluginAuth = jest.fn();
const mockProcessDeleteRequest = jest.fn();
const mockDeleteToolCalls = jest.fn();
const mockDeleteUserAgents = jest.fn();
const mockDeleteUserPrompts = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), info: jest.fn() },
webSearchKeys: [],
}));
jest.mock('librechat-data-provider', () => ({
Tools: {},
CacheKeys: {},
Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
FileSources: {},
}));
jest.mock('@librechat/api', () => ({
MCPOAuthHandler: {},
MCPTokenStorage: {},
normalizeHttpError: jest.fn(),
extractWebSearchEnvVars: jest.fn(),
}));
jest.mock('~/models', () => ({
deleteAllUserSessions: (...args) => mockDeleteAllUserSessions(...args),
deleteAllSharedLinks: (...args) => mockDeleteAllSharedLinks(...args),
updateUserPlugins: (...args) => mockUpdateUserPlugins(...args),
deleteUserById: (...args) => mockDeleteUserById(...args),
deleteMessages: (...args) => mockDeleteMessages(...args),
deletePresets: (...args) => mockDeletePresets(...args),
deleteUserKey: (...args) => mockDeleteUserKey(...args),
getUserById: (...args) => mockGetUserById(...args),
deleteConvos: (...args) => mockDeleteConvos(...args),
deleteFiles: (...args) => mockDeleteFiles(...args),
updateUser: (...args) => mockUpdateUser(...args),
findToken: (...args) => mockFindToken(...args),
getFiles: (...args) => mockGetFiles(...args),
}));
jest.mock('~/db/models', () => ({
ConversationTag: { deleteMany: jest.fn() },
AgentApiKey: { deleteMany: jest.fn() },
Transaction: { deleteMany: jest.fn() },
MemoryEntry: { deleteMany: jest.fn() },
Assistant: { deleteMany: jest.fn() },
AclEntry: { deleteMany: jest.fn() },
Balance: { deleteMany: jest.fn() },
Action: { deleteMany: jest.fn() },
Group: { updateMany: jest.fn() },
Token: { deleteMany: jest.fn() },
User: {},
}));
jest.mock('~/server/services/PluginService', () => ({
updateUserPluginAuth: jest.fn(),
deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args),
}));
jest.mock('~/server/services/twoFactorService', () => ({
verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
}));
jest.mock('~/server/services/AuthService', () => ({
verifyEmail: jest.fn(),
resendVerificationEmail: jest.fn(),
}));
jest.mock('~/config', () => ({
getMCPManager: jest.fn(),
getFlowStateManager: jest.fn(),
getMCPServersRegistry: jest.fn(),
}));
jest.mock('~/server/services/Config/getCachedTools', () => ({
invalidateCachedTools: jest.fn(),
}));
jest.mock('~/server/services/Files/S3/crud', () => ({
needsRefresh: jest.fn(),
getNewS3URL: jest.fn(),
}));
jest.mock('~/server/services/Files/process', () => ({
processDeleteRequest: (...args) => mockProcessDeleteRequest(...args),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
}));
jest.mock('~/models/ToolCall', () => ({
deleteToolCalls: (...args) => mockDeleteToolCalls(...args),
}));
jest.mock('~/models/Prompt', () => ({
deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
}));
jest.mock('~/models/Agent', () => ({
deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(),
}));
const { deleteUserController } = require('~/server/controllers/UserController');
function createRes() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.send = jest.fn().mockReturnValue(res);
return res;
}
function stubDeletionMocks() {
mockDeleteMessages.mockResolvedValue();
mockDeleteAllUserSessions.mockResolvedValue();
mockDeleteUserKey.mockResolvedValue();
mockDeletePresets.mockResolvedValue();
mockDeleteConvos.mockResolvedValue();
mockDeleteUserPluginAuth.mockResolvedValue();
mockDeleteUserById.mockResolvedValue();
mockDeleteAllSharedLinks.mockResolvedValue();
mockGetFiles.mockResolvedValue([]);
mockProcessDeleteRequest.mockResolvedValue();
mockDeleteFiles.mockResolvedValue();
mockDeleteToolCalls.mockResolvedValue();
mockDeleteUserAgents.mockResolvedValue();
mockDeleteUserPrompts.mockResolvedValue();
}
beforeEach(() => {
jest.clearAllMocks();
stubDeletionMocks();
});
describe('deleteUserController - 2FA enforcement', () => {
it('proceeds with deletion when 2FA is not enabled', async () => {
const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false });
await deleteUserController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
expect(mockDeleteMessages).toHaveBeenCalled();
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
});
it('proceeds with deletion when user has no 2FA record', async () => {
const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue(null);
await deleteUserController(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
});
it('returns error when 2FA is enabled and verification fails with 400', async () => {
const req = { user: { id: 'user1', _id: 'user1' }, body: {} };
const res = createRes();
mockGetUserById.mockResolvedValue({
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
});
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
await deleteUserController(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(mockDeleteMessages).not.toHaveBeenCalled();
});
it('returns 401 when 2FA is enabled and invalid TOTP token provided', async () => {
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
};
const req = { user: { id: 'user1', _id: 'user1' }, body: { token: 'wrong' } };
const res = createRes();
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({
verified: false,
status: 401,
message: 'Invalid token or backup code',
});
await deleteUserController(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
user: existingUser,
token: 'wrong',
backupCode: undefined,
});
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
expect(mockDeleteMessages).not.toHaveBeenCalled();
});
it('returns 401 when 2FA is enabled and invalid backup code provided', async () => {
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
backupCodes: [],
};
const req = { user: { id: 'user1', _id: 'user1' }, body: { backupCode: 'bad-code' } };
const res = createRes();
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({
verified: false,
status: 401,
message: 'Invalid token or backup code',
});
await deleteUserController(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
user: existingUser,
token: undefined,
backupCode: 'bad-code',
});
expect(res.status).toHaveBeenCalledWith(401);
expect(mockDeleteMessages).not.toHaveBeenCalled();
});
it('deletes account when valid TOTP token provided with 2FA enabled', async () => {
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
};
const req = {
user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
body: { token: '123456' },
};
const res = createRes();
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
await deleteUserController(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
user: existingUser,
token: '123456',
backupCode: undefined,
});
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
expect(mockDeleteMessages).toHaveBeenCalled();
});
it('deletes account when valid backup code provided with 2FA enabled', async () => {
const existingUser = {
_id: 'user1',
twoFactorEnabled: true,
totpSecret: 'enc-secret',
backupCodes: [{ codeHash: 'h1', used: false }],
};
const req = {
user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
body: { backupCode: 'valid-code' },
};
const res = createRes();
mockGetUserById.mockResolvedValue(existingUser);
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
await deleteUserController(req, res);
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
user: existingUser,
token: undefined,
backupCode: 'valid-code',
});
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
expect(mockDeleteMessages).toHaveBeenCalled();
});
});

View file

@ -16,13 +16,10 @@ jest.mock('@librechat/data-schemas', () => ({
}));
jest.mock('@librechat/agents', () => ({
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
Providers: { GOOGLE: 'google' },
GraphEvents: {},
...jest.requireActual('@librechat/agents'),
getMessageId: jest.fn(),
ToolEndHandler: jest.fn(),
handleToolCalls: jest.fn(),
ChatModelStreamHandler: jest.fn(),
}));
jest.mock('~/server/services/Files/Citations', () => ({

View file

@ -0,0 +1,281 @@
/**
* Tests for job replacement detection in ResumableAgentController
*
* Tests the following fixes from PR #11462:
* 1. Job creation timestamp tracking
* 2. Stale job detection and event skipping
* 3. Response message saving before final event emission
*/
const mockLogger = {
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
};
const mockGenerationJobManager = {
createJob: jest.fn(),
getJob: jest.fn(),
emitDone: jest.fn(),
emitChunk: jest.fn(),
completeJob: jest.fn(),
updateMetadata: jest.fn(),
setContentParts: jest.fn(),
subscribe: jest.fn(),
};
const mockSaveMessage = jest.fn();
const mockDecrementPendingRequest = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
logger: mockLogger,
}));
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn().mockReturnValue(false),
GenerationJobManager: mockGenerationJobManager,
checkAndIncrementPendingRequest: jest.fn().mockResolvedValue({ allowed: true }),
decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
getViolationInfo: jest.fn(),
sanitizeMessageForTransmit: jest.fn((msg) => msg),
sanitizeFileForTransmit: jest.fn((file) => file),
Constants: { NO_PARENT: '00000000-0000-0000-0000-000000000000' },
}));
jest.mock('~/models', () => ({
saveMessage: (...args) => mockSaveMessage(...args),
}));
describe('Job Replacement Detection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Job Creation Timestamp Tracking', () => {
it('should capture createdAt when job is created', async () => {
const streamId = 'test-stream-123';
const createdAt = Date.now();
mockGenerationJobManager.createJob.mockResolvedValue({
createdAt,
readyPromise: Promise.resolve(),
abortController: new AbortController(),
emitter: { on: jest.fn() },
});
const job = await mockGenerationJobManager.createJob(streamId, 'user-123', streamId);
expect(job.createdAt).toBe(createdAt);
});
});
describe('Job Replacement Detection Logic', () => {
/**
* Simulates the job replacement detection logic from request.js
* This is extracted for unit testing since the full controller is complex
*/
const detectJobReplacement = async (streamId, originalCreatedAt) => {
const currentJob = await mockGenerationJobManager.getJob(streamId);
return !currentJob || currentJob.createdAt !== originalCreatedAt;
};
it('should detect when job was replaced (different createdAt)', async () => {
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
const newCreatedAt = 2000;
mockGenerationJobManager.getJob.mockResolvedValue({
createdAt: newCreatedAt,
});
const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
expect(wasReplaced).toBe(true);
});
it('should detect when job was deleted', async () => {
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
mockGenerationJobManager.getJob.mockResolvedValue(null);
const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
expect(wasReplaced).toBe(true);
});
it('should not detect replacement when same job (same createdAt)', async () => {
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
mockGenerationJobManager.getJob.mockResolvedValue({
createdAt: originalCreatedAt,
});
const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
expect(wasReplaced).toBe(false);
});
});
describe('Event Emission Behavior', () => {
/**
* Simulates the final event emission logic from request.js
*/
const emitFinalEventIfNotReplaced = async ({
streamId,
originalCreatedAt,
finalEvent,
userId,
}) => {
const currentJob = await mockGenerationJobManager.getJob(streamId);
const jobWasReplaced = !currentJob || currentJob.createdAt !== originalCreatedAt;
if (jobWasReplaced) {
mockLogger.debug('Skipping FINAL emit - job was replaced', {
streamId,
originalCreatedAt,
currentCreatedAt: currentJob?.createdAt,
});
await mockDecrementPendingRequest(userId);
return false;
}
mockGenerationJobManager.emitDone(streamId, finalEvent);
mockGenerationJobManager.completeJob(streamId);
await mockDecrementPendingRequest(userId);
return true;
};
it('should skip emitting when job was replaced', async () => {
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
const newCreatedAt = 2000;
const userId = 'user-123';
mockGenerationJobManager.getJob.mockResolvedValue({
createdAt: newCreatedAt,
});
const emitted = await emitFinalEventIfNotReplaced({
streamId,
originalCreatedAt,
finalEvent: { final: true },
userId,
});
expect(emitted).toBe(false);
expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(userId);
expect(mockLogger.debug).toHaveBeenCalledWith(
'Skipping FINAL emit - job was replaced',
expect.objectContaining({
streamId,
originalCreatedAt,
currentCreatedAt: newCreatedAt,
}),
);
});
it('should emit when job was not replaced', async () => {
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
const userId = 'user-123';
const finalEvent = { final: true, conversation: { conversationId: streamId } };
mockGenerationJobManager.getJob.mockResolvedValue({
createdAt: originalCreatedAt,
});
const emitted = await emitFinalEventIfNotReplaced({
streamId,
originalCreatedAt,
finalEvent,
userId,
});
expect(emitted).toBe(true);
expect(mockGenerationJobManager.emitDone).toHaveBeenCalledWith(streamId, finalEvent);
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(streamId);
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(userId);
});
});
describe('Response Message Saving Order', () => {
/**
* Tests that response messages are saved BEFORE final events are emitted
* This prevents race conditions where clients send follow-up messages
* before the response is in the database
*/
it('should save message before emitting final event', async () => {
const callOrder = [];
mockSaveMessage.mockImplementation(async () => {
callOrder.push('saveMessage');
});
mockGenerationJobManager.emitDone.mockImplementation(() => {
callOrder.push('emitDone');
});
mockGenerationJobManager.getJob.mockResolvedValue({
createdAt: 1000,
});
// Simulate the order of operations from request.js
const streamId = 'test-stream-123';
const originalCreatedAt = 1000;
const response = { messageId: 'response-123' };
const userId = 'user-123';
// Step 1: Save message
await mockSaveMessage({}, { ...response, user: userId }, { context: 'test' });
// Step 2: Check for replacement
const currentJob = await mockGenerationJobManager.getJob(streamId);
const jobWasReplaced = !currentJob || currentJob.createdAt !== originalCreatedAt;
// Step 3: Emit if not replaced
if (!jobWasReplaced) {
mockGenerationJobManager.emitDone(streamId, { final: true });
}
expect(callOrder).toEqual(['saveMessage', 'emitDone']);
});
});
describe('Aborted Request Handling', () => {
it('should use unfinished: true instead of error: true for aborted requests', () => {
const response = { messageId: 'response-123', content: [] };
// The new format for aborted responses
const abortedResponse = { ...response, unfinished: true };
expect(abortedResponse.unfinished).toBe(true);
expect(abortedResponse.error).toBeUndefined();
});
it('should include unfinished flag in final event for aborted requests', () => {
const response = { messageId: 'response-123', content: [] };
// Old format (deprecated)
const _oldFinalEvent = {
final: true,
responseMessage: { ...response, error: true },
error: { message: 'Request was aborted' },
};
// New format (PR #11462)
const newFinalEvent = {
final: true,
responseMessage: { ...response, unfinished: true },
};
expect(newFinalEvent.responseMessage.unfinished).toBe(true);
expect(newFinalEvent.error).toBeUndefined();
expect(newFinalEvent.responseMessage.error).toBeUndefined();
});
});
});

View file

@ -0,0 +1,229 @@
/**
* Unit tests for OpenAI-compatible API controller
* Tests that recordCollectedUsage is called correctly for token spending
*/
const mockSpendTokens = jest.fn().mockResolvedValue({});
const mockSpendStructuredTokens = jest.fn().mockResolvedValue({});
const mockRecordCollectedUsage = jest
.fn()
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-nanoid-123'),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
}));
jest.mock('@librechat/agents', () => ({
Callback: { TOOL_ERROR: 'TOOL_ERROR' },
ToolEndHandler: jest.fn(),
formatAgentMessages: jest.fn().mockReturnValue({
messages: [],
indexTokenCountMap: {},
}),
}));
jest.mock('@librechat/api', () => ({
writeSSE: jest.fn(),
createRun: jest.fn().mockResolvedValue({
processStream: jest.fn().mockResolvedValue(undefined),
}),
createChunk: jest.fn().mockReturnValue({}),
buildToolSet: jest.fn().mockReturnValue(new Set()),
sendFinalChunk: jest.fn(),
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
validateRequest: jest
.fn()
.mockReturnValue({ request: { model: 'agent-123', messages: [], stream: false } }),
initializeAgent: jest.fn().mockResolvedValue({
model: 'gpt-4',
model_parameters: {},
toolRegistry: {},
}),
getBalanceConfig: mockGetBalanceConfig,
createErrorResponse: jest.fn(),
getTransactionsConfig: mockGetTransactionsConfig,
recordCollectedUsage: mockRecordCollectedUsage,
buildNonStreamingResponse: jest.fn().mockReturnValue({ id: 'resp-123' }),
createOpenAIStreamTracker: jest.fn().mockReturnValue({
addText: jest.fn(),
addReasoning: jest.fn(),
toolCalls: new Map(),
usage: { promptTokens: 0, completionTokens: 0, reasoningTokens: 0 },
}),
createOpenAIContentAggregator: jest.fn().mockReturnValue({
addText: jest.fn(),
addReasoning: jest.fn(),
getText: jest.fn().mockReturnValue(''),
getReasoning: jest.fn().mockReturnValue(''),
toolCalls: new Map(),
usage: { promptTokens: 100, completionTokens: 50, reasoningTokens: 0 },
}),
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
isChatCompletionValidationFailure: jest.fn().mockReturnValue(false),
}));
jest.mock('~/server/services/ToolService', () => ({
loadAgentTools: jest.fn().mockResolvedValue([]),
loadToolsForExecution: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/spendTokens', () => ({
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
}));
const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('~/models/tx', () => ({
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('~/server/controllers/agents/callbacks', () => ({
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
}));
jest.mock('~/server/services/PermissionService', () => ({
findAccessibleResources: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/Conversation', () => ({
getConvoFiles: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/Agent', () => ({
getAgent: jest.fn().mockResolvedValue({
id: 'agent-123',
provider: 'openAI',
model_parameters: { model: 'gpt-4' },
}),
getAgents: jest.fn().mockResolvedValue([]),
}));
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
jest.mock('~/models', () => ({
getFiles: jest.fn(),
getUserKey: jest.fn(),
getMessages: jest.fn(),
updateFilesUsage: jest.fn(),
getUserKeyValues: jest.fn(),
getUserCodeFiles: jest.fn(),
getToolFilesByIds: jest.fn(),
getCodeGeneratedFiles: jest.fn(),
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
}));
describe('OpenAIChatCompletionController', () => {
let OpenAIChatCompletionController;
let req, res;
beforeEach(() => {
jest.clearAllMocks();
const controller = require('../openai');
OpenAIChatCompletionController = controller.OpenAIChatCompletionController;
req = {
body: {
model: 'agent-123',
messages: [{ role: 'user', content: 'Hello' }],
stream: false,
},
user: { id: 'user-123' },
config: {
endpoints: {
agents: { allowedProviders: ['openAI'] },
},
},
on: jest.fn(),
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
setHeader: jest.fn(),
flushHeaders: jest.fn(),
end: jest.fn(),
write: jest.fn(),
};
});
describe('token usage recording', () => {
it('should call recordCollectedUsage after successful non-streaming completion', async () => {
await OpenAIChatCompletionController(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
{
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
bulkWriteOps: {
insertMany: mockBulkInsertTransactions,
updateBalance: mockUpdateBalance,
},
},
expect.objectContaining({
user: 'user-123',
conversationId: expect.any(String),
collectedUsage: expect.any(Array),
context: 'message',
balance: { enabled: true },
transactions: { enabled: true },
}),
);
});
it('should pass balance and transactions config to recordCollectedUsage', async () => {
mockGetBalanceConfig.mockReturnValue({ enabled: true, startBalance: 1000 });
mockGetTransactionsConfig.mockReturnValue({ enabled: true, rateLimit: 100 });
await OpenAIChatCompletionController(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
balance: { enabled: true, startBalance: 1000 },
transactions: { enabled: true, rateLimit: 100 },
}),
);
});
it('should pass spendTokens, spendStructuredTokens, pricing, and bulkWriteOps as dependencies', async () => {
await OpenAIChatCompletionController(req, res);
const [deps] = mockRecordCollectedUsage.mock.calls[0];
expect(deps).toHaveProperty('spendTokens', mockSpendTokens);
expect(deps).toHaveProperty('spendStructuredTokens', mockSpendStructuredTokens);
expect(deps).toHaveProperty('pricing');
expect(deps.pricing).toHaveProperty('getMultiplier', mockGetMultiplier);
expect(deps.pricing).toHaveProperty('getCacheMultiplier', mockGetCacheMultiplier);
expect(deps).toHaveProperty('bulkWriteOps');
expect(deps.bulkWriteOps).toHaveProperty('insertMany', mockBulkInsertTransactions);
expect(deps.bulkWriteOps).toHaveProperty('updateBalance', mockUpdateBalance);
});
it('should include model from primaryConfig in recordCollectedUsage params', async () => {
await OpenAIChatCompletionController(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
model: 'gpt-4',
}),
);
});
});
});

View file

@ -0,0 +1,345 @@
/**
* Unit tests for Open Responses API controller
* Tests that recordCollectedUsage is called correctly for token spending
*/
const mockSpendTokens = jest.fn().mockResolvedValue({});
const mockSpendStructuredTokens = jest.fn().mockResolvedValue({});
const mockRecordCollectedUsage = jest
.fn()
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'mock-nanoid-123'),
}));
jest.mock('uuid', () => ({
v4: jest.fn(() => 'mock-uuid-456'),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
}));
jest.mock('@librechat/agents', () => ({
Callback: { TOOL_ERROR: 'TOOL_ERROR' },
ToolEndHandler: jest.fn(),
formatAgentMessages: jest.fn().mockReturnValue({
messages: [],
indexTokenCountMap: {},
}),
}));
jest.mock('@librechat/api', () => ({
createRun: jest.fn().mockResolvedValue({
processStream: jest.fn().mockResolvedValue(undefined),
}),
buildToolSet: jest.fn().mockReturnValue(new Set()),
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
initializeAgent: jest.fn().mockResolvedValue({
model: 'claude-3',
model_parameters: {},
toolRegistry: {},
}),
getBalanceConfig: mockGetBalanceConfig,
getTransactionsConfig: mockGetTransactionsConfig,
recordCollectedUsage: mockRecordCollectedUsage,
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
// Responses API
writeDone: jest.fn(),
buildResponse: jest.fn().mockReturnValue({ id: 'resp_123', output: [] }),
generateResponseId: jest.fn().mockReturnValue('resp_mock-123'),
isValidationFailure: jest.fn().mockReturnValue(false),
emitResponseCreated: jest.fn(),
createResponseContext: jest.fn().mockReturnValue({ responseId: 'resp_123' }),
createResponseTracker: jest.fn().mockReturnValue({
usage: { promptTokens: 100, completionTokens: 50 },
}),
setupStreamingResponse: jest.fn(),
emitResponseInProgress: jest.fn(),
convertInputToMessages: jest.fn().mockReturnValue([]),
validateResponseRequest: jest.fn().mockReturnValue({
request: { model: 'agent-123', input: 'Hello', stream: false },
}),
buildAggregatedResponse: jest.fn().mockReturnValue({
id: 'resp_123',
status: 'completed',
output: [],
usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 },
}),
createResponseAggregator: jest.fn().mockReturnValue({
usage: { promptTokens: 100, completionTokens: 50 },
}),
sendResponsesErrorResponse: jest.fn(),
createResponsesEventHandlers: jest.fn().mockReturnValue({
handlers: {
on_message_delta: { handle: jest.fn() },
on_reasoning_delta: { handle: jest.fn() },
on_run_step: { handle: jest.fn() },
on_run_step_delta: { handle: jest.fn() },
on_chat_model_end: { handle: jest.fn() },
},
finalizeStream: jest.fn(),
}),
createAggregatorEventHandlers: jest.fn().mockReturnValue({
on_message_delta: { handle: jest.fn() },
on_reasoning_delta: { handle: jest.fn() },
on_run_step: { handle: jest.fn() },
on_run_step_delta: { handle: jest.fn() },
on_chat_model_end: { handle: jest.fn() },
}),
}));
jest.mock('~/server/services/ToolService', () => ({
loadAgentTools: jest.fn().mockResolvedValue([]),
loadToolsForExecution: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/spendTokens', () => ({
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
}));
const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('~/models/tx', () => ({
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('~/server/controllers/agents/callbacks', () => ({
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
createResponsesToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
}));
jest.mock('~/server/services/PermissionService', () => ({
findAccessibleResources: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/models/Conversation', () => ({
getConvoFiles: jest.fn().mockResolvedValue([]),
saveConvo: jest.fn().mockResolvedValue({}),
getConvo: jest.fn().mockResolvedValue(null),
}));
jest.mock('~/models/Agent', () => ({
getAgent: jest.fn().mockResolvedValue({
id: 'agent-123',
name: 'Test Agent',
provider: 'anthropic',
model_parameters: { model: 'claude-3' },
}),
getAgents: jest.fn().mockResolvedValue([]),
}));
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
jest.mock('~/models', () => ({
getFiles: jest.fn(),
getUserKey: jest.fn(),
getMessages: jest.fn().mockResolvedValue([]),
saveMessage: jest.fn().mockResolvedValue({}),
updateFilesUsage: jest.fn(),
getUserKeyValues: jest.fn(),
getUserCodeFiles: jest.fn(),
getToolFilesByIds: jest.fn(),
getCodeGeneratedFiles: jest.fn(),
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
}));
describe('createResponse controller', () => {
let createResponse;
let req, res;
beforeEach(() => {
jest.clearAllMocks();
const controller = require('../responses');
createResponse = controller.createResponse;
req = {
body: {
model: 'agent-123',
input: 'Hello',
stream: false,
},
user: { id: 'user-123' },
config: {
endpoints: {
agents: { allowedProviders: ['anthropic'] },
},
},
on: jest.fn(),
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
setHeader: jest.fn(),
flushHeaders: jest.fn(),
end: jest.fn(),
write: jest.fn(),
};
});
describe('token usage recording - non-streaming', () => {
it('should call recordCollectedUsage after successful non-streaming completion', async () => {
await createResponse(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
{
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
bulkWriteOps: {
insertMany: mockBulkInsertTransactions,
updateBalance: mockUpdateBalance,
},
},
expect.objectContaining({
user: 'user-123',
conversationId: expect.any(String),
collectedUsage: expect.any(Array),
context: 'message',
}),
);
});
it('should pass balance and transactions config to recordCollectedUsage', async () => {
mockGetBalanceConfig.mockReturnValue({ enabled: true, startBalance: 2000 });
mockGetTransactionsConfig.mockReturnValue({ enabled: true });
await createResponse(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
balance: { enabled: true, startBalance: 2000 },
transactions: { enabled: true },
}),
);
});
it('should pass spendTokens, spendStructuredTokens, pricing, and bulkWriteOps as dependencies', async () => {
await createResponse(req, res);
const [deps] = mockRecordCollectedUsage.mock.calls[0];
expect(deps).toHaveProperty('spendTokens', mockSpendTokens);
expect(deps).toHaveProperty('spendStructuredTokens', mockSpendStructuredTokens);
expect(deps).toHaveProperty('pricing');
expect(deps.pricing).toHaveProperty('getMultiplier', mockGetMultiplier);
expect(deps.pricing).toHaveProperty('getCacheMultiplier', mockGetCacheMultiplier);
expect(deps).toHaveProperty('bulkWriteOps');
expect(deps.bulkWriteOps).toHaveProperty('insertMany', mockBulkInsertTransactions);
expect(deps.bulkWriteOps).toHaveProperty('updateBalance', mockUpdateBalance);
});
it('should include model from primaryConfig in recordCollectedUsage params', async () => {
await createResponse(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
model: 'claude-3',
}),
);
});
});
describe('token usage recording - streaming', () => {
beforeEach(() => {
req.body.stream = true;
const api = require('@librechat/api');
api.validateResponseRequest.mockReturnValue({
request: { model: 'agent-123', input: 'Hello', stream: true },
});
});
it('should call recordCollectedUsage after successful streaming completion', async () => {
await createResponse(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
{
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
bulkWriteOps: {
insertMany: mockBulkInsertTransactions,
updateBalance: mockUpdateBalance,
},
},
expect.objectContaining({
user: 'user-123',
context: 'message',
}),
);
});
});
describe('collectedUsage population', () => {
it('should collect usage from on_chat_model_end events', async () => {
const api = require('@librechat/api');
let capturedOnChatModelEnd;
api.createAggregatorEventHandlers.mockImplementation(() => {
return {
on_message_delta: { handle: jest.fn() },
on_reasoning_delta: { handle: jest.fn() },
on_run_step: { handle: jest.fn() },
on_run_step_delta: { handle: jest.fn() },
on_chat_model_end: {
handle: jest.fn((event, data) => {
if (capturedOnChatModelEnd) {
capturedOnChatModelEnd(event, data);
}
}),
},
};
});
api.createRun.mockImplementation(async ({ customHandlers }) => {
capturedOnChatModelEnd = (event, data) => {
customHandlers.on_chat_model_end.handle(event, data);
};
return {
processStream: jest.fn().mockImplementation(async () => {
customHandlers.on_chat_model_end.handle('on_chat_model_end', {
output: {
usage_metadata: {
input_tokens: 150,
output_tokens: 75,
model: 'claude-3',
},
},
});
}),
};
});
await createResponse(req, res);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
collectedUsage: expect.arrayContaining([
expect.objectContaining({
input_tokens: 150,
output_tokens: 75,
}),
]),
}),
);
});
});
});

View file

@ -1,16 +1,13 @@
const { nanoid } = require('nanoid');
const { sendEvent, GenerationJobManager } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Constants, EnvVar, GraphEvents, ToolEndHandler } = require('@librechat/agents');
const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider');
const {
EnvVar,
Providers,
GraphEvents,
getMessageId,
ToolEndHandler,
handleToolCalls,
ChatModelStreamHandler,
} = require('@librechat/agents');
sendEvent,
GenerationJobManager,
writeAttachmentEvent,
createToolExecuteHandler,
} = require('@librechat/api');
const { processFileCitations } = require('~/server/services/Files/Citations');
const { processCodeOutput } = require('~/server/services/Files/Code/process');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
@ -51,8 +48,6 @@ class ModelEndHandler {
let errorMessage;
try {
const agentContext = graph.getAgentContext(metadata);
const isGoogle = agentContext.provider === Providers.GOOGLE;
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
if (data?.output?.additional_kwargs?.stop_reason === 'refusal') {
const info = { ...data.output.additional_kwargs };
errorMessage = JSON.stringify({
@ -67,21 +62,6 @@ class ModelEndHandler {
});
}
const toolCalls = data?.output?.tool_calls;
let hasUnprocessedToolCalls = false;
if (Array.isArray(toolCalls) && toolCalls.length > 0 && graph?.toolCallStepIds?.has) {
try {
hasUnprocessedToolCalls = toolCalls.some(
(tc) => tc?.id && !graph.toolCallStepIds.has(tc.id),
);
} catch {
hasUnprocessedToolCalls = false;
}
}
if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
await handleToolCalls(toolCalls, metadata, graph);
}
const usage = data?.output?.usage_metadata;
if (!usage) {
return this.finalize(errorMessage);
@ -92,38 +72,6 @@ class ModelEndHandler {
}
this.collectedUsage.push(usage);
if (!streamingDisabled) {
return this.finalize(errorMessage);
}
if (!data.output.content) {
return this.finalize(errorMessage);
}
const stepKey = graph.getStepKey(metadata);
const message_id = getMessageId(stepKey, graph) ?? '';
if (message_id) {
await graph.dispatchRunStep(stepKey, {
type: StepTypes.MESSAGE_CREATION,
message_creation: {
message_id,
},
});
}
const stepId = graph.getStepIdByKey(stepKey);
const content = data.output.content;
if (typeof content === 'string') {
await graph.dispatchMessageDelta(stepId, {
content: [
{
type: 'text',
text: content,
},
],
});
} else if (content.every((c) => c.type?.startsWith('text'))) {
await graph.dispatchMessageDelta(stepId, {
content,
});
}
} catch (error) {
logger.error('Error handling model end event:', error);
return this.finalize(errorMessage);
@ -146,18 +94,26 @@ function checkIfLastAgent(last_agent_id, langgraph_node) {
/**
* Helper to emit events either to res (standard mode) or to job emitter (resumable mode).
* In Redis mode, awaits the emit to guarantee event ordering (critical for streaming deltas).
* @param {ServerResponse} res - The server response object
* @param {string | null} streamId - The stream ID for resumable mode, or null for standard mode
* @param {Object} eventData - The event data to send
* @returns {Promise<void>}
*/
function emitEvent(res, streamId, eventData) {
async function emitEvent(res, streamId, eventData) {
if (streamId) {
GenerationJobManager.emitChunk(streamId, eventData);
await GenerationJobManager.emitChunk(streamId, eventData);
} else {
sendEvent(res, eventData);
}
}
/**
* @typedef {Object} ToolExecuteOptions
* @property {(toolNames: string[]) => Promise<{loadedTools: StructuredTool[]}>} loadTools - Function to load tools by name
* @property {Object} configurable - Configurable context for tool invocation
*/
/**
* Get default handlers for stream events.
* @param {Object} options - The options object.
@ -166,6 +122,7 @@ function emitEvent(res, streamId, eventData) {
* @param {ToolEndCallback} options.toolEndCallback - Callback to use when tool ends.
* @param {Array<UsageMetadata>} options.collectedUsage - The list of collected usage metadata.
* @param {string | null} [options.streamId] - The stream ID for resumable mode, or null for standard mode.
* @param {ToolExecuteOptions} [options.toolExecuteOptions] - Options for event-driven tool execution.
* @returns {Record<string, t.EventHandler>} The default handlers.
* @throws {Error} If the request is not found.
*/
@ -175,6 +132,7 @@ function getDefaultHandlers({
toolEndCallback,
collectedUsage,
streamId = null,
toolExecuteOptions = null,
}) {
if (!res || !aggregateContent) {
throw new Error(
@ -184,7 +142,6 @@ function getDefaultHandlers({
const handlers = {
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage),
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger),
[GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
[GraphEvents.ON_RUN_STEP]: {
/**
* Handle ON_RUN_STEP event.
@ -192,18 +149,19 @@ function getDefaultHandlers({
* @param {StreamEventData} data - The event data.
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
*/
handle: (event, data, metadata) => {
handle: async (event, data, metadata) => {
aggregateContent({ event, data });
if (data?.stepDetails.type === StepTypes.TOOL_CALLS) {
emitEvent(res, streamId, { event, data });
await emitEvent(res, streamId, { event, data });
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
emitEvent(res, streamId, { event, data });
await emitEvent(res, streamId, { event, data });
} else if (!metadata?.hide_sequential_outputs) {
emitEvent(res, streamId, { event, data });
await emitEvent(res, streamId, { event, data });
} else {
const agentName = metadata?.name ?? 'Agent';
const isToolCall = data?.stepDetails.type === StepTypes.TOOL_CALLS;
const action = isToolCall ? 'performing a task...' : 'thinking...';
emitEvent(res, streamId, {
await emitEvent(res, streamId, {
event: 'on_agent_update',
data: {
runId: metadata?.run_id,
@ -211,7 +169,6 @@ function getDefaultHandlers({
},
});
}
aggregateContent({ event, data });
},
},
[GraphEvents.ON_RUN_STEP_DELTA]: {
@ -221,15 +178,15 @@ function getDefaultHandlers({
* @param {StreamEventData} data - The event data.
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
*/
handle: (event, data, metadata) => {
if (data?.delta.type === StepTypes.TOOL_CALLS) {
emitEvent(res, streamId, { event, data });
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
emitEvent(res, streamId, { event, data });
} else if (!metadata?.hide_sequential_outputs) {
emitEvent(res, streamId, { event, data });
}
handle: async (event, data, metadata) => {
aggregateContent({ event, data });
if (data?.delta.type === StepTypes.TOOL_CALLS) {
await emitEvent(res, streamId, { event, data });
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
await emitEvent(res, streamId, { event, data });
} else if (!metadata?.hide_sequential_outputs) {
await emitEvent(res, streamId, { event, data });
}
},
},
[GraphEvents.ON_RUN_STEP_COMPLETED]: {
@ -239,15 +196,15 @@ function getDefaultHandlers({
* @param {StreamEventData & { result: ToolEndData }} data - The event data.
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
*/
handle: (event, data, metadata) => {
if (data?.result != null) {
emitEvent(res, streamId, { event, data });
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
emitEvent(res, streamId, { event, data });
} else if (!metadata?.hide_sequential_outputs) {
emitEvent(res, streamId, { event, data });
}
handle: async (event, data, metadata) => {
aggregateContent({ event, data });
if (data?.result != null) {
await emitEvent(res, streamId, { event, data });
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
await emitEvent(res, streamId, { event, data });
} else if (!metadata?.hide_sequential_outputs) {
await emitEvent(res, streamId, { event, data });
}
},
},
[GraphEvents.ON_MESSAGE_DELTA]: {
@ -257,13 +214,13 @@ function getDefaultHandlers({
* @param {StreamEventData} data - The event data.
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
*/
handle: (event, data, metadata) => {
if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
emitEvent(res, streamId, { event, data });
} else if (!metadata?.hide_sequential_outputs) {
emitEvent(res, streamId, { event, data });
}
handle: async (event, data, metadata) => {
aggregateContent({ event, data });
if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
await emitEvent(res, streamId, { event, data });
} else if (!metadata?.hide_sequential_outputs) {
await emitEvent(res, streamId, { event, data });
}
},
},
[GraphEvents.ON_REASONING_DELTA]: {
@ -273,22 +230,27 @@ function getDefaultHandlers({
* @param {StreamEventData} data - The event data.
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
*/
handle: (event, data, metadata) => {
if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
emitEvent(res, streamId, { event, data });
} else if (!metadata?.hide_sequential_outputs) {
emitEvent(res, streamId, { event, data });
}
handle: async (event, data, metadata) => {
aggregateContent({ event, data });
if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
await emitEvent(res, streamId, { event, data });
} else if (!metadata?.hide_sequential_outputs) {
await emitEvent(res, streamId, { event, data });
}
},
},
};
if (toolExecuteOptions) {
handlers[GraphEvents.ON_TOOL_EXECUTE] = createToolExecuteHandler(toolExecuteOptions);
}
return handlers;
}
/**
* Helper to write attachment events either to res or to job emitter.
* Note: Attachments are not order-sensitive like deltas, so fire-and-forget is acceptable.
* @param {ServerResponse} res - The server response object
* @param {string | null} streamId - The stream ID for resumable mode, or null for standard mode
* @param {Object} attachment - The attachment data
@ -408,7 +370,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null })
const { url } = part.image_url;
artifactPromises.push(
(async () => {
const filename = `${output.name}_${output.tool_call_id}_img_${nanoid()}`;
const filename = `${output.name}_img_${nanoid()}`;
const file_id = output.artifact.file_ids?.[i];
const file = await saveBase64Image(url, {
req,
@ -441,10 +403,10 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null })
return;
}
{
if (output.name !== Tools.execute_code) {
return;
}
const isCodeTool =
output.name === Tools.execute_code || output.name === Constants.PROGRAMMATIC_TOOL_CALLING;
if (!isCodeTool) {
return;
}
if (!output.artifact.files) {
@ -488,7 +450,226 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null })
};
}
/**
* Helper to write attachment events in Open Responses format (librechat:attachment)
* @param {ServerResponse} res - The server response object
* @param {Object} tracker - The response tracker with sequence number
* @param {Object} attachment - The attachment data
* @param {Object} metadata - Additional metadata (messageId, conversationId)
*/
function writeResponsesAttachment(res, tracker, attachment, metadata) {
const sequenceNumber = tracker.nextSequence();
writeAttachmentEvent(res, sequenceNumber, attachment, {
messageId: metadata.run_id,
conversationId: metadata.thread_id,
});
}
/**
* Creates a tool end callback specifically for the Responses API.
* Emits attachments as `librechat:attachment` events per the Open Responses extension spec.
*
* @param {Object} params
* @param {ServerRequest} params.req
* @param {ServerResponse} params.res
* @param {Object} params.tracker - Response tracker with sequence number
* @param {Promise<MongoFile | { filename: string; filepath: string; expires: number;} | null>[]} params.artifactPromises
* @returns {ToolEndCallback} The tool end callback.
*/
function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) {
/**
* @type {ToolEndCallback}
*/
return async (data, metadata) => {
const output = data?.output;
if (!output) {
return;
}
if (!output.artifact) {
return;
}
if (output.artifact[Tools.file_search]) {
artifactPromises.push(
(async () => {
const user = req.user;
const attachment = await processFileCitations({
user,
metadata,
appConfig: req.config,
toolArtifact: output.artifact,
toolCallId: output.tool_call_id,
});
if (!attachment) {
return null;
}
// For Responses API, emit attachment during streaming
if (res.headersSent && !res.writableEnded) {
writeResponsesAttachment(res, tracker, attachment, metadata);
}
return attachment;
})().catch((error) => {
logger.error('Error processing file citations:', error);
return null;
}),
);
}
if (output.artifact[Tools.ui_resources]) {
artifactPromises.push(
(async () => {
const attachment = {
type: Tools.ui_resources,
toolCallId: output.tool_call_id,
[Tools.ui_resources]: output.artifact[Tools.ui_resources].data,
};
// For Responses API, always emit attachment during streaming
if (res.headersSent && !res.writableEnded) {
writeResponsesAttachment(res, tracker, attachment, metadata);
}
return attachment;
})().catch((error) => {
logger.error('Error processing artifact content:', error);
return null;
}),
);
}
if (output.artifact[Tools.web_search]) {
artifactPromises.push(
(async () => {
const attachment = {
type: Tools.web_search,
toolCallId: output.tool_call_id,
[Tools.web_search]: { ...output.artifact[Tools.web_search] },
};
// For Responses API, always emit attachment during streaming
if (res.headersSent && !res.writableEnded) {
writeResponsesAttachment(res, tracker, attachment, metadata);
}
return attachment;
})().catch((error) => {
logger.error('Error processing artifact content:', error);
return null;
}),
);
}
if (output.artifact.content) {
/** @type {FormattedContent[]} */
const content = output.artifact.content;
for (let i = 0; i < content.length; i++) {
const part = content[i];
if (!part) {
continue;
}
if (part.type !== 'image_url') {
continue;
}
const { url } = part.image_url;
artifactPromises.push(
(async () => {
const filename = `${output.name}_img_${nanoid()}`;
const file_id = output.artifact.file_ids?.[i];
const file = await saveBase64Image(url, {
req,
file_id,
filename,
endpoint: metadata.provider,
context: FileContext.image_generation,
});
const fileMetadata = Object.assign(file, {
toolCallId: output.tool_call_id,
});
if (!fileMetadata) {
return null;
}
// For Responses API, emit attachment during streaming
if (res.headersSent && !res.writableEnded) {
const attachment = {
file_id: fileMetadata.file_id,
filename: fileMetadata.filename,
type: fileMetadata.type,
url: fileMetadata.filepath,
width: fileMetadata.width,
height: fileMetadata.height,
tool_call_id: output.tool_call_id,
};
writeResponsesAttachment(res, tracker, attachment, metadata);
}
return fileMetadata;
})().catch((error) => {
logger.error('Error processing artifact content:', error);
return null;
}),
);
}
return;
}
const isCodeTool =
output.name === Tools.execute_code || output.name === Constants.PROGRAMMATIC_TOOL_CALLING;
if (!isCodeTool) {
return;
}
if (!output.artifact.files) {
return;
}
for (const file of output.artifact.files) {
const { id, name } = file;
artifactPromises.push(
(async () => {
const result = await loadAuthValues({
userId: req.user.id,
authFields: [EnvVar.CODE_API_KEY],
});
const fileMetadata = await processCodeOutput({
req,
id,
name,
apiKey: result[EnvVar.CODE_API_KEY],
messageId: metadata.run_id,
toolCallId: output.tool_call_id,
conversationId: metadata.thread_id,
session_id: output.artifact.session_id,
});
if (!fileMetadata) {
return null;
}
// For Responses API, emit attachment during streaming
if (res.headersSent && !res.writableEnded) {
const attachment = {
file_id: fileMetadata.file_id,
filename: fileMetadata.filename,
type: fileMetadata.type,
url: fileMetadata.filepath,
width: fileMetadata.width,
height: fileMetadata.height,
tool_call_id: output.tool_call_id,
};
writeResponsesAttachment(res, tracker, attachment, metadata);
}
return fileMetadata;
})().catch((error) => {
logger.error('Error processing code output:', error);
return null;
}),
);
}
};
}
module.exports = {
getDefaultHandlers,
createToolEndCallback,
createResponsesToolEndCallback,
};

View file

@ -1,22 +1,28 @@
require('events').EventEmitter.defaultMaxListeners = 100;
const { logger } = require('@librechat/data-schemas');
const { DynamicStructuredTool } = require('@langchain/core/tools');
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
const {
createRun,
Tokenizer,
checkAccess,
logAxiosError,
buildToolSet,
sanitizeTitle,
logToolError,
payloadParser,
resolveHeaders,
createSafeUser,
initializeAgent,
getBalanceConfig,
omitTitleOptions,
getProviderConfig,
memoryInstructions,
createTokenCounter,
applyContextToAgent,
recordCollectedUsage,
GenerationJobManager,
getTransactionsConfig,
createMemoryProcessor,
createMultiAgentMapper,
filterMalformedContentParts,
} = require('@librechat/api');
const {
@ -24,9 +30,7 @@ const {
Providers,
TitleMethod,
formatMessage,
labelContentByAgent,
formatAgentMessages,
getTokenCountForMessage,
createMetadataAggregator,
} = require('@librechat/agents');
const {
@ -38,11 +42,12 @@ const {
PermissionTypes,
isAgentsEndpoint,
isEphemeralAgentId,
bedrockInputSchema,
removeNullishValues,
} = require('librechat-data-provider');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { updateBalance, bulkInsertTransactions } = require('~/models');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
const { createContextHandlers } = require('~/app/clients/prompts');
const { getConvoFiles } = require('~/models/Conversation');
const BaseClient = require('~/app/clients/BaseClient');
@ -51,183 +56,6 @@ const { loadAgent } = require('~/models/Agent');
const { getMCPManager } = require('~/config');
const db = require('~/models');
const omitTitleOptions = new Set([
'stream',
'thinking',
'streaming',
'clientOptions',
'thinkingConfig',
'thinkingBudget',
'includeThoughts',
'maxOutputTokens',
'additionalModelRequestFields',
]);
/**
* @param {ServerRequest} req
* @param {Agent} agent
* @param {string} endpoint
*/
const payloadParser = ({ req, agent, endpoint }) => {
if (isAgentsEndpoint(endpoint)) {
return { model: undefined };
} else if (endpoint === EModelEndpoint.bedrock) {
const parsedValues = bedrockInputSchema.parse(agent.model_parameters);
if (parsedValues.thinking == null) {
parsedValues.thinking = false;
}
return parsedValues;
}
return req.body.endpointOption.model_parameters;
};
function createTokenCounter(encoding) {
return function (message) {
const countTokens = (text) => Tokenizer.getTokenCount(text, encoding);
return getTokenCountForMessage(message, countTokens);
};
}
function logToolError(graph, error, toolId) {
logAxiosError({
error,
message: `[api/server/controllers/agents/client.js #chatCompletion] Tool Error "${toolId}"`,
});
}
/** Regex pattern to match agent ID suffix (____N) */
const AGENT_SUFFIX_PATTERN = /____(\d+)$/;
/**
* Finds the primary agent ID within a set of agent IDs.
* Primary = no suffix (____N) or lowest suffix number.
* @param {Set<string>} agentIds
* @returns {string | null}
*/
function findPrimaryAgentId(agentIds) {
let primaryAgentId = null;
let lowestSuffixIndex = Infinity;
for (const agentId of agentIds) {
const suffixMatch = agentId.match(AGENT_SUFFIX_PATTERN);
if (!suffixMatch) {
return agentId;
}
const suffixIndex = parseInt(suffixMatch[1], 10);
if (suffixIndex < lowestSuffixIndex) {
lowestSuffixIndex = suffixIndex;
primaryAgentId = agentId;
}
}
return primaryAgentId;
}
/**
* Creates a mapMethod for getMessagesForConversation that processes agent content.
* - Strips agentId/groupId metadata from all content
* - For parallel agents (addedConvo with groupId): filters each group to its primary agent
* - For handoffs (agentId without groupId): keeps all content from all agents
* - For multi-agent: applies agent labels to content
*
* The key distinction:
* - Parallel execution (addedConvo): Parts have both agentId AND groupId
* - Handoffs: Parts only have agentId, no groupId
*
* @param {Agent} primaryAgent - Primary agent configuration
* @param {Map<string, Agent>} [agentConfigs] - Additional agent configurations
* @returns {(message: TMessage) => TMessage} Map method for processing messages
*/
function createMultiAgentMapper(primaryAgent, agentConfigs) {
const hasMultipleAgents = (primaryAgent.edges?.length ?? 0) > 0 || (agentConfigs?.size ?? 0) > 0;
/** @type {Record<string, string> | null} */
let agentNames = null;
if (hasMultipleAgents) {
agentNames = { [primaryAgent.id]: primaryAgent.name || 'Assistant' };
if (agentConfigs) {
for (const [agentId, agentConfig] of agentConfigs.entries()) {
agentNames[agentId] = agentConfig.name || agentConfig.id;
}
}
}
return (message) => {
if (message.isCreatedByUser || !Array.isArray(message.content)) {
return message;
}
// Check for metadata
const hasAgentMetadata = message.content.some((part) => part?.agentId || part?.groupId != null);
if (!hasAgentMetadata) {
return message;
}
try {
// Build a map of groupId -> Set of agentIds, to find primary per group
/** @type {Map<number, Set<string>>} */
const groupAgentMap = new Map();
for (const part of message.content) {
const groupId = part?.groupId;
const agentId = part?.agentId;
if (groupId != null && agentId) {
if (!groupAgentMap.has(groupId)) {
groupAgentMap.set(groupId, new Set());
}
groupAgentMap.get(groupId).add(agentId);
}
}
// For each group, find the primary agent
/** @type {Map<number, string>} */
const groupPrimaryMap = new Map();
for (const [groupId, agentIds] of groupAgentMap) {
const primary = findPrimaryAgentId(agentIds);
if (primary) {
groupPrimaryMap.set(groupId, primary);
}
}
/** @type {Array<TMessageContentParts>} */
const filteredContent = [];
/** @type {Record<number, string>} */
const agentIdMap = {};
for (const part of message.content) {
const agentId = part?.agentId;
const groupId = part?.groupId;
// Filtering logic:
// - No groupId (handoffs): always include
// - Has groupId (parallel): only include if it's the primary for that group
const isParallelPart = groupId != null;
const groupPrimary = isParallelPart ? groupPrimaryMap.get(groupId) : null;
const shouldInclude = !isParallelPart || !agentId || agentId === groupPrimary;
if (shouldInclude) {
const newIndex = filteredContent.length;
const { agentId: _a, groupId: _g, ...cleanPart } = part;
filteredContent.push(cleanPart);
if (agentId && hasMultipleAgents) {
agentIdMap[newIndex] = agentId;
}
}
}
const finalContent =
Object.keys(agentIdMap).length > 0 && agentNames
? labelContentByAgent(filteredContent, agentIdMap, agentNames)
: filteredContent;
return { ...message, content: finalContent };
} catch (error) {
logger.error('[AgentClient] Error processing multi-agent message:', error);
return message;
}
};
}
class AgentClient extends BaseClient {
constructor(options = {}) {
super(null, options);
@ -295,14 +123,9 @@ class AgentClient extends BaseClient {
checkVisionRequest() {}
getSaveOptions() {
// TODO:
// would need to be override settings; otherwise, model needs to be undefined
// model: this.override.model,
// instructions: this.override.instructions,
// additional_instructions: this.override.additional_instructions,
let runOptions = {};
try {
runOptions = payloadParser(this.options);
runOptions = payloadParser(this.options) ?? {};
} catch (error) {
logger.error(
'[api/server/controllers/agents/client.js #getSaveOptions] Error parsing options',
@ -313,14 +136,14 @@ class AgentClient extends BaseClient {
return removeNullishValues(
Object.assign(
{
spec: this.options.spec,
iconURL: this.options.iconURL,
endpoint: this.options.endpoint,
agent_id: this.options.agent.id,
modelLabel: this.options.modelLabel,
maxContextTokens: this.options.maxContextTokens,
resendFiles: this.options.resendFiles,
imageDetail: this.options.imageDetail,
spec: this.options.spec,
iconURL: this.options.iconURL,
maxContextTokens: this.maxContextTokens,
},
// TODO: PARSE OPTIONS BY PROVIDER, MAY CONTAIN SENSITIVE DATA
runOptions,
@ -328,11 +151,13 @@ class AgentClient extends BaseClient {
);
}
/**
* Returns build message options. For AgentClient, agent-specific instructions
* are retrieved directly from agent objects in buildMessages, so this returns empty.
* @returns {Object} Empty options object
*/
getBuildMessagesOptions() {
return {
instructions: this.options.agent.instructions,
additional_instructions: this.options.agent.additional_instructions,
};
return {};
}
/**
@ -355,12 +180,7 @@ class AgentClient extends BaseClient {
return files;
}
async buildMessages(
messages,
parentMessageId,
{ instructions = null, additional_instructions = null },
opts,
) {
async buildMessages(messages, parentMessageId, _buildOptions, opts) {
/** Always pass mapMethod; getMessagesForConversation applies it only to messages with addedConvo flag */
const orderedMessages = this.constructor.getMessagesForConversation({
messages,
@ -374,11 +194,29 @@ class AgentClient extends BaseClient {
/** @type {number | undefined} */
let promptTokens;
/** @type {string} */
let systemContent = [instructions ?? '', additional_instructions ?? '']
.filter(Boolean)
.join('\n')
.trim();
/**
* Extract base instructions for all agents (combines instructions + additional_instructions).
* This must be done before applying context to preserve the original agent configuration.
*/
const extractBaseInstructions = (agent) => {
const baseInstructions = [agent.instructions ?? '', agent.additional_instructions ?? '']
.filter(Boolean)
.join('\n')
.trim();
agent.instructions = baseInstructions;
return agent;
};
/** Collect all agents for unified processing, extracting base instructions during collection */
const allAgents = [
{ agent: extractBaseInstructions(this.options.agent), agentId: this.options.agent.id },
...(this.agentConfigs?.size > 0
? Array.from(this.agentConfigs.entries()).map(([agentId, agent]) => ({
agent: extractBaseInstructions(agent),
agentId,
}))
: []),
];
if (this.options.attachments) {
const attachments = await this.options.attachments;
@ -413,6 +251,7 @@ class AgentClient extends BaseClient {
assistantName: this.options?.modelLabel,
});
/** For non-latest messages, prepend file context directly to message content */
if (message.fileContext && i !== orderedMessages.length - 1) {
if (typeof formattedMessage.content === 'string') {
formattedMessage.content = message.fileContext + '\n' + formattedMessage.content;
@ -422,8 +261,6 @@ class AgentClient extends BaseClient {
? (textPart.text = message.fileContext + '\n' + textPart.text)
: formattedMessage.content.unshift({ type: 'text', text: message.fileContext });
}
} else if (message.fileContext && i === orderedMessages.length - 1) {
systemContent = [systemContent, message.fileContext].join('\n');
}
const needsTokenCount =
@ -456,46 +293,35 @@ class AgentClient extends BaseClient {
return formattedMessage;
});
/**
* Build shared run context - applies to ALL agents in the run.
* This includes: file context (latest message), augmented prompt (RAG), memory context.
*/
const sharedRunContextParts = [];
/** File context from the latest message (attachments) */
const latestMessage = orderedMessages[orderedMessages.length - 1];
if (latestMessage?.fileContext) {
sharedRunContextParts.push(latestMessage.fileContext);
}
/** Augmented prompt from RAG/context handlers */
if (this.contextHandlers) {
this.augmentedPrompt = await this.contextHandlers.createContext();
systemContent = this.augmentedPrompt + systemContent;
}
// Inject MCP server instructions if available
const ephemeralAgent = this.options.req.body.ephemeralAgent;
let mcpServers = [];
// Check for ephemeral agent MCP servers
if (ephemeralAgent && ephemeralAgent.mcp && ephemeralAgent.mcp.length > 0) {
mcpServers = ephemeralAgent.mcp;
}
// Check for regular agent MCP tools
else if (this.options.agent && this.options.agent.tools) {
mcpServers = this.options.agent.tools
.filter(
(tool) =>
tool instanceof DynamicStructuredTool && tool.name.includes(Constants.mcp_delimiter),
)
.map((tool) => tool.name.split(Constants.mcp_delimiter).pop())
.filter(Boolean);
}
if (mcpServers.length > 0) {
try {
const mcpInstructions = await getMCPManager().formatInstructionsForContext(mcpServers);
if (mcpInstructions) {
systemContent = [systemContent, mcpInstructions].filter(Boolean).join('\n\n');
logger.debug('[AgentClient] Injected MCP instructions for servers:', mcpServers);
}
} catch (error) {
logger.error('[AgentClient] Failed to inject MCP instructions:', error);
if (this.augmentedPrompt) {
sharedRunContextParts.push(this.augmentedPrompt);
}
}
if (systemContent) {
this.options.agent.instructions = systemContent;
/** Memory context (user preferences/memories) */
const withoutKeys = await this.useMemory();
if (withoutKeys) {
const memoryContext = `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`;
sharedRunContextParts.push(memoryContext);
}
const sharedRunContext = sharedRunContextParts.join('\n\n');
/** @type {Record<string, number> | undefined} */
let tokenCountMap;
@ -521,14 +347,27 @@ class AgentClient extends BaseClient {
opts.getReqData({ promptTokens });
}
const withoutKeys = await this.useMemory();
if (withoutKeys) {
systemContent += `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`;
}
if (systemContent) {
this.options.agent.instructions = systemContent;
}
/**
* Apply context to all agents.
* Each agent gets: shared run context + their own base instructions + their own MCP instructions.
*
* NOTE: This intentionally mutates agent objects in place. The agentConfigs Map
* holds references to config objects that will be passed to the graph runtime.
*/
const ephemeralAgent = this.options.req.body.ephemeralAgent;
const mcpManager = getMCPManager();
await Promise.all(
allAgents.map(({ agent, agentId }) =>
applyContextToAgent({
agent,
agentId,
logger,
mcpManager,
sharedRunContext,
ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined,
}),
),
);
return result;
}
@ -600,6 +439,8 @@ class AgentClient extends BaseClient {
agent_id: memoryConfig.agent.id,
endpoint: EModelEndpoint.agents,
});
} else if (memoryConfig.agent?.id != null) {
prelimAgent = this.options.agent;
} else if (
memoryConfig.agent?.id == null &&
memoryConfig.agent?.model != null &&
@ -614,6 +455,10 @@ class AgentClient extends BaseClient {
);
}
if (!prelimAgent) {
return;
}
const agent = await initializeAgent(
{
req: this.options.req,
@ -633,6 +478,7 @@ class AgentClient extends BaseClient {
updateFilesUsage: db.updateFilesUsage,
getUserKeyValues: db.getUserKeyValues,
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
},
);
@ -781,89 +627,29 @@ class AgentClient extends BaseClient {
context = 'message',
collectedUsage = this.collectedUsage,
}) {
if (!collectedUsage || !collectedUsage.length) {
return;
}
// Support both OpenAI format (input_token_details) and Anthropic format (cache_*_input_tokens)
const firstUsage = collectedUsage[0];
const input_tokens =
(firstUsage?.input_tokens || 0) +
(Number(firstUsage?.input_token_details?.cache_creation) ||
Number(firstUsage?.cache_creation_input_tokens) ||
0) +
(Number(firstUsage?.input_token_details?.cache_read) ||
Number(firstUsage?.cache_read_input_tokens) ||
0);
let output_tokens = 0;
let previousTokens = input_tokens; // Start with original input
for (let i = 0; i < collectedUsage.length; i++) {
const usage = collectedUsage[i];
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 = {
const result = await recordCollectedUsage(
{
spendTokens,
spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier },
bulkWriteOps: { insertMany: bulkInsertTransactions, updateBalance },
},
{
user: this.user ?? this.options.req.user?.id,
conversationId: this.conversationId,
collectedUsage,
model: model ?? this.model ?? this.options.agent.model_parameters.model,
context,
messageId: this.responseMessageId,
balance,
transactions,
conversationId: this.conversationId,
user: this.user ?? this.options.req.user?.id,
endpointTokenConfig: this.options.endpointTokenConfig,
model: usage.model ?? model ?? this.model ?? this.options.agent.model_parameters.model,
};
},
);
if (i > 0) {
// Count new tokens generated (input_tokens minus previous accumulated tokens)
output_tokens +=
(Number(usage.input_tokens) || 0) + cache_creation + cache_read - previousTokens;
}
// Add this message's output tokens
output_tokens += Number(usage.output_tokens) || 0;
// Update previousTokens to include this message's output
previousTokens += Number(usage.output_tokens) || 0;
if (cache_creation > 0 || cache_read > 0) {
spendStructuredTokens(txMetadata, {
promptTokens: {
input: usage.input_tokens,
write: cache_creation,
read: cache_read,
},
completionTokens: usage.output_tokens,
}).catch((err) => {
logger.error(
'[api/server/controllers/agents/client.js #recordCollectedUsage] Error spending structured tokens',
err,
);
});
continue;
}
spendTokens(txMetadata, {
promptTokens: usage.input_tokens,
completionTokens: usage.output_tokens,
}).catch((err) => {
logger.error(
'[api/server/controllers/agents/client.js #recordCollectedUsage] Error spending tokens',
err,
);
});
if (result) {
this.usage = result;
}
this.usage = {
input_tokens,
output_tokens,
};
}
/**
@ -952,13 +738,13 @@ class AgentClient extends BaseClient {
},
user: createSafeUser(this.options.req.user),
},
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
recursionLimit: agentsEConfig?.recursionLimit ?? 50,
signal: abortController.signal,
streamMode: 'values',
version: 'v2',
};
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
const toolSet = buildToolSet(this.options.agent);
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
payload,
this.indexTokenCountMap,
@ -1019,6 +805,7 @@ class AgentClient extends BaseClient {
run = await createRun({
agents,
messages,
indexTokenCountMap,
runId: this.responseMessageId,
signal: abortController.signal,
@ -1054,9 +841,10 @@ class AgentClient extends BaseClient {
config.signal = null;
};
const hideSequentialOutputs = config.configurable.hide_sequential_outputs;
await runAgents(initialMessages);
/** @deprecated Agent Chain */
if (config.configurable.hide_sequential_outputs) {
if (hideSequentialOutputs) {
this.contentParts = this.contentParts.filter((part, index) => {
// Include parts that are either:
// 1. At or after the finalContentStart index
@ -1091,11 +879,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',
@ -1120,6 +917,14 @@ class AgentClient extends BaseClient {
}
const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator();
const { req, agent } = this.options;
if (req?.body?.isTemporary) {
logger.debug(
`[api/server/controllers/agents/client.js #titleConvo] Skipping title generation for temporary conversation`,
);
return;
}
const appConfig = req.config;
let endpoint = agent.endpoint;
@ -1293,6 +1098,7 @@ class AgentClient extends BaseClient {
model: clientOptions.model,
balance: balanceConfig,
transactions: transactionsConfig,
messageId: this.responseMessageId,
}).catch((err) => {
logger.error(
'[api/server/controllers/agents/client.js #titleConvo] Error recording collected usage',
@ -1331,6 +1137,7 @@ class AgentClient extends BaseClient {
model,
context,
balance,
messageId: this.responseMessageId,
conversationId: this.conversationId,
user: this.user ?? this.options.req.user?.id,
endpointTokenConfig: this.options.endpointTokenConfig,
@ -1349,6 +1156,7 @@ class AgentClient extends BaseClient {
model,
balance,
context: 'reasoning',
messageId: this.responseMessageId,
conversationId: this.conversationId,
user: this.user ?? this.options.req.user?.id,
endpointTokenConfig: this.options.endpointTokenConfig,
@ -1364,7 +1172,11 @@ class AgentClient extends BaseClient {
}
}
/** Anthropic Claude models use a distinct BPE tokenizer; all others default to o200k_base. */
getEncoding() {
if (this.model && this.model.toLowerCase().includes('claude')) {
return 'claude';
}
return 'o200k_base';
}

View file

@ -12,6 +12,17 @@ jest.mock('@librechat/agents', () => ({
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
checkAccess: jest.fn(),
initializeAgent: jest.fn(),
createMemoryProcessor: jest.fn(),
}));
jest.mock('~/models/Agent', () => ({
loadAgent: jest.fn(),
}));
jest.mock('~/models/Role', () => ({
getRoleByName: jest.fn(),
}));
// Mock getMCPManager
@ -252,6 +263,7 @@ describe('AgentClient - titleConvo', () => {
transactions: {
enabled: true,
},
messageId: 'response-123',
});
});
@ -336,6 +348,25 @@ describe('AgentClient - titleConvo', () => {
expect(client.recordCollectedUsage).not.toHaveBeenCalled();
});
it('should skip title generation for temporary chats', async () => {
// Set isTemporary to true
mockReq.body.isTemporary = true;
const text = 'Test temporary chat';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return undefined without generating title
expect(result).toBeUndefined();
// generateTitle should NOT have been called
expect(mockRun.generateTitle).not.toHaveBeenCalled();
// recordCollectedUsage should NOT have been called
expect(client.recordCollectedUsage).not.toHaveBeenCalled();
});
it('should skip title generation when titleConvo is false in all config', async () => {
// Set titleConvo to false in "all" config
mockReq.config = {
@ -1291,8 +1322,8 @@ describe('AgentClient - titleConvo', () => {
expect(client.options.agent.instructions).toContain('# MCP Server Instructions');
expect(client.options.agent.instructions).toContain('Use these tools carefully');
// Verify the base instructions are also included
expect(client.options.agent.instructions).toContain('Base instructions');
// Verify the base instructions are also included (from agent config, not buildOptions)
expect(client.options.agent.instructions).toContain('Base agent instructions');
});
it('should handle MCP instructions with ephemeral agent', async () => {
@ -1354,8 +1385,8 @@ describe('AgentClient - titleConvo', () => {
additional_instructions: null,
});
// Verify the instructions still work without MCP content
expect(client.options.agent.instructions).toBe('Base instructions only');
// Verify the instructions still work without MCP content (from agent config, not buildOptions)
expect(client.options.agent.instructions).toBe('Base agent instructions');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
@ -1379,8 +1410,8 @@ describe('AgentClient - titleConvo', () => {
additional_instructions: null,
});
// Should still have base instructions without MCP content
expect(client.options.agent.instructions).toContain('Base instructions');
// Should still have base instructions without MCP content (from agent config, not buildOptions)
expect(client.options.agent.instructions).toContain('Base agent instructions');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
});
@ -1830,4 +1861,400 @@ 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();
// Verify primary agent has its configured instructions (not from buildOptions) and memory context
expect(client.options.agent.instructions).toContain('Primary agent 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);
});
});
describe('useMemory method - prelimAgent assignment', () => {
let client;
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
let mockCheckAccess;
let mockLoadAgent;
let mockInitializeAgent;
let mockCreateMemoryProcessor;
beforeEach(() => {
jest.clearAllMocks();
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Test instructions',
model: 'gpt-4',
model_parameters: {
model: 'gpt-4',
},
};
mockReq = {
user: {
id: 'user-123',
personalization: {
memories: true,
},
},
config: {
memory: {
agent: {
id: 'agent-123',
},
},
endpoints: {
[EModelEndpoint.agents]: {
allowedProviders: [EModelEndpoint.openAI],
},
},
},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
};
mockCheckAccess = require('@librechat/api').checkAccess;
mockLoadAgent = require('~/models/Agent').loadAgent;
mockInitializeAgent = require('@librechat/api').initializeAgent;
mockCreateMemoryProcessor = require('@librechat/api').createMemoryProcessor;
});
it('should use current agent when memory config agent.id matches current agent id', async () => {
mockCheckAccess.mockResolvedValue(true);
mockInitializeAgent.mockResolvedValue({
...mockAgent,
provider: EModelEndpoint.openAI,
});
mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]);
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
await client.useMemory();
expect(mockLoadAgent).not.toHaveBeenCalled();
expect(mockInitializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent: mockAgent,
}),
expect.any(Object),
);
});
it('should load different agent when memory config agent.id differs from current agent id', async () => {
const differentAgentId = 'different-agent-456';
const differentAgent = {
id: differentAgentId,
provider: EModelEndpoint.openAI,
model: 'gpt-4',
instructions: 'Different agent instructions',
};
mockReq.config.memory.agent.id = differentAgentId;
mockCheckAccess.mockResolvedValue(true);
mockLoadAgent.mockResolvedValue(differentAgent);
mockInitializeAgent.mockResolvedValue({
...differentAgent,
provider: EModelEndpoint.openAI,
});
mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]);
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
await client.useMemory();
expect(mockLoadAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent_id: differentAgentId,
}),
);
expect(mockInitializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent: differentAgent,
}),
expect.any(Object),
);
});
it('should return early when prelimAgent is undefined (no valid memory agent config)', async () => {
mockReq.config.memory = {
agent: {},
};
mockCheckAccess.mockResolvedValue(true);
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
const result = await client.useMemory();
expect(result).toBeUndefined();
expect(mockInitializeAgent).not.toHaveBeenCalled();
expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
});
it('should create ephemeral agent when no id but model and provider are specified', async () => {
mockReq.config.memory = {
agent: {
model: 'gpt-4',
provider: EModelEndpoint.openAI,
},
};
mockCheckAccess.mockResolvedValue(true);
mockInitializeAgent.mockResolvedValue({
id: Constants.EPHEMERAL_AGENT_ID,
model: 'gpt-4',
provider: EModelEndpoint.openAI,
});
mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]);
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
await client.useMemory();
expect(mockLoadAgent).not.toHaveBeenCalled();
expect(mockInitializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent: expect.objectContaining({
id: Constants.EPHEMERAL_AGENT_ID,
model: 'gpt-4',
provider: EModelEndpoint.openAI,
}),
}),
expect.any(Object),
);
});
});
});

View file

@ -0,0 +1,713 @@
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents');
const { EModelEndpoint, ResourceType, PermissionBits } = require('librechat-data-provider');
const {
writeSSE,
createRun,
createChunk,
buildToolSet,
sendFinalChunk,
createSafeUser,
validateRequest,
initializeAgent,
getBalanceConfig,
createErrorResponse,
recordCollectedUsage,
getTransactionsConfig,
createToolExecuteHandler,
buildNonStreamingResponse,
createOpenAIStreamTracker,
createOpenAIContentAggregator,
isChatCompletionValidationFailure,
} = require('@librechat/api');
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { createToolEndCallback } = require('~/server/controllers/agents/callbacks');
const { findAccessibleResources } = require('~/server/services/PermissionService');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
const { getConvoFiles } = require('~/models/Conversation');
const { getAgent, getAgents } = require('~/models/Agent');
const db = require('~/models');
/**
* Creates a tool loader function for the agent.
* @param {AbortSignal} signal - The abort signal
* @param {boolean} [definitionsOnly=true] - When true, returns only serializable
* tool definitions without creating full tool instances (for event-driven mode)
*/
function createToolLoader(signal, definitionsOnly = true) {
return async function loadTools({
req,
res,
tools,
model,
agentId,
provider,
tool_options,
tool_resources,
}) {
const agent = { id: agentId, tools, provider, model, tool_options };
try {
return await loadAgentTools({
req,
res,
agent,
signal,
tool_resources,
definitionsOnly,
streamId: null, // No resumable stream for OpenAI compat
});
} catch (error) {
logger.error('Error loading tools for agent ' + agentId, error);
}
};
}
/**
* Convert content part to internal format
* @param {Object} part - Content part
* @returns {Object} Converted part
*/
function convertContentPart(part) {
if (part.type === 'text') {
return { type: 'text', text: part.text };
}
if (part.type === 'image_url') {
return { type: 'image_url', image_url: part.image_url };
}
return part;
}
/**
* Convert OpenAI messages to internal format
* @param {Array} messages - OpenAI format messages
* @returns {Array} Internal format messages
*/
function convertMessages(messages) {
return messages.map((msg) => {
let content;
if (typeof msg.content === 'string') {
content = msg.content;
} else if (msg.content) {
content = msg.content.map(convertContentPart);
} else {
content = '';
}
return {
role: msg.role,
content,
...(msg.name && { name: msg.name }),
...(msg.tool_calls && { tool_calls: msg.tool_calls }),
...(msg.tool_call_id && { tool_call_id: msg.tool_call_id }),
};
});
}
/**
* Send an error response in OpenAI format
*/
function sendErrorResponse(res, statusCode, message, type = 'invalid_request_error', code = null) {
res.status(statusCode).json(createErrorResponse(message, type, code));
}
/**
* OpenAI-compatible chat completions controller for agents.
*
* POST /v1/chat/completions
*
* Request format:
* {
* "model": "agent_id_here",
* "messages": [{"role": "user", "content": "Hello!"}],
* "stream": true,
* "conversation_id": "optional",
* "parent_message_id": "optional"
* }
*/
const OpenAIChatCompletionController = async (req, res) => {
const appConfig = req.config;
const requestStartTime = Date.now();
const validation = validateRequest(req.body);
if (isChatCompletionValidationFailure(validation)) {
return sendErrorResponse(res, 400, validation.error);
}
const request = validation.request;
const agentId = request.model;
// Look up the agent
const agent = await getAgent({ id: agentId });
if (!agent) {
return sendErrorResponse(
res,
404,
`Agent not found: ${agentId}`,
'invalid_request_error',
'model_not_found',
);
}
const responseId = `chatcmpl-${nanoid()}`;
const conversationId = request.conversation_id ?? nanoid();
const parentMessageId = request.parent_message_id ?? null;
const created = Math.floor(Date.now() / 1000);
/** @type {import('@librechat/api').OpenAIResponseContext} — key must be `requestId` to match the type used by createChunk/buildNonStreamingResponse */
const context = {
created,
requestId: responseId,
model: agentId,
};
logger.debug(
`[OpenAI API] Response ${responseId} started for agent ${agentId}, stream: ${request.stream}`,
);
// Set up abort controller
const abortController = new AbortController();
// Handle client disconnect
req.on('close', () => {
if (!abortController.signal.aborted) {
abortController.abort();
logger.debug('[OpenAI API] Client disconnected, aborting');
}
});
try {
// Build allowed providers set
const allowedProviders = new Set(
appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders,
);
// Create tool loader
const loadTools = createToolLoader(abortController.signal);
// Initialize the agent first to check for disableStreaming
const endpointOption = {
endpoint: agent.provider,
model_parameters: agent.model_parameters ?? {},
};
const primaryConfig = await initializeAgent(
{
req,
res,
loadTools,
requestFiles: [],
conversationId,
parentMessageId,
agent,
endpointOption,
allowedProviders,
isInitialAgent: true,
},
{
getConvoFiles,
getFiles: db.getFiles,
getUserKey: db.getUserKey,
getMessages: db.getMessages,
updateFilesUsage: db.updateFilesUsage,
getUserKeyValues: db.getUserKeyValues,
getUserCodeFiles: db.getUserCodeFiles,
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
},
);
// Determine if streaming is enabled (check both request and agent config)
const streamingDisabled = !!primaryConfig.model_parameters?.disableStreaming;
const isStreaming = request.stream === true && !streamingDisabled;
// Create tracker for streaming or aggregator for non-streaming
const tracker = isStreaming ? createOpenAIStreamTracker() : null;
const aggregator = isStreaming ? null : createOpenAIContentAggregator();
// Set up response for streaming
if (isStreaming) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// Send initial chunk with role
const initialChunk = createChunk(context, { role: 'assistant' });
writeSSE(res, initialChunk);
}
// Create handler config for OpenAI streaming (only used when streaming)
const handlerConfig = isStreaming
? {
res,
context,
tracker,
}
: null;
const collectedUsage = [];
/** @type {Promise<import('librechat-data-provider').TAttachment | null>[]} */
const artifactPromises = [];
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null });
const toolExecuteOptions = {
loadTools: async (toolNames) => {
return loadToolsForExecution({
req,
res,
agent,
toolNames,
signal: abortController.signal,
toolRegistry: primaryConfig.toolRegistry,
userMCPAuthMap: primaryConfig.userMCPAuthMap,
tool_resources: primaryConfig.tool_resources,
});
},
toolEndCallback,
};
const openaiMessages = convertMessages(request.messages);
const toolSet = buildToolSet(primaryConfig);
const { messages: formattedMessages, indexTokenCountMap } = formatAgentMessages(
openaiMessages,
{},
toolSet,
);
/**
* Create a simple handler that processes data
*/
const createHandler = (processor) => ({
handle: (_event, data) => {
if (processor) {
processor(data);
}
},
});
/**
* Stream text content in OpenAI format
*/
const streamText = (text) => {
if (!text) {
return;
}
if (isStreaming) {
tracker.addText();
writeSSE(res, createChunk(context, { content: text }));
} else {
aggregator.addText(text);
}
};
/**
* Stream reasoning content in OpenAI format (OpenRouter convention)
*/
const streamReasoning = (text) => {
if (!text) {
return;
}
if (isStreaming) {
tracker.addReasoning();
writeSSE(res, createChunk(context, { reasoning: text }));
} else {
aggregator.addReasoning(text);
}
};
// Event handlers for OpenAI-compatible streaming
const handlers = {
// Text content streaming
on_message_delta: createHandler((data) => {
const content = data?.delta?.content;
if (Array.isArray(content)) {
for (const part of content) {
if (part.type === 'text' && part.text) {
streamText(part.text);
}
}
}
}),
// Reasoning/thinking content streaming
on_reasoning_delta: createHandler((data) => {
const content = data?.delta?.content;
if (Array.isArray(content)) {
for (const part of content) {
const text = part.think || part.text;
if (text) {
streamReasoning(text);
}
}
}
}),
// Tool call initiation - streams id and name (from on_run_step)
on_run_step: createHandler((data) => {
const stepDetails = data?.stepDetails;
if (stepDetails?.type === 'tool_calls' && stepDetails.tool_calls) {
for (const tc of stepDetails.tool_calls) {
const toolIndex = data.index ?? 0;
const toolId = tc.id ?? '';
const toolName = tc.name ?? '';
const toolCall = {
id: toolId,
type: 'function',
function: { name: toolName, arguments: '' },
};
// Track tool call in tracker or aggregator
if (isStreaming) {
if (!tracker.toolCalls.has(toolIndex)) {
tracker.toolCalls.set(toolIndex, toolCall);
}
// Stream initial tool call chunk (like OpenAI does)
writeSSE(
res,
createChunk(context, {
tool_calls: [{ index: toolIndex, ...toolCall }],
}),
);
} else {
if (!aggregator.toolCalls.has(toolIndex)) {
aggregator.toolCalls.set(toolIndex, toolCall);
}
}
}
}
}),
// Tool call argument streaming (from on_run_step_delta)
on_run_step_delta: createHandler((data) => {
const delta = data?.delta;
if (delta?.type === 'tool_calls' && delta.tool_calls) {
for (const tc of delta.tool_calls) {
const args = tc.args ?? '';
if (!args) {
continue;
}
const toolIndex = tc.index ?? 0;
// Update tool call arguments
const targetMap = isStreaming ? tracker.toolCalls : aggregator.toolCalls;
const tracked = targetMap.get(toolIndex);
if (tracked) {
tracked.function.arguments += args;
}
// Stream argument delta (only for streaming)
if (isStreaming) {
writeSSE(
res,
createChunk(context, {
tool_calls: [
{
index: toolIndex,
function: { arguments: args },
},
],
}),
);
}
}
}
}),
// Usage tracking
on_chat_model_end: createHandler((data) => {
const usage = data?.output?.usage_metadata;
if (usage) {
collectedUsage.push(usage);
const target = isStreaming ? tracker : aggregator;
target.usage.promptTokens += usage.input_tokens ?? 0;
target.usage.completionTokens += usage.output_tokens ?? 0;
}
}),
on_run_step_completed: createHandler(),
// Use proper ToolEndHandler for processing artifacts (images, file citations, code output)
on_tool_end: new ToolEndHandler(toolEndCallback, logger),
on_chain_stream: createHandler(),
on_chain_end: createHandler(),
on_agent_update: createHandler(),
on_custom_event: createHandler(),
// Event-driven tool execution handler
on_tool_execute: createToolExecuteHandler(toolExecuteOptions),
};
// Create and run the agent
const userId = req.user?.id ?? 'api-user';
// Extract userMCPAuthMap from primaryConfig (needed for MCP tool connections)
const userMCPAuthMap = primaryConfig.userMCPAuthMap;
const run = await createRun({
agents: [primaryConfig],
messages: formattedMessages,
indexTokenCountMap,
runId: responseId,
signal: abortController.signal,
customHandlers: handlers,
requestBody: {
messageId: responseId,
conversationId,
},
user: { id: userId },
});
if (!run) {
throw new Error('Failed to create agent run');
}
// Process the stream
const config = {
runName: 'AgentRun',
configurable: {
thread_id: conversationId,
user_id: userId,
user: createSafeUser(req.user),
requestBody: {
messageId: responseId,
conversationId,
},
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
signal: abortController.signal,
streamMode: 'values',
version: 'v2',
};
await run.processStream({ messages: formattedMessages }, config, {
callbacks: {
[Callback.TOOL_ERROR]: (graph, error, toolId) => {
logger.error(`[OpenAI API] Tool Error "${toolId}"`, error);
},
},
});
// Record token usage against balance
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
recordCollectedUsage(
{
spendTokens,
spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
},
{
user: userId,
conversationId,
collectedUsage,
context: 'message',
messageId: responseId,
balance: balanceConfig,
transactions: transactionsConfig,
model: primaryConfig.model || agent.model_parameters?.model,
},
).catch((err) => {
logger.error('[OpenAI API] Error recording usage:', err);
});
// Finalize response
const duration = Date.now() - requestStartTime;
if (isStreaming) {
sendFinalChunk(handlerConfig);
res.end();
logger.debug(`[OpenAI API] Response ${responseId} completed in ${duration}ms (streaming)`);
// Wait for artifact processing after response ends (non-blocking)
if (artifactPromises.length > 0) {
Promise.all(artifactPromises).catch((artifactError) => {
logger.warn('[OpenAI API] Error processing artifacts:', artifactError);
});
}
} else {
// For non-streaming, wait for artifacts before sending response
if (artifactPromises.length > 0) {
try {
await Promise.all(artifactPromises);
} catch (artifactError) {
logger.warn('[OpenAI API] Error processing artifacts:', artifactError);
}
}
// Build usage from aggregated data
const usage = {
prompt_tokens: aggregator.usage.promptTokens,
completion_tokens: aggregator.usage.completionTokens,
total_tokens: aggregator.usage.promptTokens + aggregator.usage.completionTokens,
};
if (aggregator.usage.reasoningTokens > 0) {
usage.completion_tokens_details = {
reasoning_tokens: aggregator.usage.reasoningTokens,
};
}
const response = buildNonStreamingResponse(
context,
aggregator.getText(),
aggregator.getReasoning(),
aggregator.toolCalls,
usage,
);
res.json(response);
logger.debug(
`[OpenAI API] Response ${responseId} completed in ${duration}ms (non-streaming)`,
);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An error occurred';
logger.error('[OpenAI API] Error:', error);
// Check if we already started streaming (headers sent)
if (res.headersSent) {
// Headers already sent, send error in stream
const errorChunk = createChunk(context, { content: `\n\nError: ${errorMessage}` }, 'stop');
writeSSE(res, errorChunk);
writeSSE(res, '[DONE]');
res.end();
} else {
// Forward upstream provider status codes (e.g., Anthropic 400s) instead of masking as 500
const statusCode =
typeof error?.status === 'number' && error.status >= 400 && error.status < 600
? error.status
: 500;
const errorType =
statusCode >= 400 && statusCode < 500 ? 'invalid_request_error' : 'server_error';
sendErrorResponse(res, statusCode, errorMessage, errorType);
}
}
};
/**
* List available agents as models (filtered by remote access permissions)
*
* GET /v1/models
*/
const ListModelsController = async (req, res) => {
try {
const userId = req.user?.id;
const userRole = req.user?.role;
if (!userId) {
return sendErrorResponse(res, 401, 'Authentication required', 'auth_error');
}
// Find agents the user has remote access to (VIEW permission on REMOTE_AGENT)
const accessibleAgentIds = await findAccessibleResources({
userId,
role: userRole,
resourceType: ResourceType.REMOTE_AGENT,
requiredPermissions: PermissionBits.VIEW,
});
// Get the accessible agents
let agents = [];
if (accessibleAgentIds.length > 0) {
agents = await getAgents({ _id: { $in: accessibleAgentIds } });
}
const models = agents.map((agent) => ({
id: agent.id,
object: 'model',
created: Math.floor(new Date(agent.createdAt || Date.now()).getTime() / 1000),
owned_by: 'librechat',
permission: [],
root: agent.id,
parent: null,
// LibreChat extensions
name: agent.name,
description: agent.description,
provider: agent.provider,
}));
res.json({
object: 'list',
data: models,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to list models';
logger.error('[OpenAI API] Error listing models:', error);
sendErrorResponse(res, 500, errorMessage, 'server_error');
}
};
/**
* Get a specific model/agent (with remote access permission check)
*
* GET /v1/models/:model
*/
const GetModelController = async (req, res) => {
try {
const { model } = req.params;
const userId = req.user?.id;
const userRole = req.user?.role;
if (!userId) {
return sendErrorResponse(res, 401, 'Authentication required', 'auth_error');
}
const agent = await getAgent({ id: model });
if (!agent) {
return sendErrorResponse(
res,
404,
`Model not found: ${model}`,
'invalid_request_error',
'model_not_found',
);
}
// Check if user has remote access to this agent
const accessibleAgentIds = await findAccessibleResources({
userId,
role: userRole,
resourceType: ResourceType.REMOTE_AGENT,
requiredPermissions: PermissionBits.VIEW,
});
const hasAccess = accessibleAgentIds.some((id) => id.toString() === agent._id.toString());
if (!hasAccess) {
return sendErrorResponse(
res,
403,
`No remote access to model: ${model}`,
'permission_error',
'access_denied',
);
}
res.json({
id: agent.id,
object: 'model',
created: Math.floor(new Date(agent.createdAt || Date.now()).getTime() / 1000),
owned_by: 'librechat',
permission: [],
root: agent.id,
parent: null,
// LibreChat extensions
name: agent.name,
description: agent.description,
provider: agent.provider,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to get model';
logger.error('[OpenAI API] Error getting model:', error);
sendErrorResponse(res, 500, errorMessage, 'server_error');
}
};
module.exports = {
OpenAIChatCompletionController,
ListModelsController,
GetModelController,
};

View file

@ -0,0 +1,364 @@
/**
* Tests for AgentClient.recordCollectedUsage
*
* This is a critical function that handles token spending for agent LLM calls.
* The client now delegates to the TS recordCollectedUsage from @librechat/api,
* passing pricing and bulkWriteOps deps.
*/
const { EModelEndpoint } = require('librechat-data-provider');
const mockSpendTokens = jest.fn().mockResolvedValue();
const mockSpendStructuredTokens = jest.fn().mockResolvedValue();
const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
const mockRecordCollectedUsage = jest
.fn()
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
jest.mock('~/models/spendTokens', () => ({
spendTokens: (...args) => mockSpendTokens(...args),
spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
}));
jest.mock('~/models/tx', () => ({
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('~/models', () => ({
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
}));
jest.mock('~/config', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
},
getMCPManager: jest.fn(() => ({
formatInstructionsForContext: jest.fn(),
})),
}));
jest.mock('@librechat/agents', () => ({
...jest.requireActual('@librechat/agents'),
createMetadataAggregator: () => ({
handleLLMEnd: jest.fn(),
collected: [],
}),
}));
jest.mock('@librechat/api', () => {
const actual = jest.requireActual('@librechat/api');
return {
...actual,
recordCollectedUsage: (...args) => mockRecordCollectedUsage(...args),
};
});
const AgentClient = require('./client');
describe('AgentClient - recordCollectedUsage', () => {
let client;
let mockAgent;
let mockOptions;
beforeEach(() => {
jest.clearAllMocks();
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: {
model: 'gpt-4',
},
};
mockOptions = {
req: {
user: { id: 'user-123' },
body: { model: 'gpt-4', endpoint: EModelEndpoint.openAI },
},
res: {},
agent: mockAgent,
endpointTokenConfig: {},
};
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.user = 'user-123';
});
describe('basic functionality', () => {
it('should delegate to recordCollectedUsage with full deps', async () => {
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
const [deps, params] = mockRecordCollectedUsage.mock.calls[0];
expect(deps).toHaveProperty('spendTokens');
expect(deps).toHaveProperty('spendStructuredTokens');
expect(deps).toHaveProperty('pricing');
expect(deps.pricing).toHaveProperty('getMultiplier');
expect(deps.pricing).toHaveProperty('getCacheMultiplier');
expect(deps).toHaveProperty('bulkWriteOps');
expect(deps.bulkWriteOps).toHaveProperty('insertMany');
expect(deps.bulkWriteOps).toHaveProperty('updateBalance');
expect(params).toEqual(
expect.objectContaining({
user: 'user-123',
conversationId: 'convo-123',
collectedUsage,
context: 'message',
balance: { enabled: true },
transactions: { enabled: true },
}),
);
});
it('should not set this.usage if collectedUsage is empty (returns undefined)', async () => {
mockRecordCollectedUsage.mockResolvedValue(undefined);
await client.recordCollectedUsage({
collectedUsage: [],
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage).toBeUndefined();
});
it('should not set this.usage if collectedUsage is null (returns undefined)', async () => {
mockRecordCollectedUsage.mockResolvedValue(undefined);
await client.recordCollectedUsage({
collectedUsage: null,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage).toBeUndefined();
});
it('should set this.usage from recordCollectedUsage result', async () => {
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 75 });
const collectedUsage = [{ input_tokens: 200, output_tokens: 75, model: 'gpt-4' }];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage).toEqual({ input_tokens: 200, output_tokens: 75 });
});
});
describe('sequential execution (single agent with tool calls)', () => {
it('should pass all usage entries to recordCollectedUsage', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 150, output_tokens: 30, model: 'gpt-4' },
{ input_tokens: 180, output_tokens: 20, model: 'gpt-4' },
];
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 100 });
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
const [, params] = mockRecordCollectedUsage.mock.calls[0];
expect(params.collectedUsage).toHaveLength(3);
expect(client.usage.output_tokens).toBe(100);
expect(client.usage.input_tokens).toBe(100);
});
});
describe('parallel execution (multiple agents)', () => {
it('should pass parallel agent usage to recordCollectedUsage', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 80, output_tokens: 40, model: 'gpt-4' },
];
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 90 });
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(client.usage.output_tokens).toBe(90);
expect(client.usage.output_tokens).toBeGreaterThan(0);
});
/** Bug regression: parallel agents where second agent has LOWER input tokens produced negative output via incremental calculation. */
it('should NOT produce negative output_tokens', async () => {
const collectedUsage = [
{ input_tokens: 200, output_tokens: 100, model: 'gpt-4' },
{ input_tokens: 50, output_tokens: 30, model: 'gpt-4' },
];
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 130 });
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage.output_tokens).toBeGreaterThan(0);
expect(client.usage.output_tokens).toBe(130);
});
});
describe('real-world scenarios', () => {
it('should correctly handle sequential tool calls with growing context', async () => {
const collectedUsage = [
{ input_tokens: 31596, output_tokens: 151, model: 'claude-opus-4-5-20251101' },
{ input_tokens: 35368, output_tokens: 150, model: 'claude-opus-4-5-20251101' },
{ input_tokens: 58362, output_tokens: 295, model: 'claude-opus-4-5-20251101' },
{ input_tokens: 112604, output_tokens: 193, model: 'claude-opus-4-5-20251101' },
{ input_tokens: 257440, output_tokens: 2217, model: 'claude-opus-4-5-20251101' },
];
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 31596, output_tokens: 3006 });
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage.input_tokens).toBe(31596);
expect(client.usage.output_tokens).toBe(3006);
});
it('should correctly handle cache tokens', async () => {
const collectedUsage = [
{
input_tokens: 788,
output_tokens: 163,
input_token_details: { cache_read: 0, cache_creation: 30808 },
model: 'claude-opus-4-5-20251101',
},
];
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 31596, output_tokens: 163 });
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage.input_tokens).toBe(31596);
expect(client.usage.output_tokens).toBe(163);
});
});
describe('model fallback', () => {
it('should use param model when available', async () => {
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
await client.recordCollectedUsage({
model: 'param-model',
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
const [, params] = mockRecordCollectedUsage.mock.calls[0];
expect(params.model).toBe('param-model');
});
it('should fallback to client.model when param model is missing', async () => {
client.model = 'client-model';
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
const [, params] = mockRecordCollectedUsage.mock.calls[0];
expect(params.model).toBe('client-model');
});
it('should fallback to agent model_parameters.model as last resort', async () => {
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
const [, params] = mockRecordCollectedUsage.mock.calls[0];
expect(params.model).toBe('gpt-4');
});
});
describe('getStreamUsage integration', () => {
it('should return the usage object set by recordCollectedUsage', async () => {
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
const usage = client.getStreamUsage();
expect(usage).toEqual({ input_tokens: 100, output_tokens: 50 });
});
it('should return undefined before recordCollectedUsage is called', () => {
const usage = client.getStreamUsage();
expect(usage).toBeUndefined();
});
/** Verifies usage passes the check in BaseClient.sendMessage: if (usage != null && Number(usage[this.outputTokensKey]) > 0) */
it('should have output_tokens > 0 for BaseClient.sendMessage check', async () => {
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 130 });
const collectedUsage = [
{ input_tokens: 200, output_tokens: 100, model: 'gpt-4' },
{ input_tokens: 50, output_tokens: 30, model: 'gpt-4' },
];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
const usage = client.getStreamUsage();
expect(usage).not.toBeNull();
expect(Number(usage.output_tokens)).toBeGreaterThan(0);
});
});
});

View file

@ -3,9 +3,9 @@ const { Constants, ViolationTypes } = require('librechat-data-provider');
const {
sendEvent,
getViolationInfo,
buildMessageFiles,
GenerationJobManager,
decrementPendingRequest,
sanitizeFileForTransmit,
sanitizeMessageForTransmit,
checkAndIncrementPendingRequest,
} = require('@librechat/api');
@ -67,7 +67,15 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
let client = null;
try {
logger.debug(`[ResumableAgentController] Creating job`, {
streamId,
conversationId,
reqConversationId,
userId,
});
const job = await GenerationJobManager.createJob(streamId, userId, conversationId);
const jobCreatedAt = job.createdAt; // Capture creation time to detect job replacement
req._resumableStreamId = streamId;
// Send JSON response IMMEDIATELY so client can connect to SSE stream
@ -244,13 +252,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
if (req.body.files && client.options?.attachments) {
userMessage.files = [];
const messageFiles = new Set(req.body.files.map((file) => file.file_id));
for (const attachment of client.options.attachments) {
if (messageFiles.has(attachment.file_id)) {
userMessage.files.push(sanitizeFileForTransmit(attachment));
}
if (req.body.files && Array.isArray(client.options.attachments)) {
const files = buildMessageFiles(req.body.files, client.options.attachments);
if (files.length > 0) {
userMessage.files = files;
}
delete userMessage.image_urls;
}
@ -272,6 +277,33 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
});
}
// CRITICAL: Save response message BEFORE emitting final event.
// This prevents race conditions where the client sends a follow-up message
// before the response is saved to the database, causing orphaned parentMessageIds.
if (client.savedMessageIds && !client.savedMessageIds.has(messageId)) {
await saveMessage(
req,
{ ...response, user: userId, unfinished: wasAbortedBeforeComplete },
{ context: 'api/server/controllers/agents/request.js - resumable response end' },
);
}
// Check if our job was replaced by a new request before emitting
// This prevents stale requests from emitting events to newer jobs
const currentJob = await GenerationJobManager.getJob(streamId);
const jobWasReplaced = !currentJob || currentJob.createdAt !== jobCreatedAt;
if (jobWasReplaced) {
logger.debug(`[ResumableAgentController] Skipping FINAL emit - job was replaced`, {
streamId,
originalCreatedAt: jobCreatedAt,
currentCreatedAt: currentJob?.createdAt,
});
// Still decrement pending request since we incremented at start
await decrementPendingRequest(userId);
return;
}
if (!wasAbortedBeforeComplete) {
const finalEvent = {
final: true,
@ -281,27 +313,35 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
responseMessage: { ...response },
};
GenerationJobManager.emitDone(streamId, finalEvent);
logger.debug(`[ResumableAgentController] Emitting FINAL event`, {
streamId,
wasAbortedBeforeComplete,
userMessageId: userMessage?.messageId,
responseMessageId: response?.messageId,
conversationId: conversation?.conversationId,
});
await GenerationJobManager.emitDone(streamId, finalEvent);
GenerationJobManager.completeJob(streamId);
await decrementPendingRequest(userId);
if (client.savedMessageIds && !client.savedMessageIds.has(messageId)) {
await saveMessage(
req,
{ ...response, user: userId },
{ context: 'api/server/controllers/agents/request.js - resumable response end' },
);
}
} else {
const finalEvent = {
final: true,
conversation,
title: conversation.title,
requestMessage: sanitizeMessageForTransmit(userMessage),
responseMessage: { ...response, error: true },
error: { message: 'Request was aborted' },
responseMessage: { ...response, unfinished: true },
};
GenerationJobManager.emitDone(streamId, finalEvent);
logger.debug(`[ResumableAgentController] Emitting ABORTED FINAL event`, {
streamId,
wasAbortedBeforeComplete,
userMessageId: userMessage?.messageId,
responseMessageId: response?.messageId,
conversationId: conversation?.conversationId,
});
await GenerationJobManager.emitDone(streamId, finalEvent);
GenerationJobManager.completeJob(streamId, 'Request aborted');
await decrementPendingRequest(userId);
}
@ -334,7 +374,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// abortJob already handled emitDone and completeJob
} else {
logger.error(`[ResumableAgentController] Generation error for ${streamId}:`, error);
GenerationJobManager.emitError(streamId, error.message || 'Generation failed');
await GenerationJobManager.emitError(streamId, error.message || 'Generation failed');
GenerationJobManager.completeJob(streamId, error.message);
}
@ -363,7 +403,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
res.status(500).json({ error: error.message || 'Failed to start generation' });
} else {
// JSON already sent, emit error to stream so client can receive it
GenerationJobManager.emitError(streamId, error.message || 'Failed to start generation');
await GenerationJobManager.emitError(streamId, error.message || 'Failed to start generation');
}
GenerationJobManager.completeJob(streamId, error.message);
await decrementPendingRequest(userId);
@ -596,14 +636,10 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
// Process files if needed (sanitize to remove large text fields before transmission)
if (req.body.files && client.options?.attachments) {
userMessage.files = [];
const messageFiles = new Set(req.body.files.map((file) => file.file_id));
for (const attachment of client.options.attachments) {
if (messageFiles.has(attachment.file_id)) {
userMessage.files.push(sanitizeFileForTransmit(attachment));
}
if (req.body.files && Array.isArray(client.options.attachments)) {
const files = buildMessageFiles(req.body.files, client.options.attachments);
if (files.length > 0) {
userMessage.files = files;
}
delete userMessage.image_urls;
}

View file

@ -0,0 +1,910 @@
const { nanoid } = require('nanoid');
const { v4: uuidv4 } = require('uuid');
const { logger } = require('@librechat/data-schemas');
const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents');
const { EModelEndpoint, ResourceType, PermissionBits } = require('librechat-data-provider');
const {
createRun,
buildToolSet,
createSafeUser,
initializeAgent,
getBalanceConfig,
recordCollectedUsage,
getTransactionsConfig,
createToolExecuteHandler,
// Responses API
writeDone,
buildResponse,
generateResponseId,
isValidationFailure,
emitResponseCreated,
createResponseContext,
createResponseTracker,
setupStreamingResponse,
emitResponseInProgress,
convertInputToMessages,
validateResponseRequest,
buildAggregatedResponse,
createResponseAggregator,
sendResponsesErrorResponse,
createResponsesEventHandlers,
createAggregatorEventHandlers,
} = require('@librechat/api');
const {
createResponsesToolEndCallback,
createToolEndCallback,
} = require('~/server/controllers/agents/callbacks');
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { findAccessibleResources } = require('~/server/services/PermissionService');
const { getConvoFiles, saveConvo, getConvo } = require('~/models/Conversation');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
const { getAgent, getAgents } = require('~/models/Agent');
const db = require('~/models');
/** @type {import('@librechat/api').AppConfig | null} */
let appConfig = null;
/**
* Set the app config for the controller
* @param {import('@librechat/api').AppConfig} config
*/
function setAppConfig(config) {
appConfig = config;
}
/**
* Creates a tool loader function for the agent.
* @param {AbortSignal} signal - The abort signal
* @param {boolean} [definitionsOnly=true] - When true, returns only serializable
* tool definitions without creating full tool instances (for event-driven mode)
*/
function createToolLoader(signal, definitionsOnly = true) {
return async function loadTools({
req,
res,
tools,
model,
agentId,
provider,
tool_options,
tool_resources,
}) {
const agent = { id: agentId, tools, provider, model, tool_options };
try {
return await loadAgentTools({
req,
res,
agent,
signal,
tool_resources,
definitionsOnly,
streamId: null,
});
} catch (error) {
logger.error('Error loading tools for agent ' + agentId, error);
}
};
}
/**
* Convert Open Responses input items to internal messages
* @param {import('@librechat/api').InputItem[]} input
* @returns {Array} Internal messages
*/
function convertToInternalMessages(input) {
return convertInputToMessages(input);
}
/**
* Load messages from a previous response/conversation
* @param {string} conversationId - The conversation/response ID
* @param {string} userId - The user ID
* @returns {Promise<Array>} Messages from the conversation
*/
async function loadPreviousMessages(conversationId, userId) {
try {
const messages = await db.getMessages({ conversationId, user: userId });
if (!messages || messages.length === 0) {
return [];
}
// Convert stored messages to internal format
return messages.map((msg) => {
const internalMsg = {
role: msg.isCreatedByUser ? 'user' : 'assistant',
content: '',
messageId: msg.messageId,
};
// Handle content - could be string or array
if (typeof msg.text === 'string') {
internalMsg.content = msg.text;
} else if (Array.isArray(msg.content)) {
// Handle content parts
internalMsg.content = msg.content;
} else if (msg.text) {
internalMsg.content = String(msg.text);
}
return internalMsg;
});
} catch (error) {
logger.error('[Responses API] Error loading previous messages:', error);
return [];
}
}
/**
* Save input messages to database
* @param {import('express').Request} req
* @param {string} conversationId
* @param {Array} inputMessages - Internal format messages
* @param {string} agentId
* @returns {Promise<void>}
*/
async function saveInputMessages(req, conversationId, inputMessages, agentId) {
for (const msg of inputMessages) {
if (msg.role === 'user') {
await db.saveMessage(
req,
{
messageId: msg.messageId || nanoid(),
conversationId,
parentMessageId: null,
isCreatedByUser: true,
text: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
sender: 'User',
endpoint: EModelEndpoint.agents,
model: agentId,
},
{ context: 'Responses API - save user input' },
);
}
}
}
/**
* Save response output to database
* @param {import('express').Request} req
* @param {string} conversationId
* @param {string} responseId
* @param {import('@librechat/api').Response} response
* @param {string} agentId
* @returns {Promise<void>}
*/
async function saveResponseOutput(req, conversationId, responseId, response, agentId) {
// Extract text content from output items
let responseText = '';
for (const item of response.output) {
if (item.type === 'message' && item.content) {
for (const part of item.content) {
if (part.type === 'output_text' && part.text) {
responseText += part.text;
}
}
}
}
// Save the assistant message
await db.saveMessage(
req,
{
messageId: responseId,
conversationId,
parentMessageId: null,
isCreatedByUser: false,
text: responseText,
sender: 'Agent',
endpoint: EModelEndpoint.agents,
model: agentId,
finish_reason: response.status === 'completed' ? 'stop' : response.status,
tokenCount: response.usage?.output_tokens,
},
{ context: 'Responses API - save assistant response' },
);
}
/**
* Save or update conversation
* @param {import('express').Request} req
* @param {string} conversationId
* @param {string} agentId
* @param {object} agent
* @returns {Promise<void>}
*/
async function saveConversation(req, conversationId, agentId, agent) {
await saveConvo(
req,
{
conversationId,
endpoint: EModelEndpoint.agents,
agentId,
title: agent?.name || 'Open Responses Conversation',
model: agent?.model,
},
{ context: 'Responses API - save conversation' },
);
}
/**
* Convert stored messages to Open Responses output format
* @param {Array} messages - Stored messages
* @returns {Array} Output items
*/
function convertMessagesToOutputItems(messages) {
const output = [];
for (const msg of messages) {
if (!msg.isCreatedByUser) {
output.push({
type: 'message',
id: msg.messageId,
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: msg.text || '',
annotations: [],
},
],
});
}
}
return output;
}
/**
* Create Response - POST /v1/responses
*
* Creates a model response following the Open Responses API specification.
* Supports both streaming and non-streaming responses.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
const createResponse = async (req, res) => {
const requestStartTime = Date.now();
// Validate request
const validation = validateResponseRequest(req.body);
if (isValidationFailure(validation)) {
return sendResponsesErrorResponse(res, 400, validation.error);
}
const request = validation.request;
const agentId = request.model;
const isStreaming = request.stream === true;
// Look up the agent
const agent = await getAgent({ id: agentId });
if (!agent) {
return sendResponsesErrorResponse(
res,
404,
`Agent not found: ${agentId}`,
'not_found',
'model_not_found',
);
}
// Generate IDs
const responseId = generateResponseId();
const conversationId = request.previous_response_id ?? uuidv4();
const parentMessageId = null;
// Create response context
const context = createResponseContext(request, responseId);
logger.debug(
`[Responses API] Request ${responseId} started for agent ${agentId}, stream: ${isStreaming}`,
);
// Set up abort controller
const abortController = new AbortController();
// Handle client disconnect
req.on('close', () => {
if (!abortController.signal.aborted) {
abortController.abort();
logger.debug('[Responses API] Client disconnected, aborting');
}
});
try {
// Build allowed providers set
const allowedProviders = new Set(
appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders,
);
// Create tool loader
const loadTools = createToolLoader(abortController.signal);
// Initialize the agent first to check for disableStreaming
const endpointOption = {
endpoint: agent.provider,
model_parameters: agent.model_parameters ?? {},
};
const primaryConfig = await initializeAgent(
{
req,
res,
loadTools,
requestFiles: [],
conversationId,
parentMessageId,
agent,
endpointOption,
allowedProviders,
isInitialAgent: true,
},
{
getConvoFiles,
getFiles: db.getFiles,
getUserKey: db.getUserKey,
getMessages: db.getMessages,
updateFilesUsage: db.updateFilesUsage,
getUserKeyValues: db.getUserKeyValues,
getUserCodeFiles: db.getUserCodeFiles,
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
},
);
// Determine if streaming is enabled (check both request and agent config)
const streamingDisabled = !!primaryConfig.model_parameters?.disableStreaming;
const actuallyStreaming = isStreaming && !streamingDisabled;
// Load previous messages if previous_response_id is provided
let previousMessages = [];
if (request.previous_response_id) {
const userId = req.user?.id ?? 'api-user';
previousMessages = await loadPreviousMessages(request.previous_response_id, userId);
}
// Convert input to internal messages
const inputMessages = convertToInternalMessages(
typeof request.input === 'string' ? request.input : request.input,
);
// Merge previous messages with new input
const allMessages = [...previousMessages, ...inputMessages];
const toolSet = buildToolSet(primaryConfig);
const { messages: formattedMessages, indexTokenCountMap } = formatAgentMessages(
allMessages,
{},
toolSet,
);
// Create tracker for streaming or aggregator for non-streaming
const tracker = actuallyStreaming ? createResponseTracker() : null;
const aggregator = actuallyStreaming ? null : createResponseAggregator();
// Set up response for streaming
if (actuallyStreaming) {
setupStreamingResponse(res);
// Create handler config
const handlerConfig = {
res,
context,
tracker,
};
// Emit response.created then response.in_progress per Open Responses spec
emitResponseCreated(handlerConfig);
emitResponseInProgress(handlerConfig);
// Create event handlers
const { handlers: responsesHandlers, finalizeStream } =
createResponsesEventHandlers(handlerConfig);
// Collect usage for balance tracking
const collectedUsage = [];
// Artifact promises for processing tool outputs
/** @type {Promise<import('librechat-data-provider').TAttachment | null>[]} */
const artifactPromises = [];
// Use Responses API-specific callback that emits librechat:attachment events
const toolEndCallback = createResponsesToolEndCallback({
req,
res,
tracker,
artifactPromises,
});
// Create tool execute options for event-driven tool execution
const toolExecuteOptions = {
loadTools: async (toolNames) => {
return loadToolsForExecution({
req,
res,
agent,
toolNames,
signal: abortController.signal,
toolRegistry: primaryConfig.toolRegistry,
userMCPAuthMap: primaryConfig.userMCPAuthMap,
tool_resources: primaryConfig.tool_resources,
});
},
toolEndCallback,
};
// Combine handlers
const handlers = {
on_message_delta: responsesHandlers.on_message_delta,
on_reasoning_delta: responsesHandlers.on_reasoning_delta,
on_run_step: responsesHandlers.on_run_step,
on_run_step_delta: responsesHandlers.on_run_step_delta,
on_chat_model_end: {
handle: (event, data) => {
responsesHandlers.on_chat_model_end.handle(event, data);
const usage = data?.output?.usage_metadata;
if (usage) {
collectedUsage.push(usage);
}
},
},
on_tool_end: new ToolEndHandler(toolEndCallback, logger),
on_run_step_completed: { handle: () => {} },
on_chain_stream: { handle: () => {} },
on_chain_end: { handle: () => {} },
on_agent_update: { handle: () => {} },
on_custom_event: { handle: () => {} },
on_tool_execute: createToolExecuteHandler(toolExecuteOptions),
};
// Create and run the agent
const userId = req.user?.id ?? 'api-user';
const userMCPAuthMap = primaryConfig.userMCPAuthMap;
const run = await createRun({
agents: [primaryConfig],
messages: formattedMessages,
indexTokenCountMap,
runId: responseId,
signal: abortController.signal,
customHandlers: handlers,
requestBody: {
messageId: responseId,
conversationId,
},
user: { id: userId },
});
if (!run) {
throw new Error('Failed to create agent run');
}
// Process the stream
const config = {
runName: 'AgentRun',
configurable: {
thread_id: conversationId,
user_id: userId,
user: createSafeUser(req.user),
requestBody: {
messageId: responseId,
conversationId,
},
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
signal: abortController.signal,
streamMode: 'values',
version: 'v2',
};
await run.processStream({ messages: formattedMessages }, config, {
callbacks: {
[Callback.TOOL_ERROR]: (graph, error, toolId) => {
logger.error(`[Responses API] Tool Error "${toolId}"`, error);
},
},
});
// Record token usage against balance
const balanceConfig = getBalanceConfig(req.config);
const transactionsConfig = getTransactionsConfig(req.config);
recordCollectedUsage(
{
spendTokens,
spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
},
{
user: userId,
conversationId,
collectedUsage,
context: 'message',
messageId: responseId,
balance: balanceConfig,
transactions: transactionsConfig,
model: primaryConfig.model || agent.model_parameters?.model,
},
).catch((err) => {
logger.error('[Responses API] Error recording usage:', err);
});
// Finalize the stream
finalizeStream();
res.end();
const duration = Date.now() - requestStartTime;
logger.debug(`[Responses API] Request ${responseId} completed in ${duration}ms (streaming)`);
// Save to database if store: true
if (request.store === true) {
try {
// Save conversation
await saveConversation(req, conversationId, agentId, agent);
// Save input messages
await saveInputMessages(req, conversationId, inputMessages, agentId);
// Build response for saving (use tracker with buildResponse for streaming)
const finalResponse = buildResponse(context, tracker, 'completed');
await saveResponseOutput(req, conversationId, responseId, finalResponse, agentId);
logger.debug(
`[Responses API] Stored response ${responseId} in conversation ${conversationId}`,
);
} catch (saveError) {
logger.error('[Responses API] Error saving response:', saveError);
// Don't fail the request if saving fails
}
}
// Wait for artifact processing after response ends (non-blocking)
if (artifactPromises.length > 0) {
Promise.all(artifactPromises).catch((artifactError) => {
logger.warn('[Responses API] Error processing artifacts:', artifactError);
});
}
} else {
const aggregatorHandlers = createAggregatorEventHandlers(aggregator);
// Collect usage for balance tracking
const collectedUsage = [];
/** @type {Promise<import('librechat-data-provider').TAttachment | null>[]} */
const artifactPromises = [];
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null });
const toolExecuteOptions = {
loadTools: async (toolNames) => {
return loadToolsForExecution({
req,
res,
agent,
toolNames,
signal: abortController.signal,
toolRegistry: primaryConfig.toolRegistry,
userMCPAuthMap: primaryConfig.userMCPAuthMap,
tool_resources: primaryConfig.tool_resources,
});
},
toolEndCallback,
};
const handlers = {
on_message_delta: aggregatorHandlers.on_message_delta,
on_reasoning_delta: aggregatorHandlers.on_reasoning_delta,
on_run_step: aggregatorHandlers.on_run_step,
on_run_step_delta: aggregatorHandlers.on_run_step_delta,
on_chat_model_end: {
handle: (event, data) => {
aggregatorHandlers.on_chat_model_end.handle(event, data);
const usage = data?.output?.usage_metadata;
if (usage) {
collectedUsage.push(usage);
}
},
},
on_tool_end: new ToolEndHandler(toolEndCallback, logger),
on_run_step_completed: { handle: () => {} },
on_chain_stream: { handle: () => {} },
on_chain_end: { handle: () => {} },
on_agent_update: { handle: () => {} },
on_custom_event: { handle: () => {} },
on_tool_execute: createToolExecuteHandler(toolExecuteOptions),
};
const userId = req.user?.id ?? 'api-user';
const userMCPAuthMap = primaryConfig.userMCPAuthMap;
const run = await createRun({
agents: [primaryConfig],
messages: formattedMessages,
indexTokenCountMap,
runId: responseId,
signal: abortController.signal,
customHandlers: handlers,
requestBody: {
messageId: responseId,
conversationId,
},
user: { id: userId },
});
if (!run) {
throw new Error('Failed to create agent run');
}
const config = {
runName: 'AgentRun',
configurable: {
thread_id: conversationId,
user_id: userId,
user: createSafeUser(req.user),
requestBody: {
messageId: responseId,
conversationId,
},
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
signal: abortController.signal,
streamMode: 'values',
version: 'v2',
};
await run.processStream({ messages: formattedMessages }, config, {
callbacks: {
[Callback.TOOL_ERROR]: (graph, error, toolId) => {
logger.error(`[Responses API] Tool Error "${toolId}"`, error);
},
},
});
// Record token usage against balance
const balanceConfig = getBalanceConfig(req.config);
const transactionsConfig = getTransactionsConfig(req.config);
recordCollectedUsage(
{
spendTokens,
spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
},
{
user: userId,
conversationId,
collectedUsage,
context: 'message',
messageId: responseId,
balance: balanceConfig,
transactions: transactionsConfig,
model: primaryConfig.model || agent.model_parameters?.model,
},
).catch((err) => {
logger.error('[Responses API] Error recording usage:', err);
});
if (artifactPromises.length > 0) {
try {
await Promise.all(artifactPromises);
} catch (artifactError) {
logger.warn('[Responses API] Error processing artifacts:', artifactError);
}
}
const response = buildAggregatedResponse(context, aggregator);
if (request.store === true) {
try {
await saveConversation(req, conversationId, agentId, agent);
await saveInputMessages(req, conversationId, inputMessages, agentId);
await saveResponseOutput(req, conversationId, responseId, response, agentId);
logger.debug(
`[Responses API] Stored response ${responseId} in conversation ${conversationId}`,
);
} catch (saveError) {
logger.error('[Responses API] Error saving response:', saveError);
// Don't fail the request if saving fails
}
}
res.json(response);
const duration = Date.now() - requestStartTime;
logger.debug(
`[Responses API] Request ${responseId} completed in ${duration}ms (non-streaming)`,
);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An error occurred';
logger.error('[Responses API] Error:', error);
// Check if we already started streaming (headers sent)
if (res.headersSent) {
// Headers already sent, write error event and close
writeDone(res);
res.end();
} else {
// Forward upstream provider status codes (e.g., Anthropic 400s) instead of masking as 500
const statusCode =
typeof error?.status === 'number' && error.status >= 400 && error.status < 600
? error.status
: 500;
const errorType = statusCode >= 400 && statusCode < 500 ? 'invalid_request' : 'server_error';
sendResponsesErrorResponse(res, statusCode, errorMessage, errorType);
}
}
};
/**
* List available agents as models - GET /v1/models (also works with /v1/responses/models)
*
* Returns a list of available agents the user has remote access to.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
const listModels = async (req, res) => {
try {
const userId = req.user?.id;
const userRole = req.user?.role;
if (!userId) {
return sendResponsesErrorResponse(res, 401, 'Authentication required', 'auth_error');
}
// Find agents the user has remote access to (VIEW permission on REMOTE_AGENT)
const accessibleAgentIds = await findAccessibleResources({
userId,
role: userRole,
resourceType: ResourceType.REMOTE_AGENT,
requiredPermissions: PermissionBits.VIEW,
});
// Get the accessible agents
let agents = [];
if (accessibleAgentIds.length > 0) {
agents = await getAgents({ _id: { $in: accessibleAgentIds } });
}
// Convert to models format
const models = agents.map((agent) => ({
id: agent.id,
object: 'model',
created: Math.floor(new Date(agent.createdAt).getTime() / 1000),
owned_by: agent.author ?? 'librechat',
// Additional metadata
name: agent.name,
description: agent.description,
provider: agent.provider,
}));
res.json({
object: 'list',
data: models,
});
} catch (error) {
logger.error('[Responses API] Error listing models:', error);
sendResponsesErrorResponse(
res,
500,
error instanceof Error ? error.message : 'Failed to list models',
'server_error',
);
}
};
/**
* Get Response - GET /v1/responses/:id
*
* Retrieves a stored response by its ID.
* The response ID maps to a conversationId in LibreChat's storage.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
const getResponse = async (req, res) => {
try {
const responseId = req.params.id;
const userId = req.user?.id;
if (!responseId) {
return sendResponsesErrorResponse(res, 400, 'Response ID is required');
}
// The responseId could be either the response ID or the conversation ID
// Try to find a conversation with this ID
const conversation = await getConvo(userId, responseId);
if (!conversation) {
return sendResponsesErrorResponse(
res,
404,
`Response not found: ${responseId}`,
'not_found',
'response_not_found',
);
}
// Load messages for this conversation
const messages = await db.getMessages({ conversationId: responseId, user: userId });
if (!messages || messages.length === 0) {
return sendResponsesErrorResponse(
res,
404,
`No messages found for response: ${responseId}`,
'not_found',
'response_not_found',
);
}
// Convert messages to Open Responses output format
const output = convertMessagesToOutputItems(messages);
// Find the last assistant message for usage info
const lastAssistantMessage = messages.filter((m) => !m.isCreatedByUser).pop();
// Build the response object
const response = {
id: responseId,
object: 'response',
created_at: Math.floor(new Date(conversation.createdAt || Date.now()).getTime() / 1000),
completed_at: Math.floor(new Date(conversation.updatedAt || Date.now()).getTime() / 1000),
status: 'completed',
incomplete_details: null,
model: conversation.agentId || conversation.model || 'unknown',
previous_response_id: null,
instructions: null,
output,
error: null,
tools: [],
tool_choice: 'auto',
truncation: 'disabled',
parallel_tool_calls: true,
text: { format: { type: 'text' } },
temperature: 1,
top_p: 1,
presence_penalty: 0,
frequency_penalty: 0,
top_logprobs: null,
reasoning: null,
user: userId,
usage: lastAssistantMessage?.tokenCount
? {
input_tokens: 0,
output_tokens: lastAssistantMessage.tokenCount,
total_tokens: lastAssistantMessage.tokenCount,
}
: null,
max_output_tokens: null,
max_tool_calls: null,
store: true,
background: false,
service_tier: 'default',
metadata: {},
safety_identifier: null,
prompt_cache_key: null,
};
res.json(response);
} catch (error) {
logger.error('[Responses API] Error getting response:', error);
sendResponsesErrorResponse(
res,
500,
error instanceof Error ? error.message : 'Failed to get response',
'server_error',
);
}
};
module.exports = {
createResponse,
getResponse,
listModels,
setAppConfig,
};

View file

@ -5,11 +5,15 @@ const { logger } = require('@librechat/data-schemas');
const {
agentCreateSchema,
agentUpdateSchema,
refreshListAvatars,
mergeAgentOcrConversion,
MAX_AVATAR_REFRESH_AGENTS,
convertOcrToContextInPlace,
} = require('@librechat/api');
const {
Time,
Tools,
CacheKeys,
Constants,
FileSources,
ResourceType,
@ -19,8 +23,6 @@ const {
PermissionBits,
actionDelimiter,
removeNullishValues,
CacheKeys,
Time,
} = require('librechat-data-provider');
const {
getListAgentsByAccess,
@ -56,46 +58,6 @@ const systemTools = {
const MAX_SEARCH_LEN = 100;
const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Opportunistically refreshes S3-backed avatars for agent list responses.
* Only list responses are refreshed because they're the highest-traffic surface and
* the avatar URLs have a short-lived TTL. The refresh is cached per-user for 30 minutes
* via {@link CacheKeys.S3_EXPIRY_INTERVAL} so we refresh once per interval at most.
* @param {Array} agents - Agents being enriched with S3-backed avatars
* @param {string} userId - User identifier used for the cache refresh key
*/
const refreshListAvatars = async (agents, userId) => {
if (!agents?.length) {
return;
}
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
const refreshKey = `${userId}:agents_list`;
const alreadyChecked = await cache.get(refreshKey);
if (alreadyChecked) {
return;
}
await Promise.all(
agents.map(async (agent) => {
if (agent?.avatar?.source !== FileSources.s3 || !agent?.avatar?.filepath) {
return;
}
try {
const newPath = await refreshS3Url(agent.avatar);
if (newPath && newPath !== agent.avatar.filepath) {
agent.avatar = { ...agent.avatar, filepath: newPath };
}
} catch (err) {
logger.debug('[/Agents] Avatar refresh error for list item', err);
}
}),
);
await cache.set(refreshKey, true, Time.THIRTY_MINUTES);
};
/**
* Creates an Agent.
* @route POST /Agents
@ -119,7 +81,7 @@ const createAgentHandler = async (req, res) => {
agentData.author = userId;
agentData.tools = [];
const availableTools = await getCachedTools();
const availableTools = (await getCachedTools()) ?? {};
for (const tool of tools) {
if (availableTools[tool]) {
agentData.tools.push(tool);
@ -132,16 +94,25 @@ const createAgentHandler = async (req, res) => {
const agent = await createAgent(agentData);
// Automatically grant owner permissions to the creator
try {
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: userId,
});
await Promise.all([
grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: userId,
}),
grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.REMOTE_AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
grantedBy: userId,
}),
]);
logger.debug(
`[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
);
@ -434,16 +405,25 @@ const duplicateAgentHandler = async (req, res) => {
newAgentData.actions = agentActions;
const newAgent = await createAgent(newAgentData);
// Automatically grant owner permissions to the duplicator
try {
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: newAgent._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: userId,
});
await Promise.all([
grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: newAgent._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: userId,
}),
grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.REMOTE_AGENT,
resourceId: newAgent._id,
accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
grantedBy: userId,
}),
]);
logger.debug(
`[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
);
@ -544,6 +524,38 @@ const getListAgentsHandler = async (req, res) => {
requiredPermissions: PermissionBits.VIEW,
});
/**
* Refresh all S3 avatars for this user's accessible agent set (not only the current page)
* This addresses page-size limits preventing refresh of agents beyond the first page
*/
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
const refreshKey = `${userId}:agents_avatar_refresh`;
let cachedRefresh = await cache.get(refreshKey);
const isValidCachedRefresh =
cachedRefresh != null && typeof cachedRefresh === 'object' && cachedRefresh.urlCache != null;
if (!isValidCachedRefresh) {
try {
const fullList = await getListAgentsByAccess({
accessibleIds,
otherParams: {},
limit: MAX_AVATAR_REFRESH_AGENTS,
after: null,
});
const { urlCache } = await refreshListAvatars({
agents: fullList?.data ?? [],
userId,
refreshS3Url,
updateAgent,
});
cachedRefresh = { urlCache };
await cache.set(refreshKey, cachedRefresh, Time.THIRTY_MINUTES);
} catch (err) {
logger.error('[/Agents] Error refreshing avatars for full list: %o', err);
}
} else {
logger.debug('[/Agents] S3 avatar refresh already checked, skipping');
}
// Use the new ACL-aware function
const data = await getListAgentsByAccess({
accessibleIds,
@ -559,11 +571,20 @@ const getListAgentsHandler = async (req, res) => {
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
const urlCache = cachedRefresh?.urlCache;
data.data = agents.map((agent) => {
try {
if (agent?._id && publicSet.has(agent._id.toString())) {
agent.isPublic = true;
}
if (
urlCache &&
agent?.id &&
agent?.avatar?.source === FileSources.s3 &&
urlCache[agent.id]
) {
agent.avatar = { ...agent.avatar, filepath: urlCache[agent.id] };
}
} catch (e) {
// Silently ignore mapping errors
void e;
@ -571,15 +592,9 @@ const getListAgentsHandler = async (req, res) => {
return agent;
});
// Opportunistically refresh S3 avatar URLs for list results with caching
try {
await refreshListAvatars(data.data, req.user.id);
} catch (err) {
logger.debug('[/Agents] Skipping avatar refresh for list', err);
}
return res.json(data);
} catch (error) {
logger.error('[/Agents] Error listing Agents', error);
logger.error('[/Agents] Error listing Agents: %o', error);
res.status(500).json({ error: error.message });
}
};
@ -655,6 +670,14 @@ const uploadAgentAvatarHandler = async (req, res) => {
const updatedAgent = await updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
});
try {
const avatarCache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
await avatarCache.delete(`${req.user.id}:agents_avatar_refresh`);
} catch (cacheErr) {
logger.error('[/:agent_id/avatar] Error invalidating avatar refresh cache', cacheErr);
}
res.status(201).json(updatedAgent);
} catch (error) {
const message = 'An error occurred while updating the Agent Avatar';

View file

@ -1,8 +1,9 @@
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { nanoid } = require('nanoid');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { v4: uuidv4 } = require('uuid');
const { agentSchema } = require('@librechat/data-schemas');
const { FileSources } = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
// Only mock the dependencies that are not database-related
jest.mock('~/server/services/Config', () => ({
@ -54,6 +55,16 @@ jest.mock('~/models', () => ({
getCategoriesWithCounts: jest.fn(),
}));
// Mock cache for S3 avatar refresh tests
const mockCache = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
};
jest.mock('~/cache', () => ({
getLogStores: jest.fn(() => mockCache),
}));
const {
createAgent: createAgentHandler,
updateAgent: updateAgentHandler,
@ -65,6 +76,8 @@ const {
findPubliclyAccessibleResources,
} = require('~/server/services/PermissionService');
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
/**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
*/
@ -1207,4 +1220,431 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(response.data[0].is_promoted).toBe(true);
});
});
describe('S3 Avatar Refresh', () => {
let userA, userB;
let agentWithS3Avatar, agentWithLocalAvatar, agentOwnedByOther;
beforeEach(async () => {
await Agent.deleteMany({});
jest.clearAllMocks();
// Reset cache mock
mockCache.get.mockResolvedValue(false);
mockCache.set.mockResolvedValue(undefined);
userA = new mongoose.Types.ObjectId();
userB = new mongoose.Types.ObjectId();
// Create agent with S3 avatar owned by userA
agentWithS3Avatar = await Agent.create({
id: `agent_${nanoid(12)}`,
name: 'Agent with S3 Avatar',
description: 'Has S3 avatar',
provider: 'openai',
model: 'gpt-4',
author: userA,
avatar: {
source: FileSources.s3,
filepath: 'old-s3-path.jpg',
},
versions: [
{
name: 'Agent with S3 Avatar',
description: 'Has S3 avatar',
provider: 'openai',
model: 'gpt-4',
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
// Create agent with local avatar owned by userA
agentWithLocalAvatar = await Agent.create({
id: `agent_${nanoid(12)}`,
name: 'Agent with Local Avatar',
description: 'Has local avatar',
provider: 'openai',
model: 'gpt-4',
author: userA,
avatar: {
source: 'local',
filepath: 'local-path.jpg',
},
versions: [
{
name: 'Agent with Local Avatar',
description: 'Has local avatar',
provider: 'openai',
model: 'gpt-4',
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
// Create agent with S3 avatar owned by userB
agentOwnedByOther = await Agent.create({
id: `agent_${nanoid(12)}`,
name: 'Agent Owned By Other',
description: 'Owned by userB',
provider: 'openai',
model: 'gpt-4',
author: userB,
avatar: {
source: FileSources.s3,
filepath: 'other-s3-path.jpg',
},
versions: [
{
name: 'Agent Owned By Other',
description: 'Owned by userB',
provider: 'openai',
model: 'gpt-4',
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
});
test('should skip avatar refresh if cache hit', async () => {
mockCache.get.mockResolvedValue({ urlCache: {} });
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
await getListAgentsHandler(mockReq, mockRes);
// Should not call refreshS3Url when cache hit
expect(refreshS3Url).not.toHaveBeenCalled();
});
test('should refresh and persist S3 avatars on cache miss', async () => {
mockCache.get.mockResolvedValue(false);
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
refreshS3Url.mockResolvedValue('new-s3-path.jpg');
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
await getListAgentsHandler(mockReq, mockRes);
// Verify S3 URL was refreshed
expect(refreshS3Url).toHaveBeenCalled();
// Verify cache was set with urlCache map, not a plain boolean
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ urlCache: expect.any(Object) }),
expect.any(Number),
);
// Verify response was returned
expect(mockRes.json).toHaveBeenCalled();
});
test('should refresh avatars for all accessible agents (VIEW permission)', async () => {
mockCache.get.mockResolvedValue(false);
// User A has access to both their own agent and userB's agent
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id, agentOwnedByOther._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
refreshS3Url.mockResolvedValue('new-path.jpg');
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
await getListAgentsHandler(mockReq, mockRes);
// Should be called for both agents - any user with VIEW access can refresh
expect(refreshS3Url).toHaveBeenCalledTimes(2);
});
test('should skip non-S3 avatars', async () => {
mockCache.get.mockResolvedValue(false);
findAccessibleResources.mockResolvedValue([agentWithLocalAvatar._id, agentWithS3Avatar._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
refreshS3Url.mockResolvedValue('new-path.jpg');
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
await getListAgentsHandler(mockReq, mockRes);
// Should only be called for S3 avatar agent
expect(refreshS3Url).toHaveBeenCalledTimes(1);
});
test('should not update if S3 URL unchanged', async () => {
mockCache.get.mockResolvedValue(false);
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
// Return the same path - no update needed
refreshS3Url.mockResolvedValue('old-s3-path.jpg');
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
await getListAgentsHandler(mockReq, mockRes);
// Verify refreshS3Url was called
expect(refreshS3Url).toHaveBeenCalled();
// Response should still be returned
expect(mockRes.json).toHaveBeenCalled();
});
test('should handle S3 refresh errors gracefully', async () => {
mockCache.get.mockResolvedValue(false);
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
refreshS3Url.mockRejectedValue(new Error('S3 error'));
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
// Should not throw - handles error gracefully
await expect(getListAgentsHandler(mockReq, mockRes)).resolves.not.toThrow();
// Response should still be returned
expect(mockRes.json).toHaveBeenCalled();
});
test('should process agents in batches', async () => {
mockCache.get.mockResolvedValue(false);
// Create 25 agents (should be processed in batches of 20)
const manyAgents = [];
for (let i = 0; i < 25; i++) {
const agent = await Agent.create({
id: `agent_${nanoid(12)}`,
name: `Agent ${i}`,
description: `Agent ${i} description`,
provider: 'openai',
model: 'gpt-4',
author: userA,
avatar: {
source: FileSources.s3,
filepath: `path${i}.jpg`,
},
versions: [
{
name: `Agent ${i}`,
description: `Agent ${i} description`,
provider: 'openai',
model: 'gpt-4',
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
manyAgents.push(agent);
}
const allAgentIds = manyAgents.map((a) => a._id);
findAccessibleResources.mockResolvedValue(allAgentIds);
findPubliclyAccessibleResources.mockResolvedValue([]);
refreshS3Url.mockImplementation((avatar) =>
Promise.resolve(avatar.filepath.replace('.jpg', '-new.jpg')),
);
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
await getListAgentsHandler(mockReq, mockRes);
// All 25 should be processed
expect(refreshS3Url).toHaveBeenCalledTimes(25);
});
test('should skip agents without id or author', async () => {
mockCache.get.mockResolvedValue(false);
// Create agent without proper id field (edge case)
const agentWithoutId = await Agent.create({
id: `agent_${nanoid(12)}`,
name: 'Agent without ID field',
description: 'Testing',
provider: 'openai',
model: 'gpt-4',
author: userA,
avatar: {
source: FileSources.s3,
filepath: 'test-path.jpg',
},
versions: [
{
name: 'Agent without ID field',
description: 'Testing',
provider: 'openai',
model: 'gpt-4',
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
findAccessibleResources.mockResolvedValue([agentWithoutId._id, agentWithS3Avatar._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
refreshS3Url.mockResolvedValue('new-path.jpg');
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
await getListAgentsHandler(mockReq, mockRes);
// Should still complete without errors
expect(mockRes.json).toHaveBeenCalled();
});
test('should use MAX_AVATAR_REFRESH_AGENTS limit for full list query', async () => {
mockCache.get.mockResolvedValue(false);
findAccessibleResources.mockResolvedValue([]);
findPubliclyAccessibleResources.mockResolvedValue([]);
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
await getListAgentsHandler(mockReq, mockRes);
// Verify that the handler completed successfully
expect(mockRes.json).toHaveBeenCalled();
});
test('should treat legacy boolean cache entry as a miss and run refresh', async () => {
// Simulate a cache entry written by the pre-fix code
mockCache.get.mockResolvedValue(true);
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
refreshS3Url.mockResolvedValue('new-s3-path.jpg');
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
await getListAgentsHandler(mockReq, mockRes);
// Boolean true fails the shape guard, so refresh must run
expect(refreshS3Url).toHaveBeenCalled();
// Cache is overwritten with the proper format
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ urlCache: expect.any(Object) }),
expect.any(Number),
);
});
test('should apply cached urlCache filepath to paginated response on cache hit', async () => {
const agentId = agentWithS3Avatar.id;
const cachedUrl = 'cached-presigned-url.jpg';
mockCache.get.mockResolvedValue({ urlCache: { [agentId]: cachedUrl } });
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
await getListAgentsHandler(mockReq, mockRes);
expect(refreshS3Url).not.toHaveBeenCalled();
const responseData = mockRes.json.mock.calls[0][0];
const agent = responseData.data.find((a) => a.id === agentId);
// Cached URL is served, not the stale DB value 'old-s3-path.jpg'
expect(agent.avatar.filepath).toBe(cachedUrl);
});
test('should preserve DB filepath for agents absent from urlCache on cache hit', async () => {
mockCache.get.mockResolvedValue({ urlCache: {} });
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
const mockReq = {
user: { id: userA.toString(), role: 'USER' },
query: {},
};
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
await getListAgentsHandler(mockReq, mockRes);
expect(refreshS3Url).not.toHaveBeenCalled();
const responseData = mockRes.json.mock.calls[0][0];
const agent = responseData.data.find((a) => a.id === agentWithS3Avatar.id);
expect(agent.avatar.filepath).toBe('old-s3-path.jpg');
});
});
});

View file

@ -31,7 +31,7 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = await getCachedTools();
const toolDefinitions = (await getCachedTools()) ?? {};
assistantData.tools = tools
.map((tool) => {
@ -136,7 +136,7 @@ const patchAssistant = async (req, res) => {
...updateData
} = req.body;
const toolDefinitions = await getCachedTools();
const toolDefinitions = (await getCachedTools()) ?? {};
updateData.tools = (updateData.tools ?? [])
.map((tool) => {

View file

@ -28,7 +28,7 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = await getCachedTools();
const toolDefinitions = (await getCachedTools()) ?? {};
assistantData.tools = tools
.map((tool) => {
@ -125,7 +125,7 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
let hasFileSearch = false;
for (const tool of updateData.tools ?? []) {
const toolDefinitions = await getCachedTools();
const toolDefinitions = (await getCachedTools()) ?? {};
let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool;
if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {

View file

@ -8,13 +8,16 @@ const logoutController = async (req, res) => {
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
const isOpenIdUser = req.user?.openidId != null && req.user?.provider === 'openid';
/** For OpenID users, read refresh token from session; for others, use cookie */
/** For OpenID users, read tokens from session (with cookie fallback) */
let refreshToken;
let idToken;
if (isOpenIdUser && req.session?.openidTokens) {
refreshToken = req.session.openidTokens.refreshToken;
idToken = req.session.openidTokens.idToken;
delete req.session.openidTokens;
}
refreshToken = refreshToken || parsedCookies.refreshToken;
idToken = idToken || parsedCookies.openid_id_token;
try {
const logout = await logoutUser(req, refreshToken);
@ -22,6 +25,7 @@ const logoutController = async (req, res) => {
res.clearCookie('refreshToken');
res.clearCookie('openid_access_token');
res.clearCookie('openid_id_token');
res.clearCookie('openid_user_id');
res.clearCookie('token_provider');
const response = { message };
@ -30,21 +34,34 @@ const logoutController = async (req, res) => {
isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) &&
process.env.OPENID_ISSUER
) {
const openIdConfig = getOpenIdConfig();
if (!openIdConfig) {
logger.warn(
'[logoutController] OpenID config not found. Please verify that the open id configuration and initialization are correct.',
);
} else {
const endSessionEndpoint = openIdConfig
? openIdConfig.serverMetadata().end_session_endpoint
: null;
let openIdConfig;
try {
openIdConfig = getOpenIdConfig();
} catch (err) {
logger.warn('[logoutController] OpenID config not available:', err.message);
}
if (openIdConfig) {
const endSessionEndpoint = openIdConfig.serverMetadata().end_session_endpoint;
if (endSessionEndpoint) {
const endSessionUrl = new URL(endSessionEndpoint);
/** Redirect back to app's login page after IdP logout */
const postLogoutRedirectUri =
process.env.OPENID_POST_LOGOUT_REDIRECT_URI || `${process.env.DOMAIN_CLIENT}/login`;
endSessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
/** Add id_token_hint (preferred) or client_id for OIDC spec compliance */
if (idToken) {
endSessionUrl.searchParams.set('id_token_hint', idToken);
} else if (process.env.OPENID_CLIENT_ID) {
endSessionUrl.searchParams.set('client_id', process.env.OPENID_CLIENT_ID);
} else {
logger.warn(
'[logoutController] Neither id_token_hint nor OPENID_CLIENT_ID is available. ' +
'To enable id_token_hint, set OPENID_REUSE_TOKENS=true. ' +
'The OIDC end-session request may be rejected by the identity provider.',
);
}
response.redirect = endSessionUrl.toString();
} else {
logger.warn(

View file

@ -0,0 +1,259 @@
const cookies = require('cookie');
const mockLogoutUser = jest.fn();
const mockLogger = { warn: jest.fn(), error: jest.fn() };
const mockIsEnabled = jest.fn();
const mockGetOpenIdConfig = jest.fn();
jest.mock('cookie');
jest.mock('@librechat/api', () => ({ isEnabled: (...args) => mockIsEnabled(...args) }));
jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger }));
jest.mock('~/server/services/AuthService', () => ({
logoutUser: (...args) => mockLogoutUser(...args),
}));
jest.mock('~/strategies', () => ({ getOpenIdConfig: () => mockGetOpenIdConfig() }));
const { logoutController } = require('./LogoutController');
function buildReq(overrides = {}) {
return {
user: { _id: 'user1', openidId: 'oid1', provider: 'openid' },
headers: { cookie: 'refreshToken=rt1' },
session: {
openidTokens: { refreshToken: 'srt', idToken: 'small-id-token' },
destroy: jest.fn(),
},
...overrides,
};
}
function buildRes() {
const res = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
clearCookie: jest.fn(),
};
return res;
}
const ORIGINAL_ENV = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = {
...ORIGINAL_ENV,
OPENID_USE_END_SESSION_ENDPOINT: 'true',
OPENID_ISSUER: 'https://idp.example.com',
OPENID_CLIENT_ID: 'my-client-id',
DOMAIN_CLIENT: 'https://app.example.com',
};
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
mockLogoutUser.mockResolvedValue({ status: 200, message: 'Logout successful' });
mockIsEnabled.mockReturnValue(true);
mockGetOpenIdConfig.mockReturnValue({
serverMetadata: () => ({
end_session_endpoint: 'https://idp.example.com/logout',
}),
});
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
describe('LogoutController', () => {
describe('id_token_hint from session', () => {
it('sets id_token_hint when session has idToken', async () => {
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
expect(body.redirect).not.toContain('client_id=');
});
});
describe('id_token_hint from cookie fallback', () => {
it('uses cookie id_token when session has no tokens', async () => {
cookies.parse.mockReturnValue({
refreshToken: 'cookie-rt',
openid_id_token: 'cookie-id-token',
});
const req = buildReq({ session: { destroy: jest.fn() } });
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=cookie-id-token');
});
});
describe('client_id fallback', () => {
it('falls back to client_id when no idToken is available', async () => {
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
const req = buildReq({ session: { destroy: jest.fn() } });
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('client_id=my-client-id');
expect(body.redirect).not.toContain('id_token_hint=');
});
it('does not produce client_id=undefined when OPENID_CLIENT_ID is unset', async () => {
delete process.env.OPENID_CLIENT_ID;
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
const req = buildReq({ session: { destroy: jest.fn() } });
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('client_id=');
expect(body.redirect).not.toContain('undefined');
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Neither id_token_hint nor OPENID_CLIENT_ID'),
);
});
});
describe('OPENID_USE_END_SESSION_ENDPOINT disabled', () => {
it('does not include redirect when disabled', async () => {
mockIsEnabled.mockReturnValue(false);
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
});
});
describe('OPENID_ISSUER unset', () => {
it('does not include redirect when OPENID_ISSUER is missing', async () => {
delete process.env.OPENID_ISSUER;
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
});
});
describe('non-OpenID user', () => {
it('does not include redirect for non-OpenID users', async () => {
const req = buildReq({
user: { _id: 'user1', provider: 'local' },
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
});
});
describe('post_logout_redirect_uri', () => {
it('uses OPENID_POST_LOGOUT_REDIRECT_URI when set', async () => {
process.env.OPENID_POST_LOGOUT_REDIRECT_URI = 'https://custom.example.com/logged-out';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
const url = new URL(body.redirect);
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
'https://custom.example.com/logged-out',
);
});
it('defaults to DOMAIN_CLIENT/login when OPENID_POST_LOGOUT_REDIRECT_URI is unset', async () => {
delete process.env.OPENID_POST_LOGOUT_REDIRECT_URI;
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
const url = new URL(body.redirect);
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
'https://app.example.com/login',
);
});
});
describe('OpenID config not available', () => {
it('warns and returns no redirect when getOpenIdConfig throws', async () => {
mockGetOpenIdConfig.mockImplementation(() => {
throw new Error('OpenID configuration has not been initialized');
});
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('OpenID config not available'),
'OpenID configuration has not been initialized',
);
});
});
describe('end_session_endpoint not in metadata', () => {
it('warns and returns no redirect when end_session_endpoint is missing', async () => {
mockGetOpenIdConfig.mockReturnValue({
serverMetadata: () => ({}),
});
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('end_session_endpoint not found'),
);
});
});
describe('error handling', () => {
it('returns 500 on logoutUser error', async () => {
mockLogoutUser.mockRejectedValue(new Error('session error'));
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ message: 'session error' });
});
});
describe('cookie clearing', () => {
it('clears all auth cookies on successful logout', async () => {
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
expect(res.clearCookie).toHaveBeenCalledWith('openid_access_token');
expect(res.clearCookie).toHaveBeenCalledWith('openid_id_token');
expect(res.clearCookie).toHaveBeenCalledWith('openid_user_id');
expect(res.clearCookie).toHaveBeenCalledWith('token_provider');
});
});
});

View file

@ -0,0 +1,79 @@
const { CacheKeys } = require('librechat-data-provider');
const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas');
const {
isEnabled,
getAdminPanelUrl,
isAdminPanelRedirect,
generateAdminExchangeCode,
} = require('@librechat/api');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const getLogStores = require('~/cache/getLogStores');
const { checkBan } = require('~/server/middleware');
const { generateToken } = require('~/models');
const domains = {
client: process.env.DOMAIN_CLIENT,
server: process.env.DOMAIN_SERVER,
};
function createOAuthHandler(redirectUri = domains.client) {
/**
* A handler to process OAuth authentication results.
* @type {Function}
* @param {ServerRequest} req - Express request object.
* @param {ServerResponse} res - Express response object.
* @param {NextFunction} next - Express next middleware function.
*/
return async (req, res, next) => {
try {
if (res.headersSent) {
return;
}
await checkBan(req, res);
if (req.banned) {
return;
}
/** Check if this is an admin panel redirect (cross-origin) */
if (isAdminPanelRedirect(redirectUri, getAdminPanelUrl(), domains.client)) {
/** For admin panel, generate exchange code instead of setting cookies */
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
const token = await generateToken(req.user, sessionExpiry);
/** Get refresh token from tokenset for OpenID users */
const refreshToken =
req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token;
const exchangeCode = await generateAdminExchangeCode(cache, req.user, token, refreshToken);
const callbackUrl = new URL(redirectUri);
callbackUrl.searchParams.set('code', exchangeCode);
logger.info(`[OAuth] Admin panel redirect with exchange code for user: ${req.user.email}`);
return res.redirect(callbackUrl.toString());
}
/** Standard OAuth flow - set cookies and redirect */
if (
req.user &&
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, req, res, req.user._id.toString());
} else {
await setAuthTokens(req.user._id, res);
}
res.redirect(redirectUri);
} catch (err) {
logger.error('Error in setting authentication tokens:', err);
next(err);
}
};
}
module.exports = {
createOAuthHandler,
};

View file

@ -7,9 +7,11 @@
*/
const { logger } = require('@librechat/data-schemas');
const {
MCPErrorCodes,
redactServerSecrets,
redactAllServerSecrets,
isMCPDomainNotAllowedError,
isMCPInspectionFailedError,
MCPErrorCodes,
} = require('@librechat/api');
const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider');
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
@ -181,10 +183,8 @@ const getMCPServersList = async (req, res) => {
return res.status(401).json({ message: 'Unauthorized' });
}
// 2. Get all server configs from registry (YAML + DB)
const serverConfigs = await getMCPServersRegistry().getAllServerConfigs(userId);
return res.json(serverConfigs);
return res.json(redactAllServerSecrets(serverConfigs));
} catch (error) {
logger.error('[getMCPServersList]', error);
res.status(500).json({ error: error.message });
@ -215,7 +215,7 @@ const createMCPServerController = async (req, res) => {
);
res.status(201).json({
serverName: result.serverName,
...result.config,
...redactServerSecrets(result.config),
});
} catch (error) {
logger.error('[createMCPServer]', error);
@ -243,7 +243,7 @@ const getMCPServerById = async (req, res) => {
return res.status(404).json({ message: 'MCP server not found' });
}
res.status(200).json(parsedConfig);
res.status(200).json(redactServerSecrets(parsedConfig));
} catch (error) {
logger.error('[getMCPServerById]', error);
res.status(500).json({ message: error.message });
@ -274,7 +274,7 @@ const updateMCPServerController = async (req, res) => {
userId,
);
res.status(200).json(parsedConfig);
res.status(200).json(redactServerSecrets(parsedConfig));
} catch (error) {
logger.error('[updateMCPServer]', error);
const mcpErrorResponse = handleMCPError(error, res);

View file

@ -14,6 +14,7 @@ const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const {
isEnabled,
apiNotFound,
ErrorController,
performStartupChecks,
handleJsonParseError,
@ -297,8 +298,10 @@ if (cluster.isMaster) {
/** Routes */
app.use('/oauth', routes.oauth);
app.use('/api/auth', routes.auth);
app.use('/api/admin', routes.adminAuth);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/api-keys', routes.apiKeys);
app.use('/api/user', routes.user);
app.use('/api/search', routes.search);
app.use('/api/messages', routes.messages);
@ -309,7 +312,6 @@ if (cluster.isMaster) {
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
app.use('/api/models', routes.models);
app.use('/api/plugins', routes.plugins);
app.use('/api/config', routes.config);
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
@ -323,8 +325,8 @@ if (cluster.isMaster) {
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
/** Error handler */
app.use(ErrorController);
/** 404 for unmatched API routes */
app.use('/api', apiNotFound);
/** SPA fallback - serve index.html for all unmatched routes */
app.use((req, res) => {
@ -342,6 +344,9 @@ if (cluster.isMaster) {
res.send(updatedIndexHtml);
});
/** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */
app.use(ErrorController);
/** Start listening on shared port (cluster will distribute connections) */
app.listen(port, host, async (err) => {
if (err) {

View file

@ -12,12 +12,14 @@ const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const {
isEnabled,
apiNotFound,
ErrorController,
memoryDiagnostics,
performStartupChecks,
handleJsonParseError,
initializeFileStorage,
GenerationJobManager,
createStreamServices,
initializeFileStorage,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
@ -134,8 +136,10 @@ const startServer = async () => {
app.use('/oauth', routes.oauth);
/* API Endpoints */
app.use('/api/auth', routes.auth);
app.use('/api/admin', routes.adminAuth);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/api-keys', routes.apiKeys);
app.use('/api/user', routes.user);
app.use('/api/search', routes.search);
app.use('/api/messages', routes.messages);
@ -160,8 +164,10 @@ const startServer = async () => {
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
app.use(ErrorController);
/** 404 for unmatched API routes */
app.use('/api', apiNotFound);
/** SPA fallback - serve index.html for all unmatched routes */
app.use((req, res) => {
res.set({
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
@ -177,6 +183,9 @@ const startServer = async () => {
res.send(updatedIndexHtml);
});
/** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */
app.use(ErrorController);
app.listen(port, host, async (err) => {
if (err) {
logger.error('Failed to start server:', err);
@ -199,6 +208,11 @@ const startServer = async () => {
const streamServices = createStreamServices();
GenerationJobManager.configure(streamServices);
GenerationJobManager.initialize();
const inspectFlags = process.execArgv.some((arg) => arg.startsWith('--inspect'));
if (inspectFlags || isEnabled(process.env.MEM_DIAG)) {
memoryDiagnostics.start();
}
});
};
@ -249,6 +263,15 @@ process.on('uncaughtException', (err) => {
return;
}
if (isEnabled(process.env.CONTINUE_ON_UNCAUGHT_EXCEPTION)) {
logger.error('Unhandled error encountered. The app will continue running.', {
name: err?.name,
message: err?.message,
stack: err?.stack,
});
return;
}
process.exit(1);
});

View file

@ -100,6 +100,40 @@ describe('Server Configuration', () => {
expect(response.headers['expires']).toBe('0');
});
it('should return 404 JSON for undefined API routes', async () => {
const response = await request(app).get('/api/nonexistent');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Endpoint not found' });
});
it('should return 404 JSON for nested undefined API routes', async () => {
const response = await request(app).get('/api/nonexistent/nested/path');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Endpoint not found' });
});
it('should return 404 JSON for non-GET methods on undefined API routes', async () => {
const post = await request(app).post('/api/nonexistent');
expect(post.status).toBe(404);
expect(post.body).toEqual({ message: 'Endpoint not found' });
const del = await request(app).delete('/api/nonexistent');
expect(del.status).toBe(404);
expect(del.body).toEqual({ message: 'Endpoint not found' });
});
it('should return 404 JSON for the /api root path', async () => {
const response = await request(app).get('/api');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Endpoint not found' });
});
it('should serve SPA HTML for non-API unmatched routes', async () => {
const response = await request(app).get('/this/does/not/exist');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toMatch(/html/);
});
it('should return 500 for unknown errors via ErrorController', async () => {
// Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated

View file

@ -1,19 +1,70 @@
const { logger } = require('@librechat/data-schemas');
const {
countTokens,
isEnabled,
sendEvent,
countTokens,
GenerationJobManager,
recordCollectedUsage,
sanitizeMessageForTransmit,
} = require('@librechat/api');
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const { saveMessage, getConvo, updateBalance, bulkInsertTransactions } = require('~/models');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const { getMultiplier, getCacheMultiplier } = require('~/models/tx');
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<Object>} params.collectedUsage - Usage metadata from all models
* @param {string} [params.fallbackModel] - Fallback model name if not in usage
* @param {string} [params.messageId] - The response message ID for transaction correlation
*/
async function spendCollectedUsage({
userId,
conversationId,
collectedUsage,
fallbackModel,
messageId,
}) {
if (!collectedUsage || collectedUsage.length === 0) {
return;
}
await recordCollectedUsage(
{
spendTokens,
spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier },
bulkWriteOps: { insertMany: bulkInsertTransactions, updateBalance },
},
{
user: userId,
conversationId,
collectedUsage,
context: 'abort',
messageId,
model: fallbackModel,
},
);
// 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 +90,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 +112,22 @@ 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,
messageId: jobData?.responseMessageId,
});
} 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,
@ -206,4 +268,5 @@ const handleAbortError = async (res, req, error, data) => {
module.exports = {
handleAbort,
handleAbortError,
spendCollectedUsage,
};

View file

@ -0,0 +1,245 @@
/**
* Tests for abortMiddleware - spendCollectedUsage function
*
* This tests the token spending logic for abort scenarios,
* particularly for parallel agents (addedConvo) where multiple
* models need their tokens spent.
*
* spendCollectedUsage delegates to recordCollectedUsage from @librechat/api,
* passing pricing + bulkWriteOps deps, with context: 'abort'.
* After spending, it clears the collectedUsage array to prevent double-spending
* from the AgentClient finally block (which shares the same array reference).
*/
const mockSpendTokens = jest.fn().mockResolvedValue();
const mockSpendStructuredTokens = jest.fn().mockResolvedValue();
const mockRecordCollectedUsage = jest
.fn()
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('~/models/spendTokens', () => ({
spendTokens: (...args) => mockSpendTokens(...args),
spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
}));
jest.mock('~/models/tx', () => ({
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
},
}));
jest.mock('@librechat/api', () => ({
countTokens: jest.fn().mockResolvedValue(100),
isEnabled: jest.fn().mockReturnValue(false),
sendEvent: jest.fn(),
GenerationJobManager: {
abortJob: jest.fn(),
},
recordCollectedUsage: mockRecordCollectedUsage,
sanitizeMessageForTransmit: jest.fn((msg) => msg),
}));
jest.mock('librechat-data-provider', () => ({
isAssistantsEndpoint: jest.fn().mockReturnValue(false),
ErrorTypes: { INVALID_REQUEST: 'INVALID_REQUEST', NO_SYSTEM_MESSAGES: 'NO_SYSTEM_MESSAGES' },
}));
jest.mock('~/app/clients/prompts', () => ({
truncateText: jest.fn((text) => text),
smartTruncateText: jest.fn((text) => text),
}));
jest.mock('~/cache/clearPendingReq', () => jest.fn().mockResolvedValue());
jest.mock('~/server/middleware/error', () => ({
sendError: jest.fn(),
}));
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
jest.mock('~/models', () => ({
saveMessage: jest.fn().mockResolvedValue(),
getConvo: jest.fn().mockResolvedValue({ title: 'Test Chat' }),
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
}));
jest.mock('./abortRun', () => ({
abortRun: jest.fn(),
}));
const { spendCollectedUsage } = require('./abortMiddleware');
describe('abortMiddleware - spendCollectedUsage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('spendCollectedUsage delegation', () => {
it('should return early if collectedUsage is empty', async () => {
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage: [],
fallbackModel: 'gpt-4',
});
expect(mockRecordCollectedUsage).not.toHaveBeenCalled();
});
it('should return early if collectedUsage is null', async () => {
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage: null,
fallbackModel: 'gpt-4',
});
expect(mockRecordCollectedUsage).not.toHaveBeenCalled();
});
it('should call recordCollectedUsage with abort context and full deps', async () => {
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
messageId: 'msg-123',
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
{
spendTokens: expect.any(Function),
spendStructuredTokens: expect.any(Function),
pricing: {
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
},
bulkWriteOps: {
insertMany: mockBulkInsertTransactions,
updateBalance: mockUpdateBalance,
},
},
{
user: 'user-123',
conversationId: 'convo-123',
collectedUsage,
context: 'abort',
messageId: 'msg-123',
model: 'gpt-4',
},
);
});
it('should pass context abort for multiple models (parallel agents)', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 80, output_tokens: 40, model: 'claude-3' },
{ input_tokens: 120, output_tokens: 60, model: 'gemini-pro' },
];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
context: 'abort',
collectedUsage,
}),
);
});
it('should handle real-world parallel agent abort scenario', async () => {
const collectedUsage = [
{ input_tokens: 31596, output_tokens: 151, model: 'gemini-3-flash-preview' },
{ input_tokens: 28000, output_tokens: 120, model: 'gpt-5.2' },
];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gemini-3-flash-preview',
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
user: 'user-123',
conversationId: 'convo-123',
context: 'abort',
model: 'gemini-3-flash-preview',
}),
);
});
/**
* Race condition prevention: after abort middleware spends tokens,
* the collectedUsage array is cleared so AgentClient.recordCollectedUsage()
* (which shares the same array reference) sees an empty array and returns early.
*/
it('should clear collectedUsage array after spending to prevent double-spending', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 80, output_tokens: 40, model: 'claude-3' },
];
expect(collectedUsage.length).toBe(2);
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(collectedUsage.length).toBe(0);
});
it('should await recordCollectedUsage before clearing array', async () => {
let resolved = false;
mockRecordCollectedUsage.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
resolved = true;
return { input_tokens: 100, output_tokens: 50 };
});
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 80, output_tokens: 40, model: 'claude-3' },
];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
});
expect(resolved).toBe(true);
expect(collectedUsage.length).toBe(0);
});
});
});

View file

@ -29,7 +29,7 @@ describe('canAccessAgentResource middleware', () => {
AGENTS: {
USE: true,
CREATE: true,
SHARED_GLOBAL: false,
SHARE: true,
},
},
});

View file

@ -1,16 +1,16 @@
const { ResourceType } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { findMCPServerById } = require('~/models');
const { findMCPServerByServerName } = require('~/models');
/**
* MCP Server ID resolver function
* Resolves custom MCP server ID (e.g., "mcp_abc123") to MongoDB ObjectId
* MCP Server name resolver function
* Resolves MCP server name (e.g., "my-mcp-server") to MongoDB ObjectId
*
* @param {string} mcpServerCustomId - Custom MCP server ID from route parameter
* @param {string} serverName - Server name from route parameter
* @returns {Promise<Object|null>} MCP server document with _id field, or null if not found
*/
const resolveMCPServerId = async (mcpServerCustomId) => {
return await findMCPServerById(mcpServerCustomId);
const resolveMCPServerName = async (serverName) => {
return await findMCPServerByServerName(serverName);
};
/**
@ -52,7 +52,7 @@ const canAccessMCPServerResource = (options) => {
resourceType: ResourceType.MCPSERVER,
requiredPermission,
resourceIdParam,
idResolver: resolveMCPServerId,
idResolver: resolveMCPServerName,
});
};

View file

@ -26,10 +26,10 @@ describe('canAccessMCPServerResource middleware', () => {
await Role.create({
name: 'test-role',
permissions: {
MCPSERVERS: {
MCP_SERVERS: {
USE: true,
CREATE: true,
SHARED_GLOBAL: false,
SHARE: true,
},
},
});
@ -545,7 +545,7 @@ describe('canAccessMCPServerResource middleware', () => {
describe('error handling', () => {
test('should handle server returning null gracefully (treated as not found)', async () => {
// When an MCP server is not found, findMCPServerById returns null
// When an MCP server is not found, findMCPServerByServerName returns null
// which the middleware correctly handles as a 404
req.params.serverName = 'definitely-non-existent-server';

View file

@ -32,7 +32,7 @@ describe('fileAccess middleware', () => {
AGENTS: {
USE: true,
CREATE: true,
SHARED_GLOBAL: false,
SHARE: true,
},
},
});

View file

@ -5,9 +5,11 @@ const {
EModelEndpoint,
isAgentsEndpoint,
parseCompactConvo,
getDefaultParamsEndpoint,
} = require('librechat-data-provider');
const azureAssistants = require('~/server/services/Endpoints/azureAssistants');
const assistants = require('~/server/services/Endpoints/assistants');
const { getEndpointsConfig } = require('~/server/services/Config');
const agents = require('~/server/services/Endpoints/agents');
const { updateFilesUsage } = require('~/models');
@ -19,9 +21,24 @@ const buildFunction = {
async function buildEndpointOption(req, res, next) {
const { endpoint, endpointType } = req.body;
let endpointsConfig;
try {
endpointsConfig = await getEndpointsConfig(req);
} catch (error) {
logger.error('Error fetching endpoints config in buildEndpointOption', error);
}
const defaultParamsEndpoint = getDefaultParamsEndpoint(endpointsConfig, endpoint);
let parsedBody;
try {
parsedBody = parseCompactConvo({ endpoint, endpointType, conversation: req.body });
parsedBody = parseCompactConvo({
endpoint,
endpointType,
conversation: req.body,
defaultParamsEndpoint,
});
} catch (error) {
logger.error(`Error parsing compact conversation for endpoint ${endpoint}`, error);
logger.debug({
@ -55,6 +72,7 @@ async function buildEndpointOption(req, res, next) {
endpoint,
endpointType,
conversation: currentModelSpec.preset,
defaultParamsEndpoint,
});
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
parsedBody.iconURL = currentModelSpec.iconURL;

View file

@ -0,0 +1,237 @@
/**
* Wrap parseCompactConvo: the REAL function runs, but jest can observe
* calls and return values. Must be declared before require('./buildEndpointOption')
* so the destructured reference in the middleware captures the wrapper.
*/
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
parseCompactConvo: jest.fn((...args) => actual.parseCompactConvo(...args)),
};
});
const { EModelEndpoint, parseCompactConvo } = require('librechat-data-provider');
const mockBuildOptions = jest.fn((_endpoint, parsedBody) => ({
...parsedBody,
endpoint: _endpoint,
}));
jest.mock('~/server/services/Endpoints/azureAssistants', () => ({
buildOptions: mockBuildOptions,
}));
jest.mock('~/server/services/Endpoints/assistants', () => ({
buildOptions: mockBuildOptions,
}));
jest.mock('~/server/services/Endpoints/agents', () => ({
buildOptions: mockBuildOptions,
}));
jest.mock('~/models', () => ({
updateFilesUsage: jest.fn(),
}));
const mockGetEndpointsConfig = jest.fn();
jest.mock('~/server/services/Config', () => ({
getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args),
}));
jest.mock('@librechat/api', () => ({
handleError: jest.fn(),
}));
const buildEndpointOption = require('./buildEndpointOption');
const createReq = (body, config = {}) => ({
body,
config,
baseUrl: '/api/chat',
});
const createRes = () => ({
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
});
describe('buildEndpointOption - defaultParamsEndpoint parsing', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should pass defaultParamsEndpoint to parseCompactConvo and preserve maxOutputTokens', async () => {
mockGetEndpointsConfig.mockResolvedValue({
AnthropicClaude: {
type: EModelEndpoint.custom,
customParams: {
defaultParamsEndpoint: EModelEndpoint.anthropic,
},
},
});
const req = createReq(
{
endpoint: 'AnthropicClaude',
endpointType: EModelEndpoint.custom,
model: 'anthropic/claude-opus-4.5',
temperature: 0.7,
maxOutputTokens: 8192,
topP: 0.9,
maxContextTokens: 50000,
},
{ modelSpecs: null },
);
await buildEndpointOption(req, createRes(), jest.fn());
expect(parseCompactConvo).toHaveBeenCalledWith(
expect.objectContaining({
defaultParamsEndpoint: EModelEndpoint.anthropic,
}),
);
const parsedResult = parseCompactConvo.mock.results[0].value;
expect(parsedResult.maxOutputTokens).toBe(8192);
expect(parsedResult.topP).toBe(0.9);
expect(parsedResult.temperature).toBe(0.7);
expect(parsedResult.maxContextTokens).toBe(50000);
});
it('should strip maxOutputTokens when no defaultParamsEndpoint is configured', async () => {
mockGetEndpointsConfig.mockResolvedValue({
MyOpenRouter: {
type: EModelEndpoint.custom,
},
});
const req = createReq(
{
endpoint: 'MyOpenRouter',
endpointType: EModelEndpoint.custom,
model: 'gpt-4o',
temperature: 0.7,
maxOutputTokens: 8192,
max_tokens: 4096,
},
{ modelSpecs: null },
);
await buildEndpointOption(req, createRes(), jest.fn());
expect(parseCompactConvo).toHaveBeenCalledWith(
expect.objectContaining({
defaultParamsEndpoint: undefined,
}),
);
const parsedResult = parseCompactConvo.mock.results[0].value;
expect(parsedResult.maxOutputTokens).toBeUndefined();
expect(parsedResult.max_tokens).toBe(4096);
expect(parsedResult.temperature).toBe(0.7);
});
it('should strip bedrock region from custom endpoint without defaultParamsEndpoint', async () => {
mockGetEndpointsConfig.mockResolvedValue({
MyEndpoint: {
type: EModelEndpoint.custom,
},
});
const req = createReq(
{
endpoint: 'MyEndpoint',
endpointType: EModelEndpoint.custom,
model: 'gpt-4o',
temperature: 0.7,
region: 'us-east-1',
},
{ modelSpecs: null },
);
await buildEndpointOption(req, createRes(), jest.fn());
const parsedResult = parseCompactConvo.mock.results[0].value;
expect(parsedResult.region).toBeUndefined();
expect(parsedResult.temperature).toBe(0.7);
});
it('should pass defaultParamsEndpoint when re-parsing enforced model spec', async () => {
mockGetEndpointsConfig.mockResolvedValue({
AnthropicClaude: {
type: EModelEndpoint.custom,
customParams: {
defaultParamsEndpoint: EModelEndpoint.anthropic,
},
},
});
const modelSpec = {
name: 'claude-opus-4.5',
preset: {
endpoint: 'AnthropicClaude',
endpointType: EModelEndpoint.custom,
model: 'anthropic/claude-opus-4.5',
temperature: 0.7,
maxOutputTokens: 8192,
maxContextTokens: 50000,
},
};
const req = createReq(
{
endpoint: 'AnthropicClaude',
endpointType: EModelEndpoint.custom,
spec: 'claude-opus-4.5',
model: 'anthropic/claude-opus-4.5',
},
{
modelSpecs: {
enforce: true,
list: [modelSpec],
},
},
);
await buildEndpointOption(req, createRes(), jest.fn());
const enforcedCall = parseCompactConvo.mock.calls[1];
expect(enforcedCall[0]).toEqual(
expect.objectContaining({
defaultParamsEndpoint: EModelEndpoint.anthropic,
}),
);
const enforcedResult = parseCompactConvo.mock.results[1].value;
expect(enforcedResult.maxOutputTokens).toBe(8192);
expect(enforcedResult.temperature).toBe(0.7);
expect(enforcedResult.maxContextTokens).toBe(50000);
});
it('should fall back to OpenAI schema when getEndpointsConfig fails', async () => {
mockGetEndpointsConfig.mockRejectedValue(new Error('Config unavailable'));
const req = createReq(
{
endpoint: 'AnthropicClaude',
endpointType: EModelEndpoint.custom,
model: 'anthropic/claude-opus-4.5',
temperature: 0.7,
maxOutputTokens: 8192,
max_tokens: 4096,
},
{ modelSpecs: null },
);
await buildEndpointOption(req, createRes(), jest.fn());
expect(parseCompactConvo).toHaveBeenCalledWith(
expect.objectContaining({
defaultParamsEndpoint: undefined,
}),
);
const parsedResult = parseCompactConvo.mock.results[0].value;
expect(parsedResult.maxOutputTokens).toBeUndefined();
expect(parsedResult.max_tokens).toBe(4096);
});
});

View file

@ -0,0 +1,85 @@
const { logger } = require('@librechat/data-schemas');
const { ResourceType, PermissionTypes, Permissions } = require('librechat-data-provider');
const { getRoleByName } = require('~/models/Role');
/**
* Maps resource types to their corresponding permission types
*/
const resourceToPermissionType = {
[ResourceType.AGENT]: PermissionTypes.AGENTS,
[ResourceType.PROMPTGROUP]: PermissionTypes.PROMPTS,
[ResourceType.MCPSERVER]: PermissionTypes.MCP_SERVERS,
[ResourceType.REMOTE_AGENT]: PermissionTypes.REMOTE_AGENTS,
};
/**
* Middleware to check if user has SHARE_PUBLIC permission for a resource type
* Only enforced when request body contains `public: true`
* @param {import('express').Request} req - Express request
* @param {import('express').Response} res - Express response
* @param {import('express').NextFunction} next - Express next function
*/
const checkSharePublicAccess = async (req, res, next) => {
try {
const { public: isPublic } = req.body;
// Only check if trying to enable public sharing
if (!isPublic) {
return next();
}
const user = req.user;
if (!user || !user.role) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
const { resourceType } = req.params;
const permissionType = resourceToPermissionType[resourceType];
if (!permissionType) {
return res.status(400).json({
error: 'Bad Request',
message: `Unsupported resource type for public sharing: ${resourceType}`,
});
}
const role = await getRoleByName(user.role);
if (!role || !role.permissions) {
return res.status(403).json({
error: 'Forbidden',
message: 'No permissions configured for user role',
});
}
const resourcePerms = role.permissions[permissionType] || {};
const canSharePublic = resourcePerms[Permissions.SHARE_PUBLIC] === true;
if (!canSharePublic) {
logger.warn(
`[checkSharePublicAccess][${user.id}] User denied SHARE_PUBLIC for ${resourceType}`,
);
return res.status(403).json({
error: 'Forbidden',
message: `You do not have permission to share ${resourceType} resources publicly`,
});
}
next();
} catch (error) {
logger.error(
`[checkSharePublicAccess][${req.user?.id}] Error checking SHARE_PUBLIC permission`,
error,
);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check public sharing permissions',
});
}
};
module.exports = {
checkSharePublicAccess,
};

View file

@ -0,0 +1,164 @@
const { ResourceType, PermissionTypes, Permissions } = require('librechat-data-provider');
const { checkSharePublicAccess } = require('./checkSharePublicAccess');
const { getRoleByName } = require('~/models/Role');
jest.mock('~/models/Role');
describe('checkSharePublicAccess middleware', () => {
let mockReq;
let mockRes;
let mockNext;
beforeEach(() => {
jest.clearAllMocks();
mockReq = {
user: { id: 'user123', role: 'USER' },
params: { resourceType: ResourceType.AGENT },
body: {},
};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
mockNext = jest.fn();
});
it('should call next() when public is not true', async () => {
mockReq.body = { public: false };
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should call next() when public is undefined', async () => {
mockReq.body = { updated: [] };
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should return 401 when user is not authenticated', async () => {
mockReq.body = { public: true };
mockReq.user = null;
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
expect(mockNext).not.toHaveBeenCalled();
});
it('should return 403 when user role has no SHARE_PUBLIC permission for agents', async () => {
mockReq.body = { public: true };
mockReq.params = { resourceType: ResourceType.AGENT };
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: false,
},
},
});
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: `You do not have permission to share ${ResourceType.AGENT} resources publicly`,
});
expect(mockNext).not.toHaveBeenCalled();
});
it('should call next() when user has SHARE_PUBLIC permission for agents', async () => {
mockReq.body = { public: true };
mockReq.params = { resourceType: ResourceType.AGENT };
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
},
});
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should check prompts permission for promptgroup resource type', async () => {
mockReq.body = { public: true };
mockReq.params = { resourceType: ResourceType.PROMPTGROUP };
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PROMPTS]: {
[Permissions.SHARE_PUBLIC]: true,
},
},
});
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
});
it('should check mcp_servers permission for mcpserver resource type', async () => {
mockReq.body = { public: true };
mockReq.params = { resourceType: ResourceType.MCPSERVER };
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.MCP_SERVERS]: {
[Permissions.SHARE_PUBLIC]: true,
},
},
});
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
});
it('should return 400 for unsupported resource type', async () => {
mockReq.body = { public: true };
mockReq.params = { resourceType: 'unsupported' };
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Bad Request',
message: 'Unsupported resource type for public sharing: unsupported',
});
});
it('should return 403 when role has no permissions object', async () => {
mockReq.body = { public: true };
getRoleByName.mockResolvedValue({ permissions: null });
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(403);
});
it('should return 500 on error', async () => {
mockReq.body = { public: true };
getRoleByName.mockRejectedValue(new Error('Database error'));
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: 'Failed to check public sharing permissions',
});
});
});

View file

@ -48,7 +48,7 @@ const createForkHandler = (ip = true) => {
};
await logViolation(req, res, type, errorMessage, forkViolationScore);
res.status(429).json({ message: 'Too many conversation fork requests. Try again later' });
res.status(429).json({ message: 'Too many requests. Try again later' });
};
};

View file

@ -7,16 +7,13 @@ const { isEnabled } = require('@librechat/api');
* Switches between JWT and OpenID authentication based on cookies and environment settings
*/
const requireJwtAuth = (req, res, next) => {
// Check if token provider is specified in cookies
const cookieHeader = req.headers.cookie;
const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
// Use OpenID authentication if token provider is OpenID and OPENID_REUSE_TOKENS is enabled
if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
return passport.authenticate('openidJwt', { session: false })(req, res, next);
}
// Default to standard JWT authentication
return passport.authenticate('jwt', { session: false })(req, res, next);
};

View file

@ -51,9 +51,9 @@ describe('Access Middleware', () => {
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: false,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
@ -65,7 +65,7 @@ describe('Access Middleware', () => {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.SHARE]: false,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
@ -79,9 +79,9 @@ describe('Access Middleware', () => {
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: true,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
@ -93,7 +93,7 @@ describe('Access Middleware', () => {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.SHARE]: true,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
@ -110,7 +110,7 @@ describe('Access Middleware', () => {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.SHARE]: false,
},
// Has permissions for other types
[PermissionTypes.PROMPTS]: {
@ -241,7 +241,7 @@ describe('Access Middleware', () => {
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.SHARED_GLOBAL],
permissions: [Permissions.SHARE],
getRoleByName,
});
expect(shareResult).toBe(true);
@ -318,7 +318,7 @@ describe('Access Middleware', () => {
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARED_GLOBAL],
permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARE],
getRoleByName,
});
await middleware(req, res, next);
@ -349,7 +349,7 @@ describe('Access Middleware', () => {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.SHARE]: false,
},
},
});

View file

@ -0,0 +1,93 @@
module.exports = {
agents: () => ({ sleep: jest.fn() }),
api: (overrides = {}) => ({
isEnabled: jest.fn(),
resolveImportMaxFileSize: jest.fn(() => 262144000),
createAxiosInstance: jest.fn(() => ({
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
})),
logAxiosError: jest.fn(),
...overrides,
}),
dataSchemas: () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
createModels: jest.fn(() => ({
User: {},
Conversation: {},
Message: {},
SharedLink: {},
})),
}),
dataProvider: (overrides = {}) => ({
CacheKeys: { GEN_TITLE: 'GEN_TITLE' },
EModelEndpoint: {
azureAssistants: 'azureAssistants',
assistants: 'assistants',
},
...overrides,
}),
conversationModel: () => ({
getConvosByCursor: jest.fn(),
getConvo: jest.fn(),
deleteConvos: jest.fn(),
saveConvo: jest.fn(),
}),
toolCallModel: () => ({ deleteToolCalls: jest.fn() }),
sharedModels: () => ({
deleteAllSharedLinks: jest.fn(),
deleteConvoSharedLink: jest.fn(),
}),
requireJwtAuth: () => (req, res, next) => next(),
middlewarePassthrough: () => ({
createImportLimiters: jest.fn(() => ({
importIpLimiter: (req, res, next) => next(),
importUserLimiter: (req, res, next) => next(),
})),
createForkLimiters: jest.fn(() => ({
forkIpLimiter: (req, res, next) => next(),
forkUserLimiter: (req, res, next) => next(),
})),
configMiddleware: (req, res, next) => next(),
validateConvoAccess: (req, res, next) => next(),
}),
forkUtils: () => ({
forkConversation: jest.fn(),
duplicateConversation: jest.fn(),
}),
importUtils: () => ({ importConversations: jest.fn() }),
logStores: () => jest.fn(),
multerSetup: () => ({
storage: {},
importFileFilter: jest.fn(),
}),
multerLib: () =>
jest.fn(() => ({
single: jest.fn(() => (req, res, next) => {
req.file = { path: '/tmp/test-file.json' };
next();
}),
})),
assistantEndpoint: () => ({ initializeClient: jest.fn() }),
};

View file

@ -0,0 +1,135 @@
const express = require('express');
const request = require('supertest');
const MOCKS = '../__test-utils__/convos-route-mocks';
jest.mock('@librechat/agents', () => require(MOCKS).agents());
jest.mock('@librechat/api', () => require(MOCKS).api({ limiterCache: jest.fn(() => undefined) }));
jest.mock('@librechat/data-schemas', () => require(MOCKS).dataSchemas());
jest.mock('librechat-data-provider', () =>
require(MOCKS).dataProvider({ ViolationTypes: { FILE_UPLOAD_LIMIT: 'file_upload_limit' } }),
);
jest.mock('~/cache/logViolation', () => jest.fn().mockResolvedValue(undefined));
jest.mock('~/cache/getLogStores', () => require(MOCKS).logStores());
jest.mock('~/models/Conversation', () => require(MOCKS).conversationModel());
jest.mock('~/models/ToolCall', () => require(MOCKS).toolCallModel());
jest.mock('~/models', () => require(MOCKS).sharedModels());
jest.mock('~/server/middleware/requireJwtAuth', () => require(MOCKS).requireJwtAuth());
jest.mock('~/server/middleware', () => {
const { createForkLimiters } = jest.requireActual('~/server/middleware/limiters/forkLimiters');
return {
createImportLimiters: jest.fn(() => ({
importIpLimiter: (req, res, next) => next(),
importUserLimiter: (req, res, next) => next(),
})),
createForkLimiters,
configMiddleware: (req, res, next) => next(),
validateConvoAccess: (req, res, next) => next(),
};
});
jest.mock('~/server/utils/import/fork', () => require(MOCKS).forkUtils());
jest.mock('~/server/utils/import', () => require(MOCKS).importUtils());
jest.mock('~/server/routes/files/multer', () => require(MOCKS).multerSetup());
jest.mock('multer', () => require(MOCKS).multerLib());
jest.mock('~/server/services/Endpoints/azureAssistants', () => require(MOCKS).assistantEndpoint());
jest.mock('~/server/services/Endpoints/assistants', () => require(MOCKS).assistantEndpoint());
describe('POST /api/convos/duplicate - Rate Limiting', () => {
let app;
let duplicateConversation;
const savedEnv = {};
beforeAll(() => {
savedEnv.FORK_USER_MAX = process.env.FORK_USER_MAX;
savedEnv.FORK_USER_WINDOW = process.env.FORK_USER_WINDOW;
savedEnv.FORK_IP_MAX = process.env.FORK_IP_MAX;
savedEnv.FORK_IP_WINDOW = process.env.FORK_IP_WINDOW;
});
afterAll(() => {
for (const key of Object.keys(savedEnv)) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
});
const setupApp = () => {
jest.clearAllMocks();
jest.isolateModules(() => {
const convosRouter = require('../convos');
({ duplicateConversation } = require('~/server/utils/import/fork'));
app = express();
app.use(express.json());
app.use((req, res, next) => {
req.user = { id: 'rate-limit-test-user' };
next();
});
app.use('/api/convos', convosRouter);
});
duplicateConversation.mockResolvedValue({
conversation: { conversationId: 'duplicated-conv' },
});
};
describe('user limit', () => {
beforeEach(() => {
process.env.FORK_USER_MAX = '2';
process.env.FORK_USER_WINDOW = '1';
process.env.FORK_IP_MAX = '100';
process.env.FORK_IP_WINDOW = '1';
setupApp();
});
it('should return 429 after exceeding the user rate limit', async () => {
const userMax = parseInt(process.env.FORK_USER_MAX, 10);
for (let i = 0; i < userMax; i++) {
const res = await request(app)
.post('/api/convos/duplicate')
.send({ conversationId: 'conv-123' });
expect(res.status).toBe(201);
}
const res = await request(app)
.post('/api/convos/duplicate')
.send({ conversationId: 'conv-123' });
expect(res.status).toBe(429);
expect(res.body.message).toMatch(/too many/i);
});
});
describe('IP limit', () => {
beforeEach(() => {
process.env.FORK_USER_MAX = '100';
process.env.FORK_USER_WINDOW = '1';
process.env.FORK_IP_MAX = '2';
process.env.FORK_IP_WINDOW = '1';
setupApp();
});
it('should return 429 after exceeding the IP rate limit', async () => {
const ipMax = parseInt(process.env.FORK_IP_MAX, 10);
for (let i = 0; i < ipMax; i++) {
const res = await request(app)
.post('/api/convos/duplicate')
.send({ conversationId: 'conv-123' });
expect(res.status).toBe(201);
}
const res = await request(app)
.post('/api/convos/duplicate')
.send({ conversationId: 'conv-123' });
expect(res.status).toBe(429);
expect(res.body.message).toMatch(/too many/i);
});
});
});

Some files were not shown because too many files have changed in this diff Show more