Compare commits

..

110 commits

Author SHA1 Message Date
Danny Avila
cbdc6f6060
📦 chore: Bump NPM Audit Packages (#12227)
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 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
435 changed files with 38386 additions and 11915 deletions

View file

@ -65,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 #
#=============#
@ -193,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
@ -243,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
@ -514,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=
@ -658,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 #
@ -672,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 #
@ -844,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

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.3-rc1
# v0.8.3
# Base node image
FROM node:20-alpine AS node

View file

@ -1,5 +1,5 @@
# Dockerfile.multi
# v0.8.3-rc1
# 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"/>

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

@ -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

@ -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",
@ -61,7 +61,7 @@
"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",
@ -74,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",
@ -87,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",
@ -107,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",
@ -143,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": [
{
@ -156,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,4 +1,3 @@
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
const { v4 } = require('uuid');
@ -6,12 +5,7 @@ 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,
@ -59,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() || ''
);
}
/**
@ -117,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) {
@ -131,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
@ -326,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;
@ -363,6 +289,7 @@ async function recordTokenUsage({ usageMetadata, req, userId, conversationId, mo
{
user: userId,
model,
messageId,
conversationId,
context: 'image_generation',
balance,
@ -390,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({
@ -432,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,
@ -447,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,
@ -480,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);
@ -509,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 = [
{
@ -567,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,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

@ -7,6 +7,7 @@ const {
} = require('@librechat/agents');
const {
checkAccess,
toolkitParent,
createSafeUser,
mcpToolPattern,
loadWebSearchAuth,
@ -207,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,
@ -222,7 +223,6 @@ const loadTools = async ({
isAgent: !!agent,
req: options.req,
imageFiles,
processFileURL: options.processFileURL,
userId: user,
fileStrategy,
});
@ -370,8 +370,16 @@ const loadTools = async ({
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

@ -47,7 +47,7 @@ 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,

View file

@ -236,8 +236,12 @@ async function performSync(flowManager, flowId, flowType) {
const messageCount = messageProgress.totalDocuments;
const messagesIndexed = messageProgress.totalProcessed;
const unindexedMessages = messageCount - messagesIndexed;
const noneIndexed = messagesIndexed === 0 && unindexedMessages > 0;
if (settingsUpdated || unindexedMessages > syncThreshold) {
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;
@ -261,9 +265,13 @@ async function performSync(flowManager, flowId, flowType) {
const convoCount = convoProgress.totalDocuments;
const convosIndexed = convoProgress.totalProcessed;
const unindexedConvos = convoCount - convosIndexed;
if (settingsUpdated || unindexedConvos > syncThreshold) {
const noneConvosIndexed = convosIndexed === 0 && unindexedConvos > 0;
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;

View file

@ -462,4 +462,69 @@ describe('performSync() - syncThreshold logic', () => {
);
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

@ -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

@ -228,7 +228,7 @@ module.exports = {
},
],
};
} catch (err) {
} catch (_err) {
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
}
if (cursorFilter) {
@ -361,6 +361,7 @@ module.exports = {
const deleteMessagesResult = await deleteMessages({
conversationId: { $in: conversationIds },
user,
});
return { ...deleteConvoResult, messages: deleteMessagesResult };

View file

@ -549,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

@ -1,140 +1,7 @@
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) {
@ -145,8 +12,8 @@ function calculateTokenValue(txn) {
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;
}
}
@ -321,11 +188,11 @@ function calculateStructuredTokenValue(txn) {
}
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, premiumTokenValues, tokenValues } = require('./tx');
const { createTransaction, createStructuredTransaction } = require('./Transaction');
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
const { Balance, Transaction } = require('~/db/models');
let mongoServer;
@ -823,6 +825,139 @@ describe('Premium Token Pricing Integration Tests', () => {
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;
@ -852,3 +987,339 @@ describe('Premium Token Pricing Integration Tests', () => {
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

@ -878,6 +878,135 @@ describe('spendTokens', () => {
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({

View file

@ -4,31 +4,18 @@ const defaultRate = 6;
/**
* Token Pricing Configuration
*
* IMPORTANT: Key Ordering for Pattern Matching
* ============================================
* The `findMatchingPattern` function iterates through object keys in REVERSE order
* (last-defined keys are checked first) and uses `modelName.includes(key)` for matching.
* 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.
*
* This means:
* 1. BASE PATTERNS must be defined FIRST (e.g., "kimi", "moonshot")
* 2. SPECIFIC PATTERNS must be defined AFTER their base patterns (e.g., "kimi-k2", "kimi-k2.5")
*
* Example ordering for Kimi models:
* kimi: { prompt: 0.6, completion: 2.5 }, // Base pattern - checked last
* 'kimi-k2': { prompt: 0.6, completion: 2.5 }, // More specific - checked before "kimi"
* 'kimi-k2.5': { prompt: 0.6, completion: 3.0 }, // Most specific - checked first
*
* Why this matters:
* - Model name "kimi-k2.5" contains both "kimi" and "kimi-k2" as substrings
* - If "kimi" were checked first, it would incorrectly match and return wrong pricing
* - By defining specific patterns AFTER base patterns, they're checked first in reverse iteration
* 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.
*
* When adding new model families:
* 1. Define the base/generic pattern first
* 2. Define increasingly specific patterns after
* 3. Ensure no pattern is a substring of another that should match differently
*/
/**
@ -150,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 },
@ -200,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 },
@ -314,6 +308,29 @@ const cacheTokenValues = {
'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 },
@ -330,6 +347,10 @@ const cacheTokenValues = {
'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 },
};
/**
@ -340,6 +361,7 @@ const cacheTokenValues = {
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 },
};
/**

View file

@ -52,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');
@ -138,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');
@ -336,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,
@ -360,6 +396,48 @@ describe('getMultiplier', () => {
);
});
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', () => {
const valueKey = getValueKey('gpt-4o-2024-08-06');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
@ -1326,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({
@ -1345,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',
@ -1389,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',
@ -1432,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', () => {

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.3-rc1",
"version": "v0.8.3",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@ -44,13 +44,14 @@
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.1.50",
"@librechat/agents": "^3.1.55",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@modelcontextprotocol/sdk": "^1.26.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@node-saml/passport-saml": "^5.1.0",
"@smithy/node-http-handler": "^4.4.5",
"ai-tokenizer": "^1.0.6",
"axios": "^1.13.5",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
@ -63,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",
@ -80,13 +81,14 @@
"klona": "^2.0.6",
"librechat-data-provider": "*",
"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",
@ -102,14 +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.18.2",
"undici": "^7.24.1",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^5.0.0",
"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,7 +18,7 @@ const {
findUser,
} = require('~/models');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { getOpenIdConfig } = require('~/strategies');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const registrationController = async (req, res) => {
try {
@ -87,7 +87,7 @@ const refreshController = async (req, res) => {
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',
@ -196,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({
@ -212,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

@ -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,
@ -34,6 +35,7 @@ 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');
@ -241,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

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

@ -82,6 +82,13 @@ jest.mock('~/models/spendTokens', () => ({
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()),
}));
@ -103,6 +110,8 @@ jest.mock('~/models/Agent', () => ({
getAgents: jest.fn().mockResolvedValue([]),
}));
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
jest.mock('~/models', () => ({
getFiles: jest.fn(),
getUserKey: jest.fn(),
@ -112,6 +121,8 @@ jest.mock('~/models', () => ({
getUserCodeFiles: jest.fn(),
getToolFilesByIds: jest.fn(),
getCodeGeneratedFiles: jest.fn(),
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
}));
describe('OpenAIChatCompletionController', () => {
@ -155,7 +166,15 @@ describe('OpenAIChatCompletionController', () => {
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
{ spendTokens: mockSpendTokens, spendStructuredTokens: mockSpendStructuredTokens },
{
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
bulkWriteOps: {
insertMany: mockBulkInsertTransactions,
updateBalance: mockUpdateBalance,
},
},
expect.objectContaining({
user: 'user-123',
conversationId: expect.any(String),
@ -182,12 +201,18 @@ describe('OpenAIChatCompletionController', () => {
);
});
it('should pass spendTokens and spendStructuredTokens as dependencies', async () => {
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 () => {

View file

@ -106,6 +106,13 @@ jest.mock('~/models/spendTokens', () => ({
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()),
@ -131,6 +138,8 @@ jest.mock('~/models/Agent', () => ({
getAgents: jest.fn().mockResolvedValue([]),
}));
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
jest.mock('~/models', () => ({
getFiles: jest.fn(),
getUserKey: jest.fn(),
@ -141,6 +150,8 @@ jest.mock('~/models', () => ({
getUserCodeFiles: jest.fn(),
getToolFilesByIds: jest.fn(),
getCodeGeneratedFiles: jest.fn(),
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
}));
describe('createResponse controller', () => {
@ -184,7 +195,15 @@ describe('createResponse controller', () => {
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
{ spendTokens: mockSpendTokens, spendStructuredTokens: mockSpendStructuredTokens },
{
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
bulkWriteOps: {
insertMany: mockBulkInsertTransactions,
updateBalance: mockUpdateBalance,
},
},
expect.objectContaining({
user: 'user-123',
conversationId: expect.any(String),
@ -209,12 +228,18 @@ describe('createResponse controller', () => {
);
});
it('should pass spendTokens and spendStructuredTokens as dependencies', async () => {
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 () => {
@ -244,7 +269,15 @@ describe('createResponse controller', () => {
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
{ spendTokens: mockSpendTokens, spendStructuredTokens: mockSpendStructuredTokens },
{
spendTokens: mockSpendTokens,
spendStructuredTokens: mockSpendStructuredTokens,
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
bulkWriteOps: {
insertMany: mockBulkInsertTransactions,
updateBalance: mockUpdateBalance,
},
},
expect.objectContaining({
user: 'user-123',
context: 'message',

View file

@ -13,11 +13,12 @@ const {
createSafeUser,
initializeAgent,
getBalanceConfig,
getProviderConfig,
omitTitleOptions,
getProviderConfig,
memoryInstructions,
applyContextToAgent,
createTokenCounter,
applyContextToAgent,
recordCollectedUsage,
GenerationJobManager,
getTransactionsConfig,
createMemoryProcessor,
@ -45,6 +46,8 @@ const {
} = 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');
@ -624,82 +627,29 @@ class AgentClient extends BaseClient {
context = 'message',
collectedUsage = this.collectedUsage,
}) {
if (!collectedUsage || !collectedUsage.length) {
return;
}
// Use first entry's input_tokens as the base input (represents initial user message context)
// 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);
// Sum output_tokens directly from all entries - works for both sequential and parallel execution
// This avoids the incremental calculation that produced negative values for parallel agents
let total_output_tokens = 0;
for (const usage of collectedUsage) {
if (!usage) {
continue;
}
// Support both OpenAI format (input_token_details) and Anthropic format (cache_*_input_tokens)
const cache_creation =
Number(usage.input_token_details?.cache_creation) ||
Number(usage.cache_creation_input_tokens) ||
0;
const cache_read =
Number(usage.input_token_details?.cache_read) || Number(usage.cache_read_input_tokens) || 0;
// Accumulate output tokens for the usage summary
total_output_tokens += Number(usage.output_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 (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: total_output_tokens,
};
}
/**
@ -891,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
@ -1147,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',
@ -1185,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,
@ -1203,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,
@ -1218,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

@ -263,6 +263,7 @@ describe('AgentClient - titleConvo', () => {
transactions: {
enabled: true,
},
messageId: 'response-123',
});
});

View file

@ -25,6 +25,7 @@ const { loadAgentTools, loadToolsForExecution } = require('~/server/services/Too
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');
@ -129,7 +130,6 @@ const OpenAIChatCompletionController = async (req, res) => {
const appConfig = req.config;
const requestStartTime = Date.now();
// Validate request
const validation = validateRequest(req.body);
if (isChatCompletionValidationFailure(validation)) {
return sendErrorResponse(res, 400, validation.error);
@ -150,20 +150,20 @@ const OpenAIChatCompletionController = async (req, res) => {
);
}
// Generate IDs
const requestId = `chatcmpl-${nanoid()}`;
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,
requestId: responseId,
model: agentId,
};
logger.debug(
`[OpenAI API] Request ${requestId} started for agent ${agentId}, stream: ${request.stream}`,
`[OpenAI API] Response ${responseId} started for agent ${agentId}, stream: ${request.stream}`,
);
// Set up abort controller
@ -450,11 +450,11 @@ const OpenAIChatCompletionController = async (req, res) => {
agents: [primaryConfig],
messages: formattedMessages,
indexTokenCountMap,
runId: requestId,
runId: responseId,
signal: abortController.signal,
customHandlers: handlers,
requestBody: {
messageId: requestId,
messageId: responseId,
conversationId,
},
user: { id: userId },
@ -471,6 +471,10 @@ const OpenAIChatCompletionController = async (req, res) => {
thread_id: conversationId,
user_id: userId,
user: createSafeUser(req.user),
requestBody: {
messageId: responseId,
conversationId,
},
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
signal: abortController.signal,
@ -490,12 +494,18 @@ const OpenAIChatCompletionController = async (req, res) => {
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
recordCollectedUsage(
{ spendTokens, spendStructuredTokens },
{
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,
@ -509,7 +519,7 @@ const OpenAIChatCompletionController = async (req, res) => {
if (isStreaming) {
sendFinalChunk(handlerConfig);
res.end();
logger.debug(`[OpenAI API] Request ${requestId} completed in ${duration}ms (streaming)`);
logger.debug(`[OpenAI API] Response ${responseId} completed in ${duration}ms (streaming)`);
// Wait for artifact processing after response ends (non-blocking)
if (artifactPromises.length > 0) {
@ -548,7 +558,9 @@ const OpenAIChatCompletionController = async (req, res) => {
usage,
);
res.json(response);
logger.debug(`[OpenAI API] Request ${requestId} completed in ${duration}ms (non-streaming)`);
logger.debug(
`[OpenAI API] Response ${responseId} completed in ${duration}ms (non-streaming)`,
);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An error occurred';

View file

@ -2,23 +2,37 @@
* Tests for AgentClient.recordCollectedUsage
*
* This is a critical function that handles token spending for agent LLM calls.
* It must correctly handle:
* - Sequential execution (single agent with tool calls)
* - Parallel execution (multiple agents with independent inputs)
* - Cache token handling (OpenAI and Anthropic formats)
* The client now delegates to the TS recordCollectedUsage from @librechat/api,
* passing pricing and bulkWriteOps deps.
*/
const { EModelEndpoint } = require('librechat-data-provider');
// Mock dependencies before requiring the module
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(),
@ -39,6 +53,14 @@ jest.mock('@librechat/agents', () => ({
}),
}));
jest.mock('@librechat/api', () => {
const actual = jest.requireActual('@librechat/api');
return {
...actual,
recordCollectedUsage: (...args) => mockRecordCollectedUsage(...args),
};
});
const AgentClient = require('./client');
describe('AgentClient - recordCollectedUsage', () => {
@ -74,31 +96,66 @@ describe('AgentClient - recordCollectedUsage', () => {
});
describe('basic functionality', () => {
it('should return early if collectedUsage is empty', async () => {
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(mockSpendTokens).not.toHaveBeenCalled();
expect(mockSpendStructuredTokens).not.toHaveBeenCalled();
expect(client.usage).toBeUndefined();
});
it('should return early if collectedUsage is null', async () => {
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(mockSpendTokens).not.toHaveBeenCalled();
expect(client.usage).toBeUndefined();
});
it('should handle single usage entry correctly', async () => {
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
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,
@ -106,521 +163,122 @@ describe('AgentClient - recordCollectedUsage', () => {
transactions: { enabled: true },
});
expect(mockSpendTokens).toHaveBeenCalledTimes(1);
expect(mockSpendTokens).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: 'convo-123',
user: 'user-123',
model: 'gpt-4',
}),
{ promptTokens: 100, completionTokens: 50 },
);
expect(client.usage.input_tokens).toBe(100);
expect(client.usage.output_tokens).toBe(50);
});
it('should skip null entries in collectedUsage', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
null,
{ input_tokens: 200, output_tokens: 60, model: 'gpt-4' },
];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockSpendTokens).toHaveBeenCalledTimes(2);
expect(client.usage).toEqual({ input_tokens: 200, output_tokens: 75 });
});
});
describe('sequential execution (single agent with tool calls)', () => {
it('should calculate tokens correctly for sequential tool calls', async () => {
// Sequential flow: output of call N becomes part of input for call N+1
// Call 1: input=100, output=50
// Call 2: input=150 (100+50), output=30
// Call 3: input=180 (150+30), output=20
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(mockSpendTokens).toHaveBeenCalledTimes(3);
// Total output should be sum of all output_tokens: 50 + 30 + 20 = 100
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); // First entry's input
expect(client.usage.input_tokens).toBe(100);
});
});
describe('parallel execution (multiple agents)', () => {
it('should handle parallel agents with independent input tokens', async () => {
// Parallel agents have INDEPENDENT input tokens (not cumulative)
// Agent A: input=100, output=50
// Agent B: input=80, output=40 (different context, not 100+50)
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(mockSpendTokens).toHaveBeenCalledTimes(2);
// Expected total output: 50 + 40 = 90
// output_tokens must be positive and should reflect total output
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(client.usage.output_tokens).toBe(90);
expect(client.usage.output_tokens).toBeGreaterThan(0);
});
it('should NOT produce negative output_tokens for parallel execution', async () => {
// Critical bug scenario: parallel agents where second agent has LOWER input tokens
/** 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 },
});
// output_tokens MUST be positive for proper token tracking
expect(client.usage.output_tokens).toBeGreaterThan(0);
// Correct value should be 100 + 30 = 130
});
it('should calculate correct total output for parallel agents', async () => {
// Three parallel agents with independent contexts
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 120, output_tokens: 60, model: 'gpt-4-turbo' },
{ input_tokens: 80, output_tokens: 40, model: 'claude-3' },
];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockSpendTokens).toHaveBeenCalledTimes(3);
// Total output should be 50 + 60 + 40 = 150
expect(client.usage.output_tokens).toBe(150);
});
it('should handle worst-case parallel scenario without negative tokens', async () => {
// Extreme case: first agent has very high input, subsequent have low
const collectedUsage = [
{ input_tokens: 1000, output_tokens: 500, model: 'gpt-4' },
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 50, output_tokens: 25, model: 'gpt-4' },
];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
// Must be positive, should be 500 + 50 + 25 = 575
expect(client.usage.output_tokens).toBeGreaterThan(0);
expect(client.usage.output_tokens).toBe(575);
expect(client.usage.output_tokens).toBe(130);
});
});
describe('real-world scenarios', () => {
it('should correctly sum output tokens for sequential tool calls with growing context', async () => {
// Real production data: Claude Opus with multiple tool calls
// Context grows as tool results are added, but output_tokens should only count model generations
it('should correctly handle sequential tool calls with growing context', async () => {
const collectedUsage = [
{
input_tokens: 31596,
output_tokens: 151,
total_tokens: 31747,
input_token_details: { cache_read: 0, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 35368,
output_tokens: 150,
total_tokens: 35518,
input_token_details: { cache_read: 0, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 58362,
output_tokens: 295,
total_tokens: 58657,
input_token_details: { cache_read: 0, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 112604,
output_tokens: 193,
total_tokens: 112797,
input_token_details: { cache_read: 0, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 257440,
output_tokens: 2217,
total_tokens: 259657,
input_token_details: { cache_read: 0, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
{ 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 },
});
// input_tokens should be first entry's input (initial context)
expect(client.usage.input_tokens).toBe(31596);
// output_tokens should be sum of all model outputs: 151 + 150 + 295 + 193 + 2217 = 3006
// NOT the inflated value from incremental calculation (338,559)
expect(client.usage.output_tokens).toBe(3006);
// Verify spendTokens was called for each entry with correct values
expect(mockSpendTokens).toHaveBeenCalledTimes(5);
expect(mockSpendTokens).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ model: 'claude-opus-4-5-20251101' }),
{ promptTokens: 31596, completionTokens: 151 },
);
expect(mockSpendTokens).toHaveBeenNthCalledWith(
5,
expect.objectContaining({ model: 'claude-opus-4-5-20251101' }),
{ promptTokens: 257440, completionTokens: 2217 },
);
});
it('should handle single followup message correctly', async () => {
// Real production data: followup to the above conversation
const collectedUsage = [
{
input_tokens: 263406,
output_tokens: 257,
total_tokens: 263663,
input_token_details: { cache_read: 0, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(client.usage.input_tokens).toBe(263406);
expect(client.usage.output_tokens).toBe(257);
expect(mockSpendTokens).toHaveBeenCalledTimes(1);
expect(mockSpendTokens).toHaveBeenCalledWith(
expect.objectContaining({ model: 'claude-opus-4-5-20251101' }),
{ promptTokens: 263406, completionTokens: 257 },
);
});
it('should ensure output_tokens > 0 check passes for BaseClient.sendMessage', async () => {
// This verifies the fix for the duplicate token spending bug
// BaseClient.sendMessage checks: if (usage != null && Number(usage[this.outputTokensKey]) > 0)
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',
},
];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
const usage = client.getStreamUsage();
// The check that was failing before the fix
expect(usage).not.toBeNull();
expect(Number(usage.output_tokens)).toBeGreaterThan(0);
// Verify correct value
expect(usage.output_tokens).toBe(301); // 151 + 150
});
it('should correctly handle cache tokens with multiple tool calls', async () => {
// Real production data: Claude Opus with cache tokens (prompt caching)
// First entry has cache_creation, subsequent entries have cache_read
it('should correctly handle cache tokens', async () => {
const collectedUsage = [
{
input_tokens: 788,
output_tokens: 163,
total_tokens: 951,
input_token_details: { cache_read: 0, cache_creation: 30808 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 3802,
output_tokens: 149,
total_tokens: 3951,
input_token_details: { cache_read: 30808, cache_creation: 768 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 26808,
output_tokens: 225,
total_tokens: 27033,
input_token_details: { cache_read: 31576, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 80912,
output_tokens: 204,
total_tokens: 81116,
input_token_details: { cache_read: 31576, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 136454,
output_tokens: 206,
total_tokens: 136660,
input_token_details: { cache_read: 31576, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 146316,
output_tokens: 224,
total_tokens: 146540,
input_token_details: { cache_read: 31576, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 150402,
output_tokens: 1248,
total_tokens: 151650,
input_token_details: { cache_read: 31576, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 156268,
output_tokens: 139,
total_tokens: 156407,
input_token_details: { cache_read: 31576, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
{
input_tokens: 167126,
output_tokens: 2961,
total_tokens: 170087,
input_token_details: { cache_read: 31576, cache_creation: 0 },
model: 'claude-opus-4-5-20251101',
},
];
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 31596, output_tokens: 163 });
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
// input_tokens = first entry's input + cache_creation + cache_read
// = 788 + 30808 + 0 = 31596
expect(client.usage.input_tokens).toBe(31596);
// output_tokens = sum of all output_tokens
// = 163 + 149 + 225 + 204 + 206 + 224 + 1248 + 139 + 2961 = 5519
expect(client.usage.output_tokens).toBe(5519);
// First 2 entries have cache tokens, should use spendStructuredTokens
// Remaining 7 entries have cache_read but no cache_creation, still structured
expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(9);
expect(mockSpendTokens).toHaveBeenCalledTimes(0);
// Verify first entry uses structured tokens with cache_creation
expect(mockSpendStructuredTokens).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ model: 'claude-opus-4-5-20251101' }),
{
promptTokens: { input: 788, write: 30808, read: 0 },
completionTokens: 163,
},
);
// Verify second entry uses structured tokens with both cache_creation and cache_read
expect(mockSpendStructuredTokens).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ model: 'claude-opus-4-5-20251101' }),
{
promptTokens: { input: 3802, write: 768, read: 30808 },
completionTokens: 149,
},
);
});
});
describe('cache token handling', () => {
it('should handle OpenAI format cache tokens (input_token_details)', async () => {
const collectedUsage = [
{
input_tokens: 100,
output_tokens: 50,
model: 'gpt-4',
input_token_details: {
cache_creation: 20,
cache_read: 10,
},
},
];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1);
expect(mockSpendStructuredTokens).toHaveBeenCalledWith(
expect.objectContaining({ model: 'gpt-4' }),
{
promptTokens: {
input: 100,
write: 20,
read: 10,
},
completionTokens: 50,
},
);
});
it('should handle Anthropic format cache tokens (cache_*_input_tokens)', async () => {
const collectedUsage = [
{
input_tokens: 100,
output_tokens: 50,
model: 'claude-3',
cache_creation_input_tokens: 25,
cache_read_input_tokens: 15,
},
];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1);
expect(mockSpendStructuredTokens).toHaveBeenCalledWith(
expect.objectContaining({ model: 'claude-3' }),
{
promptTokens: {
input: 100,
write: 25,
read: 15,
},
completionTokens: 50,
},
);
});
it('should use spendTokens for entries without cache tokens', async () => {
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockSpendTokens).toHaveBeenCalledTimes(1);
expect(mockSpendStructuredTokens).not.toHaveBeenCalled();
});
it('should handle mixed cache and non-cache entries', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{
input_tokens: 150,
output_tokens: 30,
model: 'gpt-4',
input_token_details: { cache_creation: 10, cache_read: 5 },
},
{ input_tokens: 200, output_tokens: 20, model: 'gpt-4' },
];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockSpendTokens).toHaveBeenCalledTimes(2);
expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1);
});
it('should include cache tokens in total input calculation', async () => {
const collectedUsage = [
{
input_tokens: 100,
output_tokens: 50,
model: 'gpt-4',
input_token_details: {
cache_creation: 20,
cache_read: 10,
},
},
];
await client.recordCollectedUsage({
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
// Total input should include cache tokens: 100 + 20 + 10 = 130
expect(client.usage.input_tokens).toBe(130);
expect(client.usage.output_tokens).toBe(163);
});
});
describe('model fallback', () => {
it('should use usage.model when available', async () => {
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4-turbo' }];
await client.recordCollectedUsage({
model: 'fallback-model',
collectedUsage,
balance: { enabled: true },
transactions: { enabled: true },
});
expect(mockSpendTokens).toHaveBeenCalledWith(
expect.objectContaining({ model: 'gpt-4-turbo' }),
expect.any(Object),
);
});
it('should fallback to param model when usage.model is missing', async () => {
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({
@ -630,14 +288,13 @@ describe('AgentClient - recordCollectedUsage', () => {
transactions: { enabled: true },
});
expect(mockSpendTokens).toHaveBeenCalledWith(
expect.objectContaining({ model: 'param-model' }),
expect.any(Object),
);
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({
@ -646,13 +303,12 @@ describe('AgentClient - recordCollectedUsage', () => {
transactions: { enabled: true },
});
expect(mockSpendTokens).toHaveBeenCalledWith(
expect.objectContaining({ model: 'client-model' }),
expect.any(Object),
);
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({
@ -661,15 +317,14 @@ describe('AgentClient - recordCollectedUsage', () => {
transactions: { enabled: true },
});
expect(mockSpendTokens).toHaveBeenCalledWith(
expect.objectContaining({ model: 'gpt-4' }),
expect.any(Object),
);
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({
@ -679,10 +334,7 @@ describe('AgentClient - recordCollectedUsage', () => {
});
const usage = client.getStreamUsage();
expect(usage).toEqual({
input_tokens: 100,
output_tokens: 50,
});
expect(usage).toEqual({ input_tokens: 100, output_tokens: 50 });
});
it('should return undefined before recordCollectedUsage is called', () => {
@ -690,9 +342,9 @@ describe('AgentClient - recordCollectedUsage', () => {
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 () => {
// This test verifies the usage will pass the check in BaseClient.sendMessage:
// if (usage != null && Number(usage[this.outputTokensKey]) > 0)
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' },

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');
@ -252,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;
}
@ -639,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

@ -38,6 +38,7 @@ const { loadAgentTools, loadToolsForExecution } = require('~/server/services/Too
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');
@ -486,6 +487,10 @@ const createResponse = async (req, res) => {
thread_id: conversationId,
user_id: userId,
user: createSafeUser(req.user),
requestBody: {
messageId: responseId,
conversationId,
},
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
signal: abortController.signal,
@ -505,12 +510,18 @@ const createResponse = async (req, res) => {
const balanceConfig = getBalanceConfig(req.config);
const transactionsConfig = getTransactionsConfig(req.config);
recordCollectedUsage(
{ spendTokens, spendStructuredTokens },
{
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,
@ -630,6 +641,10 @@ const createResponse = async (req, res) => {
thread_id: conversationId,
user_id: userId,
user: createSafeUser(req.user),
requestBody: {
messageId: responseId,
conversationId,
},
...(userMCPAuthMap != null && { userMCPAuthMap }),
},
signal: abortController.signal,
@ -649,12 +664,18 @@ const createResponse = async (req, res) => {
const balanceConfig = getBalanceConfig(req.config);
const transactionsConfig = getTransactionsConfig(req.config);
recordCollectedUsage(
{ spendTokens, spendStructuredTokens },
{
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,

View file

@ -530,10 +530,10 @@ const getListAgentsHandler = async (req, res) => {
*/
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
const refreshKey = `${userId}:agents_avatar_refresh`;
const alreadyChecked = await cache.get(refreshKey);
if (alreadyChecked) {
logger.debug('[/Agents] S3 avatar refresh already checked, skipping');
} else {
let cachedRefresh = await cache.get(refreshKey);
const isValidCachedRefresh =
cachedRefresh != null && typeof cachedRefresh === 'object' && cachedRefresh.urlCache != null;
if (!isValidCachedRefresh) {
try {
const fullList = await getListAgentsByAccess({
accessibleIds,
@ -541,16 +541,19 @@ const getListAgentsHandler = async (req, res) => {
limit: MAX_AVATAR_REFRESH_AGENTS,
after: null,
});
await refreshListAvatars({
const { urlCache } = await refreshListAvatars({
agents: fullList?.data ?? [],
userId,
refreshS3Url,
updateAgent,
});
await cache.set(refreshKey, true, Time.THIRTY_MINUTES);
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
@ -568,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;
@ -658,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

@ -59,6 +59,7 @@ jest.mock('~/models', () => ({
const mockCache = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
};
jest.mock('~/cache', () => ({
getLogStores: jest.fn(() => mockCache),
@ -1309,7 +1310,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
});
test('should skip avatar refresh if cache hit', async () => {
mockCache.get.mockResolvedValue(true);
mockCache.get.mockResolvedValue({ urlCache: {} });
findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
@ -1348,8 +1349,12 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
// Verify S3 URL was refreshed
expect(refreshS3Url).toHaveBeenCalled();
// Verify cache was set
expect(mockCache.set).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();
@ -1563,5 +1568,83 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
// 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

@ -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);
@ -31,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

@ -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,6 +298,7 @@ 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);
@ -310,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());
@ -324,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) => {
@ -343,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');
@ -162,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',
@ -179,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);
@ -201,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();
}
});
};

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,17 +1,19 @@
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 { saveMessage, getConvo } = require('~/models');
const { abortRun } = require('./abortRun');
/**
@ -27,62 +29,35 @@ const { abortRun } = require('./abortRun');
* @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 }) {
async function spendCollectedUsage({
userId,
conversationId,
collectedUsage,
fallbackModel,
messageId,
}) {
if (!collectedUsage || collectedUsage.length === 0) {
return;
}
const spendPromises = [];
for (const usage of collectedUsage) {
if (!usage) {
continue;
}
// Support both OpenAI format (input_token_details) and Anthropic format (cache_*_input_tokens)
const cache_creation =
Number(usage.input_token_details?.cache_creation) ||
Number(usage.cache_creation_input_tokens) ||
0;
const cache_read =
Number(usage.input_token_details?.cache_read) || Number(usage.cache_read_input_tokens) || 0;
const txMetadata = {
context: 'abort',
conversationId,
await recordCollectedUsage(
{
spendTokens,
spendStructuredTokens,
pricing: { getMultiplier, getCacheMultiplier },
bulkWriteOps: { insertMany: bulkInsertTransactions, updateBalance },
},
{
user: userId,
model: usage.model ?? fallbackModel,
};
if (cache_creation > 0 || cache_read > 0) {
spendPromises.push(
spendStructuredTokens(txMetadata, {
promptTokens: {
input: usage.input_tokens,
write: cache_creation,
read: cache_read,
},
completionTokens: usage.output_tokens,
}).catch((err) => {
logger.error('[abortMiddleware] Error spending structured tokens for abort', err);
}),
);
continue;
}
spendPromises.push(
spendTokens(txMetadata, {
promptTokens: usage.input_tokens,
completionTokens: usage.output_tokens,
}).catch((err) => {
logger.error('[abortMiddleware] Error spending tokens for abort', err);
}),
);
}
// Wait for all token spending to complete
await Promise.all(spendPromises);
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,
@ -144,6 +119,7 @@ async function abortMessage(req, res) {
conversationId: jobData?.conversationId,
collectedUsage,
fallbackModel: jobData?.model,
messageId: jobData?.responseMessageId,
});
} else {
// Fallback: no collected usage, use text-based token counting for primary model only
@ -292,4 +268,5 @@ const handleAbortError = async (res, req, error, data) => {
module.exports = {
handleAbort,
handleAbortError,
spendCollectedUsage,
};

View file

@ -4,16 +4,32 @@
* 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(),
@ -30,6 +46,7 @@ jest.mock('@librechat/api', () => ({
GenerationJobManager: {
abortJob: jest.fn(),
},
recordCollectedUsage: mockRecordCollectedUsage,
sanitizeMessageForTransmit: jest.fn((msg) => msg),
}));
@ -49,94 +66,27 @@ 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(),
}));
// Import the module after mocks are set up
// We need to extract the spendCollectedUsage function for testing
// Since it's not exported, we'll test it through the handleAbort flow
const { spendCollectedUsage } = require('./abortMiddleware');
describe('abortMiddleware - spendCollectedUsage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('spendCollectedUsage logic', () => {
// Since spendCollectedUsage is not exported, we test the logic directly
// by replicating the function here for unit testing
const spendCollectedUsage = async ({
userId,
conversationId,
collectedUsage,
fallbackModel,
}) => {
if (!collectedUsage || collectedUsage.length === 0) {
return;
}
const spendPromises = [];
for (const usage of collectedUsage) {
if (!usage) {
continue;
}
const cache_creation =
Number(usage.input_token_details?.cache_creation) ||
Number(usage.cache_creation_input_tokens) ||
0;
const cache_read =
Number(usage.input_token_details?.cache_read) ||
Number(usage.cache_read_input_tokens) ||
0;
const txMetadata = {
context: 'abort',
conversationId,
user: userId,
model: usage.model ?? fallbackModel,
};
if (cache_creation > 0 || cache_read > 0) {
spendPromises.push(
mockSpendStructuredTokens(txMetadata, {
promptTokens: {
input: usage.input_tokens,
write: cache_creation,
read: cache_read,
},
completionTokens: usage.output_tokens,
}).catch(() => {
// Log error but don't throw
}),
);
continue;
}
spendPromises.push(
mockSpendTokens(txMetadata, {
promptTokens: usage.input_tokens,
completionTokens: usage.output_tokens,
}).catch(() => {
// Log error but don't throw
}),
);
}
// Wait for all token spending to complete
await Promise.all(spendPromises);
// Clear the array to prevent double-spending
collectedUsage.length = 0;
};
describe('spendCollectedUsage delegation', () => {
it('should return early if collectedUsage is empty', async () => {
await spendCollectedUsage({
userId: 'user-123',
@ -145,8 +95,7 @@ describe('abortMiddleware - spendCollectedUsage', () => {
fallbackModel: 'gpt-4',
});
expect(mockSpendTokens).not.toHaveBeenCalled();
expect(mockSpendStructuredTokens).not.toHaveBeenCalled();
expect(mockRecordCollectedUsage).not.toHaveBeenCalled();
});
it('should return early if collectedUsage is null', async () => {
@ -157,28 +106,10 @@ describe('abortMiddleware - spendCollectedUsage', () => {
fallbackModel: 'gpt-4',
});
expect(mockSpendTokens).not.toHaveBeenCalled();
expect(mockSpendStructuredTokens).not.toHaveBeenCalled();
expect(mockRecordCollectedUsage).not.toHaveBeenCalled();
});
it('should skip null entries in collectedUsage', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
null,
{ input_tokens: 200, output_tokens: 60, model: 'gpt-4' },
];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
});
expect(mockSpendTokens).toHaveBeenCalledTimes(2);
});
it('should spend tokens for single model', async () => {
it('should call recordCollectedUsage with abort context and full deps', async () => {
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
await spendCollectedUsage({
@ -186,21 +117,35 @@ describe('abortMiddleware - spendCollectedUsage', () => {
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
messageId: 'msg-123',
});
expect(mockSpendTokens).toHaveBeenCalledTimes(1);
expect(mockSpendTokens).toHaveBeenCalledWith(
expect.objectContaining({
context: 'abort',
conversationId: 'convo-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',
}),
{ promptTokens: 100, completionTokens: 50 },
},
);
});
it('should spend tokens for multiple models (parallel agents)', async () => {
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' },
@ -214,136 +159,17 @@ describe('abortMiddleware - spendCollectedUsage', () => {
fallbackModel: 'gpt-4',
});
expect(mockSpendTokens).toHaveBeenCalledTimes(3);
// Verify each model was called
expect(mockSpendTokens).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ model: 'gpt-4' }),
{ promptTokens: 100, completionTokens: 50 },
);
expect(mockSpendTokens).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ model: 'claude-3' }),
{ promptTokens: 80, completionTokens: 40 },
);
expect(mockSpendTokens).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ model: 'gemini-pro' }),
{ promptTokens: 120, completionTokens: 60 },
);
});
it('should use fallbackModel when usage.model is missing', async () => {
const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'fallback-model',
});
expect(mockSpendTokens).toHaveBeenCalledWith(
expect.objectContaining({ model: 'fallback-model' }),
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
context: 'abort',
collectedUsage,
}),
);
});
it('should use spendStructuredTokens for OpenAI format cache tokens', async () => {
const collectedUsage = [
{
input_tokens: 100,
output_tokens: 50,
model: 'gpt-4',
input_token_details: {
cache_creation: 20,
cache_read: 10,
},
},
];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
});
expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1);
expect(mockSpendTokens).not.toHaveBeenCalled();
expect(mockSpendStructuredTokens).toHaveBeenCalledWith(
expect.objectContaining({ model: 'gpt-4', context: 'abort' }),
{
promptTokens: {
input: 100,
write: 20,
read: 10,
},
completionTokens: 50,
},
);
});
it('should use spendStructuredTokens for Anthropic format cache tokens', async () => {
const collectedUsage = [
{
input_tokens: 100,
output_tokens: 50,
model: 'claude-3',
cache_creation_input_tokens: 25,
cache_read_input_tokens: 15,
},
];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'claude-3',
});
expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1);
expect(mockSpendTokens).not.toHaveBeenCalled();
expect(mockSpendStructuredTokens).toHaveBeenCalledWith(
expect.objectContaining({ model: 'claude-3' }),
{
promptTokens: {
input: 100,
write: 25,
read: 15,
},
completionTokens: 50,
},
);
});
it('should handle mixed cache and non-cache entries', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{
input_tokens: 150,
output_tokens: 30,
model: 'claude-3',
cache_creation_input_tokens: 20,
cache_read_input_tokens: 10,
},
{ input_tokens: 200, output_tokens: 20, model: 'gemini-pro' },
];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
});
expect(mockSpendTokens).toHaveBeenCalledTimes(2);
expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1);
});
it('should handle real-world parallel agent abort scenario', async () => {
// Simulates: Primary agent (gemini) + addedConvo agent (gpt-5) aborted mid-stream
const collectedUsage = [
{ input_tokens: 31596, output_tokens: 151, model: 'gemini-3-flash-preview' },
{ input_tokens: 28000, output_tokens: 120, model: 'gpt-5.2' },
@ -356,27 +182,24 @@ describe('abortMiddleware - spendCollectedUsage', () => {
fallbackModel: 'gemini-3-flash-preview',
});
expect(mockSpendTokens).toHaveBeenCalledTimes(2);
// Primary model
expect(mockSpendTokens).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ model: 'gemini-3-flash-preview' }),
{ promptTokens: 31596, completionTokens: 151 },
);
// Parallel model (addedConvo)
expect(mockSpendTokens).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ model: 'gpt-5.2' }),
{ promptTokens: 28000, completionTokens: 120 },
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 () => {
// This tests the race condition fix: after abort middleware spends tokens,
// the collectedUsage array is cleared so AgentClient.recordCollectedUsage()
// (which shares the same array reference) sees an empty array and returns early.
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 80, output_tokens: 40, model: 'claude-3' },
@ -391,19 +214,16 @@ describe('abortMiddleware - spendCollectedUsage', () => {
fallbackModel: 'gpt-4',
});
expect(mockSpendTokens).toHaveBeenCalledTimes(2);
// The array should be cleared after spending
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(collectedUsage.length).toBe(0);
});
it('should await all token spending operations before clearing array', async () => {
// Ensure we don't clear the array before spending completes
let spendCallCount = 0;
mockSpendTokens.mockImplementation(async () => {
spendCallCount++;
// Simulate async delay
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 = [
@ -418,10 +238,7 @@ describe('abortMiddleware - spendCollectedUsage', () => {
fallbackModel: 'gpt-4',
});
// Both spend calls should have completed
expect(spendCallCount).toBe(2);
// Array should be cleared after awaiting
expect(resolved).toBe(true);
expect(collectedUsage.length).toBe(0);
});
});

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

@ -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);
});
});
});

View file

@ -0,0 +1,98 @@
const express = require('express');
const request = require('supertest');
const multer = require('multer');
const importFileFilter = (req, file, cb) => {
if (file.mimetype === 'application/json') {
cb(null, true);
} else {
cb(new Error('Only JSON files are allowed'), false);
}
};
/** Proxy app that mirrors the production multer + error-handling pattern */
function createImportApp(fileSize) {
const app = express();
const upload = multer({
storage: multer.memoryStorage(),
fileFilter: importFileFilter,
limits: { fileSize },
});
const uploadSingle = upload.single('file');
function handleUpload(req, res, next) {
uploadSingle(req, res, (err) => {
if (err && err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ message: 'File exceeds the maximum allowed size' });
}
if (err) {
return next(err);
}
next();
});
}
app.post('/import', handleUpload, (req, res) => {
res.status(201).json({ message: 'success', size: req.file.size });
});
app.use((err, _req, res, _next) => {
res.status(400).json({ error: err.message });
});
return app;
}
describe('Conversation Import - Multer File Size Limits', () => {
describe('multer rejects files exceeding the configured limit', () => {
it('returns 413 for files larger than the limit', async () => {
const limit = 1024;
const app = createImportApp(limit);
const oversized = Buffer.alloc(limit + 512, 'x');
const res = await request(app)
.post('/import')
.attach('file', oversized, { filename: 'import.json', contentType: 'application/json' });
expect(res.status).toBe(413);
expect(res.body.message).toBe('File exceeds the maximum allowed size');
});
it('accepts files within the limit', async () => {
const limit = 4096;
const app = createImportApp(limit);
const valid = Buffer.from(JSON.stringify({ title: 'test' }));
const res = await request(app)
.post('/import')
.attach('file', valid, { filename: 'import.json', contentType: 'application/json' });
expect(res.status).toBe(201);
expect(res.body.message).toBe('success');
});
it('rejects at the exact boundary (limit + 1 byte)', async () => {
const limit = 512;
const app = createImportApp(limit);
const boundary = Buffer.alloc(limit + 1, 'a');
const res = await request(app)
.post('/import')
.attach('file', boundary, { filename: 'import.json', contentType: 'application/json' });
expect(res.status).toBe(413);
});
it('accepts a file just under the limit', async () => {
const limit = 512;
const app = createImportApp(limit);
const underLimit = Buffer.alloc(limit - 1, 'b');
const res = await request(app)
.post('/import')
.attach('file', underLimit, { filename: 'import.json', contentType: 'application/json' });
expect(res.status).toBe(201);
});
});
});

View file

@ -1,109 +1,24 @@
const express = require('express');
const request = require('supertest');
jest.mock('@librechat/agents', () => ({
sleep: jest.fn(),
}));
const MOCKS = '../__test-utils__/convos-route-mocks';
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(),
createAxiosInstance: jest.fn(() => ({
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
})),
logAxiosError: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
createModels: jest.fn(() => ({
User: {},
Conversation: {},
Message: {},
SharedLink: {},
})),
}));
jest.mock('~/models/Conversation', () => ({
getConvosByCursor: jest.fn(),
getConvo: jest.fn(),
deleteConvos: jest.fn(),
saveConvo: jest.fn(),
}));
jest.mock('~/models/ToolCall', () => ({
deleteToolCalls: jest.fn(),
}));
jest.mock('~/models', () => ({
deleteAllSharedLinks: jest.fn(),
deleteConvoSharedLink: jest.fn(),
}));
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
jest.mock('~/server/middleware', () => ({
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(),
}));
jest.mock('~/server/utils/import/fork', () => ({
forkConversation: jest.fn(),
duplicateConversation: jest.fn(),
}));
jest.mock('~/server/utils/import', () => ({
importConversations: jest.fn(),
}));
jest.mock('~/cache/getLogStores', () => jest.fn());
jest.mock('~/server/routes/files/multer', () => ({
storage: {},
importFileFilter: jest.fn(),
}));
jest.mock('multer', () => {
return jest.fn(() => ({
single: jest.fn(() => (req, res, next) => {
req.file = { path: '/tmp/test-file.json' };
next();
}),
}));
});
jest.mock('librechat-data-provider', () => ({
CacheKeys: {
GEN_TITLE: 'GEN_TITLE',
},
EModelEndpoint: {
azureAssistants: 'azureAssistants',
assistants: 'assistants',
},
}));
jest.mock('~/server/services/Endpoints/azureAssistants', () => ({
initializeClient: jest.fn(),
}));
jest.mock('~/server/services/Endpoints/assistants', () => ({
initializeClient: jest.fn(),
}));
jest.mock('@librechat/agents', () => require(MOCKS).agents());
jest.mock('@librechat/api', () => require(MOCKS).api());
jest.mock('@librechat/data-schemas', () => require(MOCKS).dataSchemas());
jest.mock('librechat-data-provider', () => require(MOCKS).dataProvider());
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', () => require(MOCKS).middlewarePassthrough());
jest.mock('~/server/utils/import/fork', () => require(MOCKS).forkUtils());
jest.mock('~/server/utils/import', () => require(MOCKS).importUtils());
jest.mock('~/cache/getLogStores', () => require(MOCKS).logStores());
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('Convos Routes', () => {
let app;

View file

@ -32,6 +32,9 @@ jest.mock('@librechat/api', () => {
getFlowState: jest.fn(),
completeOAuthFlow: jest.fn(),
generateFlowId: jest.fn(),
resolveStateToFlowId: jest.fn(async (state) => state),
storeStateMapping: jest.fn(),
deleteStateMapping: jest.fn(),
},
MCPTokenStorage: {
storeTokens: jest.fn(),
@ -180,7 +183,10 @@ describe('MCP Routes', () => {
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
authorizationUrl: 'https://oauth.example.com/auth',
flowId: 'test-user-id:test-server',
flowMetadata: { state: 'random-state-value' },
});
MCPOAuthHandler.storeStateMapping.mockResolvedValue();
mockFlowManager.initFlow = jest.fn().mockResolvedValue();
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
userId: 'test-user-id',
@ -367,6 +373,121 @@ describe('MCP Routes', () => {
expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_state`);
});
describe('CSRF fallback via active PENDING flow', () => {
it('should proceed when a fresh PENDING flow exists and no cookies are present', async () => {
const flowId = 'test-user-id:test-server';
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({
status: 'PENDING',
createdAt: Date.now(),
}),
completeFlow: jest.fn().mockResolvedValue(true),
deleteFlow: jest.fn().mockResolvedValue(true),
};
const mockFlowState = {
serverName: 'test-server',
userId: 'test-user-id',
metadata: {},
clientInfo: {},
codeVerifier: 'test-verifier',
};
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue({
access_token: 'test-token',
});
MCPTokenStorage.storeTokens.mockResolvedValue();
mockRegistryInstance.getServerConfig.mockResolvedValue({});
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]),
}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getOAuthReconnectionManager.mockReturnValue({
clearReconnection: jest.fn(),
});
require('~/server/services/Config/mcp').updateMCPServerTools.mockResolvedValue();
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.query({ code: 'test-code', state: flowId });
const basePath = getBasePath();
expect(response.status).toBe(302);
expect(response.headers.location).toContain(`${basePath}/oauth/success`);
});
it('should reject when no PENDING flow exists and no cookies are present', async () => {
const flowId = 'test-user-id:test-server';
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue(null),
};
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.query({ code: 'test-code', state: flowId });
const basePath = getBasePath();
expect(response.status).toBe(302);
expect(response.headers.location).toBe(
`${basePath}/oauth/error?error=csrf_validation_failed`,
);
});
it('should reject when only a COMPLETED flow exists (not PENDING)', async () => {
const flowId = 'test-user-id:test-server';
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({
status: 'COMPLETED',
createdAt: Date.now(),
}),
};
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.query({ code: 'test-code', state: flowId });
const basePath = getBasePath();
expect(response.status).toBe(302);
expect(response.headers.location).toBe(
`${basePath}/oauth/error?error=csrf_validation_failed`,
);
});
it('should reject when PENDING flow is stale (older than PENDING_STALE_MS)', async () => {
const flowId = 'test-user-id:test-server';
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({
status: 'PENDING',
createdAt: Date.now() - 3 * 60 * 1000,
}),
};
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.query({ code: 'test-code', state: flowId });
const basePath = getBasePath();
expect(response.status).toBe(302);
expect(response.headers.location).toBe(
`${basePath}/oauth/error?error=csrf_validation_failed`,
);
});
});
it('should handle OAuth callback successfully', async () => {
// mockRegistryInstance is defined at the top of the file
const mockFlowManager = {
@ -1572,12 +1693,14 @@ describe('MCP Routes', () => {
it('should return all server configs for authenticated user', async () => {
const mockServerConfigs = {
'server-1': {
endpoint: 'http://server1.com',
name: 'Server 1',
type: 'sse',
url: 'http://server1.com/sse',
title: 'Server 1',
},
'server-2': {
endpoint: 'http://server2.com',
name: 'Server 2',
type: 'sse',
url: 'http://server2.com/sse',
title: 'Server 2',
},
};
@ -1586,7 +1709,18 @@ describe('MCP Routes', () => {
const response = await request(app).get('/api/mcp/servers');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockServerConfigs);
expect(response.body['server-1']).toMatchObject({
type: 'sse',
url: 'http://server1.com/sse',
title: 'Server 1',
});
expect(response.body['server-2']).toMatchObject({
type: 'sse',
url: 'http://server2.com/sse',
title: 'Server 2',
});
expect(response.body['server-1'].headers).toBeUndefined();
expect(response.body['server-2'].headers).toBeUndefined();
expect(mockRegistryInstance.getAllServerConfigs).toHaveBeenCalledWith('test-user-id');
});
@ -1641,10 +1775,10 @@ describe('MCP Routes', () => {
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
expect(response.status).toBe(201);
expect(response.body).toEqual({
serverName: 'test-sse-server',
...validConfig,
});
expect(response.body.serverName).toBe('test-sse-server');
expect(response.body.type).toBe('sse');
expect(response.body.url).toBe('https://mcp-server.example.com/sse');
expect(response.body.title).toBe('Test SSE Server');
expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
'temp_server_name',
expect.objectContaining({
@ -1698,6 +1832,78 @@ describe('MCP Routes', () => {
expect(response.body.message).toBe('Invalid configuration');
});
it('should reject SSE URL containing env variable references', async () => {
const response = await request(app)
.post('/api/mcp/servers')
.send({
config: {
type: 'sse',
url: 'http://attacker.com/?secret=${JWT_SECRET}',
},
});
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
});
it('should reject streamable-http URL containing env variable references', async () => {
const response = await request(app)
.post('/api/mcp/servers')
.send({
config: {
type: 'streamable-http',
url: 'http://attacker.com/?key=${CREDS_KEY}&iv=${CREDS_IV}',
},
});
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
});
it('should reject websocket URL containing env variable references', async () => {
const response = await request(app)
.post('/api/mcp/servers')
.send({
config: {
type: 'websocket',
url: 'ws://attacker.com/?secret=${MONGO_URI}',
},
});
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
});
it('should redact secrets from create response', async () => {
const validConfig = {
type: 'sse',
url: 'https://mcp-server.example.com/sse',
title: 'Test Server',
};
mockRegistryInstance.addServer.mockResolvedValue({
serverName: 'test-server',
config: {
...validConfig,
apiKey: { source: 'admin', authorization_type: 'bearer', key: 'admin-secret-key' },
oauth: { client_id: 'cid', client_secret: 'admin-oauth-secret' },
headers: { Authorization: 'Bearer leaked-token' },
},
});
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
expect(response.status).toBe(201);
expect(response.body.apiKey?.key).toBeUndefined();
expect(response.body.oauth?.client_secret).toBeUndefined();
expect(response.body.headers).toBeUndefined();
expect(response.body.apiKey?.source).toBe('admin');
expect(response.body.oauth?.client_id).toBe('cid');
});
it('should return 500 when registry throws error', async () => {
const validConfig = {
type: 'sse',
@ -1727,7 +1933,9 @@ describe('MCP Routes', () => {
const response = await request(app).get('/api/mcp/servers/test-server');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockConfig);
expect(response.body.type).toBe('sse');
expect(response.body.url).toBe('https://mcp-server.example.com/sse');
expect(response.body.title).toBe('Test Server');
expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
'test-server',
'test-user-id',
@ -1743,6 +1951,29 @@ describe('MCP Routes', () => {
expect(response.body).toEqual({ message: 'MCP server not found' });
});
it('should redact secrets from get response', async () => {
mockRegistryInstance.getServerConfig.mockResolvedValue({
type: 'sse',
url: 'https://mcp-server.example.com/sse',
title: 'Secret Server',
apiKey: { source: 'admin', authorization_type: 'bearer', key: 'decrypted-admin-key' },
oauth: { client_id: 'cid', client_secret: 'decrypted-oauth-secret' },
headers: { Authorization: 'Bearer internal-token' },
oauth_headers: { 'X-OAuth': 'secret-value' },
});
const response = await request(app).get('/api/mcp/servers/secret-server');
expect(response.status).toBe(200);
expect(response.body.title).toBe('Secret Server');
expect(response.body.apiKey?.key).toBeUndefined();
expect(response.body.apiKey?.source).toBe('admin');
expect(response.body.oauth?.client_secret).toBeUndefined();
expect(response.body.oauth?.client_id).toBe('cid');
expect(response.body.headers).toBeUndefined();
expect(response.body.oauth_headers).toBeUndefined();
});
it('should return 500 when registry throws error', async () => {
mockRegistryInstance.getServerConfig.mockRejectedValue(new Error('Database error'));
@ -1769,7 +2000,9 @@ describe('MCP Routes', () => {
.send({ config: updatedConfig });
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedConfig);
expect(response.body.type).toBe('sse');
expect(response.body.url).toBe('https://updated-mcp-server.example.com/sse');
expect(response.body.title).toBe('Updated Server');
expect(mockRegistryInstance.updateServer).toHaveBeenCalledWith(
'test-server',
expect.objectContaining({
@ -1781,6 +2014,35 @@ describe('MCP Routes', () => {
);
});
it('should redact secrets from update response', async () => {
const validConfig = {
type: 'sse',
url: 'https://mcp-server.example.com/sse',
title: 'Updated Server',
};
mockRegistryInstance.updateServer.mockResolvedValue({
...validConfig,
apiKey: { source: 'admin', authorization_type: 'bearer', key: 'preserved-admin-key' },
oauth: { client_id: 'cid', client_secret: 'preserved-oauth-secret' },
headers: { Authorization: 'Bearer internal-token' },
env: { DATABASE_URL: 'postgres://admin:pass@localhost/db' },
});
const response = await request(app)
.patch('/api/mcp/servers/test-server')
.send({ config: validConfig });
expect(response.status).toBe(200);
expect(response.body.title).toBe('Updated Server');
expect(response.body.apiKey?.key).toBeUndefined();
expect(response.body.apiKey?.source).toBe('admin');
expect(response.body.oauth?.client_secret).toBeUndefined();
expect(response.body.oauth?.client_id).toBe('cid');
expect(response.body.headers).toBeUndefined();
expect(response.body.env).toBeUndefined();
});
it('should return 400 for invalid configuration', async () => {
const invalidConfig = {
type: 'sse',
@ -1797,6 +2059,51 @@ describe('MCP Routes', () => {
expect(response.body.errors).toBeDefined();
});
it('should reject SSE URL containing env variable references', async () => {
const response = await request(app)
.patch('/api/mcp/servers/test-server')
.send({
config: {
type: 'sse',
url: 'http://attacker.com/?secret=${JWT_SECRET}',
},
});
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
});
it('should reject streamable-http URL containing env variable references', async () => {
const response = await request(app)
.patch('/api/mcp/servers/test-server')
.send({
config: {
type: 'streamable-http',
url: 'http://attacker.com/?key=${CREDS_KEY}',
},
});
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
});
it('should reject websocket URL containing env variable references', async () => {
const response = await request(app)
.patch('/api/mcp/servers/test-server')
.send({
config: {
type: 'websocket',
url: 'ws://attacker.com/?secret=${MONGO_URI}',
},
});
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
});
it('should return 500 when registry throws error', async () => {
const validConfig = {
type: 'sse',

View file

@ -0,0 +1,200 @@
const mongoose = require('mongoose');
const express = require('express');
const request = require('supertest');
const { v4: uuidv4 } = require('uuid');
const { MongoMemoryServer } = require('mongodb-memory-server');
jest.mock('@librechat/agents', () => ({
sleep: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
unescapeLaTeX: jest.fn((x) => x),
countTokens: jest.fn().mockResolvedValue(10),
}));
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('librechat-data-provider', () => ({
...jest.requireActual('librechat-data-provider'),
}));
jest.mock('~/models', () => ({
saveConvo: jest.fn(),
getMessage: jest.fn(),
saveMessage: jest.fn(),
getMessages: jest.fn(),
updateMessage: jest.fn(),
deleteMessages: jest.fn(),
}));
jest.mock('~/server/services/Artifacts/update', () => ({
findAllArtifacts: jest.fn(),
replaceArtifactContent: jest.fn(),
}));
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, res, next) => next(),
validateMessageReq: (req, res, next) => next(),
}));
jest.mock('~/models/Conversation', () => ({
getConvosQueried: jest.fn(),
}));
jest.mock('~/db/models', () => ({
Message: {
findOne: jest.fn(),
find: jest.fn(),
meiliSearch: jest.fn(),
},
}));
/* ─── Model-level tests: real MongoDB, proves cross-user deletion is prevented ─── */
const { messageSchema } = require('@librechat/data-schemas');
describe('deleteMessages model-level IDOR prevention', () => {
let mongoServer;
let Message;
const ownerUserId = 'user-owner-111';
const attackerUserId = 'user-attacker-222';
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Message.deleteMany({});
});
it("should NOT delete another user's message when attacker supplies victim messageId", async () => {
const conversationId = uuidv4();
const victimMsgId = 'victim-msg-001';
await Message.create({
messageId: victimMsgId,
conversationId,
user: ownerUserId,
text: 'Sensitive owner data',
});
await Message.deleteMany({ messageId: victimMsgId, user: attackerUserId });
const victimMsg = await Message.findOne({ messageId: victimMsgId }).lean();
expect(victimMsg).not.toBeNull();
expect(victimMsg.user).toBe(ownerUserId);
expect(victimMsg.text).toBe('Sensitive owner data');
});
it("should delete the user's own message", async () => {
const conversationId = uuidv4();
const ownMsgId = 'own-msg-001';
await Message.create({
messageId: ownMsgId,
conversationId,
user: ownerUserId,
text: 'My message',
});
const result = await Message.deleteMany({ messageId: ownMsgId, user: ownerUserId });
expect(result.deletedCount).toBe(1);
const deleted = await Message.findOne({ messageId: ownMsgId }).lean();
expect(deleted).toBeNull();
});
it('should scope deletion by conversationId, messageId, and user together', async () => {
const convoA = uuidv4();
const convoB = uuidv4();
await Message.create([
{ messageId: 'msg-a1', conversationId: convoA, user: ownerUserId, text: 'A1' },
{ messageId: 'msg-b1', conversationId: convoB, user: ownerUserId, text: 'B1' },
]);
await Message.deleteMany({ messageId: 'msg-a1', conversationId: convoA, user: attackerUserId });
const remaining = await Message.find({ user: ownerUserId }).lean();
expect(remaining).toHaveLength(2);
});
});
/* ─── Route-level tests: supertest + mocked deleteMessages ─── */
describe('DELETE /:conversationId/:messageId route handler', () => {
let app;
const { deleteMessages } = require('~/models');
const authenticatedUserId = 'user-owner-123';
beforeAll(() => {
const messagesRouter = require('../messages');
app = express();
app.use(express.json());
app.use((req, res, next) => {
req.user = { id: authenticatedUserId };
next();
});
app.use('/api/messages', messagesRouter);
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should pass user and conversationId in the deleteMessages filter', async () => {
deleteMessages.mockResolvedValue({ deletedCount: 1 });
await request(app).delete('/api/messages/convo-1/msg-1');
expect(deleteMessages).toHaveBeenCalledTimes(1);
expect(deleteMessages).toHaveBeenCalledWith({
messageId: 'msg-1',
conversationId: 'convo-1',
user: authenticatedUserId,
});
});
it('should return 204 on successful deletion', async () => {
deleteMessages.mockResolvedValue({ deletedCount: 1 });
const response = await request(app).delete('/api/messages/convo-1/msg-owned');
expect(response.status).toBe(204);
expect(deleteMessages).toHaveBeenCalledWith({
messageId: 'msg-owned',
conversationId: 'convo-1',
user: authenticatedUserId,
});
});
it('should return 500 when deleteMessages throws', async () => {
deleteMessages.mockRejectedValue(new Error('DB failure'));
const response = await request(app).delete('/api/messages/convo-1/msg-1');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Internal server error' });
});
});

View file

@ -117,7 +117,7 @@ router.post(
'/:id/duplicate',
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.VIEW,
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
v1.duplicateAgent,

View file

@ -63,7 +63,7 @@ router.post(
resetPasswordController,
);
router.get('/2fa/enable', middleware.requireJwtAuth, enable2FA);
router.post('/2fa/enable', middleware.requireJwtAuth, enable2FA);
router.post('/2fa/verify', middleware.requireJwtAuth, verify2FA);
router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken);
router.post('/2fa/confirm', middleware.requireJwtAuth, confirm2FA);

View file

@ -16,9 +16,7 @@ const sharedLinksEnabled =
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
const publicSharedLinksEnabled =
sharedLinksEnabled &&
(process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC));
sharedLinksEnabled && isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);

View file

@ -1,7 +1,7 @@
const multer = require('multer');
const express = require('express');
const { sleep } = require('@librechat/agents');
const { isEnabled } = require('@librechat/api');
const { isEnabled, resolveImportMaxFileSize } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
const {
@ -224,8 +224,27 @@ router.post('/update', validateConvoAccess, async (req, res) => {
});
const { importIpLimiter, importUserLimiter } = createImportLimiters();
/** Fork and duplicate share one rate-limit budget (same "clone" operation class) */
const { forkIpLimiter, forkUserLimiter } = createForkLimiters();
const upload = multer({ storage: storage, fileFilter: importFileFilter });
const importMaxFileSize = resolveImportMaxFileSize();
const upload = multer({
storage,
fileFilter: importFileFilter,
limits: { fileSize: importMaxFileSize },
});
const uploadSingle = upload.single('file');
function handleUpload(req, res, next) {
uploadSingle(req, res, (err) => {
if (err && err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ message: 'File exceeds the maximum allowed size' });
}
if (err) {
return next(err);
}
next();
});
}
/**
* Imports a conversation from a JSON file and saves it to the database.
@ -238,7 +257,7 @@ router.post(
importIpLimiter,
importUserLimiter,
configMiddleware,
upload.single('file'),
handleUpload,
async (req, res) => {
try {
/* TODO: optimize to return imported conversations and add manually */
@ -280,7 +299,7 @@ router.post('/fork', forkIpLimiter, forkUserLimiter, async (req, res) => {
}
});
router.post('/duplicate', async (req, res) => {
router.post('/duplicate', forkIpLimiter, forkUserLimiter, async (req, res) => {
const { conversationId, title } = req.body;
try {

View file

@ -2,12 +2,12 @@ const fs = require('fs').promises;
const express = require('express');
const { EnvVar } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { verifyAgentUploadPermission } = require('@librechat/api');
const {
Time,
isUUID,
CacheKeys,
FileSources,
SystemRoles,
ResourceType,
EModelEndpoint,
PermissionBits,
@ -381,48 +381,15 @@ router.post('/', async (req, res) => {
return await processFileUpload({ req, res, metadata });
}
/**
* Check agent permissions for permanent agent file uploads (not message attachments).
* Message attachments (message_file=true) are temporary files for a single conversation
* and should be allowed for users who can chat with the agent.
* Permanent file uploads to tool_resources require EDIT permission.
*/
const isMessageAttachment = metadata.message_file === true || metadata.message_file === 'true';
if (metadata.agent_id && metadata.tool_resource && !isMessageAttachment) {
const userId = req.user.id;
/** Admin users bypass permission checks */
if (req.user.role !== SystemRoles.ADMIN) {
const agent = await getAgent({ id: metadata.agent_id });
if (!agent) {
return res.status(404).json({
error: 'Not Found',
message: 'Agent not found',
});
}
/** Check if user is the author or has edit permission */
if (agent.author.toString() !== userId) {
const hasEditPermission = await checkPermission({
userId,
role: req.user.role,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
requiredPermission: PermissionBits.EDIT,
});
if (!hasEditPermission) {
logger.warn(
`[/files] User ${userId} denied upload to agent ${metadata.agent_id} (insufficient permissions)`,
);
return res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions to upload files to this agent',
});
}
}
}
const denied = await verifyAgentUploadPermission({
req,
res,
metadata,
getAgent,
checkPermission,
});
if (denied) {
return;
}
return await processAgentFileUpload({ req, res, metadata });

View file

@ -0,0 +1,376 @@
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { createMethods } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
SystemRoles,
AccessRoleIds,
ResourceType,
PrincipalType,
} = require('librechat-data-provider');
const { createAgent } = require('~/models/Agent');
jest.mock('~/server/services/Files/process', () => ({
processAgentFileUpload: jest.fn().mockImplementation(async ({ res }) => {
return res.status(200).json({ message: 'Agent file uploaded', file_id: 'test-file-id' });
}),
processImageFile: jest.fn().mockImplementation(async ({ res }) => {
return res.status(200).json({ message: 'Image processed' });
}),
filterFile: jest.fn(),
}));
jest.mock('fs', () => {
const actualFs = jest.requireActual('fs');
return {
...actualFs,
promises: {
...actualFs.promises,
unlink: jest.fn().mockResolvedValue(undefined),
},
};
});
const fs = require('fs');
const { processAgentFileUpload } = require('~/server/services/Files/process');
const router = require('~/server/routes/files/images');
describe('POST /images - Agent Upload Permission Check (Integration)', () => {
let mongoServer;
let authorId;
let otherUserId;
let agentCustomId;
let User;
let Agent;
let AclEntry;
let methods;
let modelsToCleanup = [];
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
const { createModels } = require('@librechat/data-schemas');
const models = createModels(mongoose);
modelsToCleanup = Object.keys(models);
Object.assign(mongoose.models, models);
methods = createMethods(mongoose);
User = models.User;
Agent = models.Agent;
AclEntry = models.AclEntry;
await methods.seedDefaultRoles();
});
afterAll(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
for (const modelName of modelsToCleanup) {
if (mongoose.models[modelName]) {
delete mongoose.models[modelName];
}
}
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
await User.deleteMany({});
await AclEntry.deleteMany({});
authorId = new mongoose.Types.ObjectId();
otherUserId = new mongoose.Types.ObjectId();
agentCustomId = `agent_${uuidv4().replace(/-/g, '').substring(0, 21)}`;
await User.create({ _id: authorId, username: 'author', email: 'author@test.com' });
await User.create({ _id: otherUserId, username: 'other', email: 'other@test.com' });
jest.clearAllMocks();
});
const createAppWithUser = (userId, userRole = SystemRoles.USER) => {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
if (req.method === 'POST') {
req.file = {
originalname: 'test.png',
mimetype: 'image/png',
size: 100,
path: '/tmp/t.png',
filename: 'test.png',
};
req.file_id = uuidv4();
}
next();
});
app.use((req, _res, next) => {
req.user = { id: userId.toString(), role: userRole };
req.app = { locals: {} };
req.config = { fileStrategy: 'local', paths: { imageOutput: '/tmp/images' } };
next();
});
app.use('/images', router);
return app;
};
it('should return 403 when user has no permission on agent', async () => {
await createAgent({
id: agentCustomId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
});
const app = createAppWithUser(otherUserId);
const response = await request(app).post('/images').send({
endpoint: 'agents',
agent_id: agentCustomId,
tool_resource: 'context',
file_id: uuidv4(),
});
expect(response.status).toBe(403);
expect(response.body.error).toBe('Forbidden');
expect(processAgentFileUpload).not.toHaveBeenCalled();
expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
});
it('should allow upload for agent owner', async () => {
await createAgent({
id: agentCustomId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
});
const app = createAppWithUser(authorId);
const response = await request(app).post('/images').send({
endpoint: 'agents',
agent_id: agentCustomId,
tool_resource: 'context',
file_id: uuidv4(),
});
expect(response.status).toBe(200);
expect(processAgentFileUpload).toHaveBeenCalled();
});
it('should allow upload for admin regardless of ownership', async () => {
await createAgent({
id: agentCustomId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
});
const app = createAppWithUser(otherUserId, SystemRoles.ADMIN);
const response = await request(app).post('/images').send({
endpoint: 'agents',
agent_id: agentCustomId,
tool_resource: 'context',
file_id: uuidv4(),
});
expect(response.status).toBe(200);
expect(processAgentFileUpload).toHaveBeenCalled();
});
it('should allow upload for user with EDIT permission', async () => {
const agent = await createAgent({
id: agentCustomId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
});
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: PrincipalType.USER,
principalId: otherUserId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: authorId,
});
const app = createAppWithUser(otherUserId);
const response = await request(app).post('/images').send({
endpoint: 'agents',
agent_id: agentCustomId,
tool_resource: 'context',
file_id: uuidv4(),
});
expect(response.status).toBe(200);
expect(processAgentFileUpload).toHaveBeenCalled();
});
it('should deny upload for user with only VIEW permission', async () => {
const agent = await createAgent({
id: agentCustomId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
});
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: PrincipalType.USER,
principalId: otherUserId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: authorId,
});
const app = createAppWithUser(otherUserId);
const response = await request(app).post('/images').send({
endpoint: 'agents',
agent_id: agentCustomId,
tool_resource: 'context',
file_id: uuidv4(),
});
expect(response.status).toBe(403);
expect(response.body.error).toBe('Forbidden');
expect(processAgentFileUpload).not.toHaveBeenCalled();
expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
});
it('should skip permission check for regular image uploads without agent_id/tool_resource', async () => {
const app = createAppWithUser(otherUserId);
const response = await request(app).post('/images').send({
endpoint: 'agents',
file_id: uuidv4(),
});
expect(response.status).toBe(200);
});
it('should return 404 for non-existent agent', async () => {
const app = createAppWithUser(otherUserId);
const response = await request(app).post('/images').send({
endpoint: 'agents',
agent_id: 'agent_nonexistent123456789',
tool_resource: 'context',
file_id: uuidv4(),
});
expect(response.status).toBe(404);
expect(response.body.error).toBe('Not Found');
expect(processAgentFileUpload).not.toHaveBeenCalled();
expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
});
it('should allow message_file attachment (boolean true) without EDIT permission', async () => {
const agent = await createAgent({
id: agentCustomId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
});
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: PrincipalType.USER,
principalId: otherUserId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: authorId,
});
const app = createAppWithUser(otherUserId);
const response = await request(app).post('/images').send({
endpoint: 'agents',
agent_id: agentCustomId,
tool_resource: 'context',
message_file: true,
file_id: uuidv4(),
});
expect(response.status).toBe(200);
expect(processAgentFileUpload).toHaveBeenCalled();
});
it('should allow message_file attachment (string "true") without EDIT permission', async () => {
const agent = await createAgent({
id: agentCustomId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
});
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: PrincipalType.USER,
principalId: otherUserId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: authorId,
});
const app = createAppWithUser(otherUserId);
const response = await request(app).post('/images').send({
endpoint: 'agents',
agent_id: agentCustomId,
tool_resource: 'context',
message_file: 'true',
file_id: uuidv4(),
});
expect(response.status).toBe(200);
expect(processAgentFileUpload).toHaveBeenCalled();
});
it('should deny upload when message_file is false (not a message attachment)', async () => {
const agent = await createAgent({
id: agentCustomId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
});
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: PrincipalType.USER,
principalId: otherUserId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: authorId,
});
const app = createAppWithUser(otherUserId);
const response = await request(app).post('/images').send({
endpoint: 'agents',
agent_id: agentCustomId,
tool_resource: 'context',
message_file: false,
file_id: uuidv4(),
});
expect(response.status).toBe(403);
expect(response.body.error).toBe('Forbidden');
expect(processAgentFileUpload).not.toHaveBeenCalled();
expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
});
});

View file

@ -2,12 +2,15 @@ const path = require('path');
const fs = require('fs').promises;
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { verifyAgentUploadPermission } = require('@librechat/api');
const { isAssistantsEndpoint } = require('librechat-data-provider');
const {
processAgentFileUpload,
processImageFile,
filterFile,
} = require('~/server/services/Files/process');
const { checkPermission } = require('~/server/services/PermissionService');
const { getAgent } = require('~/models/Agent');
const router = express.Router();
@ -22,6 +25,16 @@ router.post('/', async (req, res) => {
metadata.file_id = req.file_id;
if (!isAssistantsEndpoint(metadata.endpoint) && metadata.tool_resource != null) {
const denied = await verifyAgentUploadPermission({
req,
res,
metadata,
getAgent,
checkPermission,
});
if (denied) {
return;
}
return await processAgentFileUpload({ req, res, metadata });
}

View file

@ -13,6 +13,7 @@ const {
MCPOAuthHandler,
MCPTokenStorage,
setOAuthSession,
PENDING_STALE_MS,
getUserMCPAuthMap,
validateOAuthCsrf,
OAUTH_CSRF_COOKIE,
@ -49,6 +50,18 @@ const router = Router();
const OAUTH_CSRF_COOKIE_PATH = '/api/mcp';
const checkMCPUsePermissions = generateCheckAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permissions: [Permissions.USE],
getRoleByName,
});
const checkMCPCreate = generateCheckAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
/**
* Get all MCP tools available to the user
* Returns only MCP tools, completely decoupled from regular LibreChat tools
@ -91,7 +104,11 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async
}
const oauthHeaders = await getOAuthHeaders(serverName, userId);
const { authorizationUrl, flowId: oauthFlowId } = await MCPOAuthHandler.initiateOAuthFlow(
const {
authorizationUrl,
flowId: oauthFlowId,
flowMetadata,
} = await MCPOAuthHandler.initiateOAuthFlow(
serverName,
serverUrl,
userId,
@ -101,6 +118,7 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
await MCPOAuthHandler.storeStateMapping(flowMetadata.state, oauthFlowId, flowManager);
setOAuthCsrfCookie(res, oauthFlowId, OAUTH_CSRF_COOKIE_PATH);
res.redirect(authorizationUrl);
} catch (error) {
@ -143,30 +161,52 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
return res.redirect(`${basePath}/oauth/error?error=missing_state`);
}
const flowId = state;
logger.debug('[MCP OAuth] Using flow ID from state', { flowId });
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowId = await MCPOAuthHandler.resolveStateToFlowId(state, flowManager);
if (!flowId) {
logger.error('[MCP OAuth] Could not resolve state to flow ID', { state });
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
}
logger.debug('[MCP OAuth] Resolved flow ID from state', { flowId });
const flowParts = flowId.split(':');
if (flowParts.length < 2 || !flowParts[0] || !flowParts[1]) {
logger.error('[MCP OAuth] Invalid flow ID format in state', { flowId });
logger.error('[MCP OAuth] Invalid flow ID format', { flowId });
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
}
const [flowUserId] = flowParts;
if (
!validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH) &&
!validateOAuthSession(req, flowUserId)
) {
logger.error('[MCP OAuth] CSRF validation failed: no valid CSRF or session cookie', {
flowId,
hasCsrfCookie: !!req.cookies?.[OAUTH_CSRF_COOKIE],
hasSessionCookie: !!req.cookies?.[OAUTH_SESSION_COOKIE],
});
return res.redirect(`${basePath}/oauth/error?error=csrf_validation_failed`);
const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
const hasSession = !hasCsrf && validateOAuthSession(req, flowUserId);
let hasActiveFlow = false;
if (!hasCsrf && !hasSession) {
const pendingFlow = await flowManager.getFlowState(flowId, 'mcp_oauth');
const pendingAge = pendingFlow?.createdAt ? Date.now() - pendingFlow.createdAt : Infinity;
hasActiveFlow = pendingFlow?.status === 'PENDING' && pendingAge < PENDING_STALE_MS;
if (hasActiveFlow) {
logger.debug(
'[MCP OAuth] CSRF/session cookies absent, validating via active PENDING flow',
{
flowId,
},
);
}
}
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
if (!hasCsrf && !hasSession && !hasActiveFlow) {
logger.error(
'[MCP OAuth] CSRF validation failed: no valid CSRF cookie, session cookie, or active flow',
{
flowId,
hasCsrfCookie: !!req.cookies?.[OAUTH_CSRF_COOKIE],
hasSessionCookie: !!req.cookies?.[OAUTH_SESSION_COOKIE],
},
);
return res.redirect(`${basePath}/oauth/error?error=csrf_validation_failed`);
}
logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
@ -281,7 +321,13 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
const toolFlowId = flowState.metadata?.toolFlowId;
if (toolFlowId) {
logger.debug('[MCP OAuth] Completing tool flow', { toolFlowId });
await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
const completed = await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
if (!completed) {
logger.warn(
'[MCP OAuth] Tool flow state not found during completion — waiter will time out',
{ toolFlowId },
);
}
}
/** Redirect to success page with flowId and serverName */
@ -436,69 +482,75 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
* Reinitialize MCP server
* This endpoint allows reinitializing a specific MCP server
*/
router.post('/:serverName/reinitialize', requireJwtAuth, setOAuthSession, async (req, res) => {
try {
const { serverName } = req.params;
const user = createSafeUser(req.user);
router.post(
'/:serverName/reinitialize',
requireJwtAuth,
checkMCPUsePermissions,
setOAuthSession,
async (req, res) => {
try {
const { serverName } = req.params;
const user = createSafeUser(req.user);
if (!user.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
if (!user.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
const mcpManager = getMCPManager();
const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);
if (!serverConfig) {
return res.status(404).json({
error: `MCP server '${serverName}' not found in configuration`,
const mcpManager = getMCPManager();
const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);
if (!serverConfig) {
return res.status(404).json({
error: `MCP server '${serverName}' not found in configuration`,
});
}
await mcpManager.disconnectUserConnection(user.id, serverName);
logger.info(
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
);
/** @type {Record<string, Record<string, string>> | undefined} */
let userMCPAuthMap;
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
userMCPAuthMap = await getUserMCPAuthMap({
userId: user.id,
servers: [serverName],
findPluginAuthsByKeys,
});
}
const result = await reinitMCPServer({
user,
serverName,
userMCPAuthMap,
});
}
await mcpManager.disconnectUserConnection(user.id, serverName);
logger.info(
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
);
if (!result) {
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
}
/** @type {Record<string, Record<string, string>> | undefined} */
let userMCPAuthMap;
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
userMCPAuthMap = await getUserMCPAuthMap({
userId: user.id,
servers: [serverName],
findPluginAuthsByKeys,
const { success, message, oauthRequired, oauthUrl } = result;
if (oauthRequired) {
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
}
res.json({
success,
message,
oauthUrl,
serverName,
oauthRequired,
});
} catch (error) {
logger.error('[MCP Reinitialize] Unexpected error', error);
res.status(500).json({ error: 'Internal server error' });
}
const result = await reinitMCPServer({
user,
serverName,
userMCPAuthMap,
});
if (!result) {
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
}
const { success, message, oauthRequired, oauthUrl } = result;
if (oauthRequired) {
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
}
res.json({
success,
message,
oauthUrl,
serverName,
oauthRequired,
});
} catch (error) {
logger.error('[MCP Reinitialize] Unexpected error', error);
res.status(500).json({ error: 'Internal server error' });
}
});
},
);
/**
* Get connection status for all MCP servers
@ -605,7 +657,7 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) =>
* Check which authentication values exist for a specific MCP server
* This endpoint returns only boolean flags indicating if values are set, not the actual values
*/
router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
router.get('/:serverName/auth-values', requireJwtAuth, checkMCPUsePermissions, async (req, res) => {
try {
const { serverName } = req.params;
const user = req.user;
@ -662,19 +714,6 @@ async function getOAuthHeaders(serverName, userId) {
MCP Server CRUD Routes (User-Managed MCP Servers)
*/
// Permission checkers for MCP server management
const checkMCPUsePermissions = generateCheckAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permissions: [Permissions.USE],
getRoleByName,
});
const checkMCPCreate = generateCheckAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
/**
* Get list of accessible MCP servers
* @route GET /api/mcp/servers

View file

@ -404,8 +404,8 @@ router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (re
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
try {
const { messageId } = req.params;
await deleteMessages({ messageId });
const { conversationId, messageId } = req.params;
await deleteMessages({ messageId, conversationId, user: req.user.id });
res.status(204).send();
} catch (error) {
logger.error('Error deleting message:', error);

View file

@ -19,9 +19,7 @@ const allowSharedLinks =
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
if (allowSharedLinks) {
const allowSharedLinksPublic =
process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
const allowSharedLinksPublic = isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
router.get(
'/:shareId',
allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth,

View file

@ -55,16 +55,16 @@ const processAddedConvo = async ({
userMCPAuthMap,
}) => {
const addedConvo = endpointOption.addedConvo;
logger.debug('[processAddedConvo] Called with addedConvo:', {
hasAddedConvo: addedConvo != null,
addedConvoEndpoint: addedConvo?.endpoint,
addedConvoModel: addedConvo?.model,
addedConvoAgentId: addedConvo?.agent_id,
});
if (addedConvo == null) {
return { userMCPAuthMap };
}
logger.debug('[processAddedConvo] Processing added conversation', {
model: addedConvo.model,
agentId: addedConvo.agent_id,
endpoint: addedConvo.endpoint,
});
try {
const addedAgent = await loadAddedAgent({ req, conversation: addedConvo, primaryAgent });
if (!addedAgent) {

View file

@ -204,13 +204,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
);
logger.debug(
`[initializeClient] Tool definitions for primary agent: ${primaryConfig.toolDefinitions?.length ?? 0}`,
);
/** Store primary agent's tool context for ON_TOOL_EXECUTE callback */
logger.debug(`[initializeClient] Storing tool context for agentId: ${primaryConfig.id}`);
logger.debug(
`[initializeClient] toolRegistry size: ${primaryConfig.toolRegistry?.size ?? 'undefined'}`,
`[initializeClient] Storing tool context for ${primaryConfig.id}: ${primaryConfig.toolDefinitions?.length ?? 0} tools, registry size: ${primaryConfig.toolRegistry?.size ?? '0'}`,
);
agentToolContexts.set(primaryConfig.id, {
agent: primaryAgent,
@ -312,6 +306,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
}
} catch (err) {
logger.error(`[initializeClient] Error processing agent ${agentId}:`, err);
skippedAgentIds.add(agentId);
}
}
@ -321,7 +316,12 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
if (checkAgentInit(agentId)) {
continue;
}
await processAgent(agentId);
try {
await processAgent(agentId);
} catch (err) {
logger.error(`[initializeClient] Error processing chain agent ${agentId}:`, err);
skippedAgentIds.add(agentId);
}
}
const chain = await createSequentialChainEdges([primaryConfig.id].concat(agent_ids), '{convo}');
collectEdges(chain);

View file

@ -0,0 +1,124 @@
jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
jest.mock('@librechat/data-schemas', () => ({
logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
}));
jest.mock('@librechat/agents', () => ({
getCodeBaseURL: jest.fn(() => 'http://localhost:8000'),
}));
const mockSanitizeFilename = jest.fn();
jest.mock('@librechat/api', () => ({
logAxiosError: jest.fn(),
getBasePath: jest.fn(() => ''),
sanitizeFilename: mockSanitizeFilename,
}));
jest.mock('librechat-data-provider', () => ({
...jest.requireActual('librechat-data-provider'),
mergeFileConfig: jest.fn(() => ({ serverFileSizeLimit: 100 * 1024 * 1024 })),
getEndpointFileConfig: jest.fn(() => ({
fileSizeLimit: 100 * 1024 * 1024,
supportedMimeTypes: ['*/*'],
})),
fileConfig: { checkType: jest.fn(() => true) },
}));
jest.mock('~/models', () => ({
createFile: jest.fn().mockResolvedValue({}),
getFiles: jest.fn().mockResolvedValue([]),
updateFile: jest.fn(),
claimCodeFile: jest.fn().mockResolvedValue({ file_id: 'mock-uuid', usage: 0 }),
}));
const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/user123/mock-uuid__output.csv');
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: mockSaveBuffer,
})),
}));
jest.mock('~/server/services/Files/permissions', () => ({
filterFilesByAgentAccess: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/server/services/Files/images/convert', () => ({
convertImage: jest.fn(),
}));
jest.mock('~/server/utils', () => ({
determineFileType: jest.fn().mockResolvedValue({ mime: 'text/csv' }),
}));
jest.mock('axios', () =>
jest.fn().mockResolvedValue({
data: Buffer.from('file-content'),
}),
);
const { createFile } = require('~/models');
const { processCodeOutput } = require('../process');
const baseParams = {
req: {
user: { id: 'user123' },
config: {
fileStrategy: 'local',
imageOutputType: 'webp',
fileConfig: {},
},
},
id: 'code-file-id',
apiKey: 'test-key',
toolCallId: 'tool-1',
conversationId: 'conv-1',
messageId: 'msg-1',
session_id: 'session-1',
};
describe('processCodeOutput path traversal protection', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('sanitizeFilename is called with the raw artifact name', async () => {
mockSanitizeFilename.mockReturnValueOnce('output.csv');
await processCodeOutput({ ...baseParams, name: 'output.csv' });
expect(mockSanitizeFilename).toHaveBeenCalledWith('output.csv');
});
test('sanitized name is used in saveBuffer fileName', async () => {
mockSanitizeFilename.mockReturnValueOnce('sanitized-name.txt');
await processCodeOutput({ ...baseParams, name: '../../../tmp/poc.txt' });
expect(mockSanitizeFilename).toHaveBeenCalledWith('../../../tmp/poc.txt');
const call = mockSaveBuffer.mock.calls[0][0];
expect(call.fileName).toBe('mock-uuid__sanitized-name.txt');
});
test('sanitized name is stored as filename in the file record', async () => {
mockSanitizeFilename.mockReturnValueOnce('safe-output.csv');
await processCodeOutput({ ...baseParams, name: 'unsafe/../../output.csv' });
const fileArg = createFile.mock.calls[0][0];
expect(fileArg.filename).toBe('safe-output.csv');
});
test('sanitized name is used for image file records', async () => {
const { convertImage } = require('~/server/services/Files/images/convert');
convertImage.mockResolvedValueOnce({
filepath: '/images/user123/mock-uuid.webp',
bytes: 100,
});
mockSanitizeFilename.mockReturnValueOnce('safe-chart.png');
await processCodeOutput({ ...baseParams, name: '../../../chart.png' });
expect(mockSanitizeFilename).toHaveBeenCalledWith('../../../chart.png');
const fileArg = createFile.mock.calls[0][0];
expect(fileArg.filename).toBe('safe-chart.png');
});
});

View file

@ -3,7 +3,7 @@ const { v4 } = require('uuid');
const axios = require('axios');
const { logger } = require('@librechat/data-schemas');
const { getCodeBaseURL } = require('@librechat/agents');
const { logAxiosError, getBasePath } = require('@librechat/api');
const { logAxiosError, getBasePath, sanitizeFilename } = require('@librechat/api');
const {
Tools,
megabyte,
@ -146,6 +146,13 @@ const processCodeOutput = async ({
);
}
const safeName = sanitizeFilename(name);
if (safeName !== name) {
logger.warn(
`[processCodeOutput] Filename sanitized: "${name}" -> "${safeName}" | conv=${conversationId}`,
);
}
if (isImage) {
const usage = isUpdate ? (claimed.usage ?? 0) + 1 : 1;
const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`);
@ -156,7 +163,7 @@ const processCodeOutput = async ({
file_id,
messageId,
usage,
filename: name,
filename: safeName,
conversationId,
user: req.user.id,
type: `image/${appConfig.imageOutputType}`,
@ -200,7 +207,7 @@ const processCodeOutput = async ({
);
}
const fileName = `${file_id}__${name}`;
const fileName = `${file_id}__${safeName}`;
const filepath = await saveBuffer({
userId: req.user.id,
buffer,
@ -213,7 +220,7 @@ const processCodeOutput = async ({
filepath,
messageId,
object: 'file',
filename: name,
filename: safeName,
type: mimeType,
conversationId,
user: req.user.id,
@ -229,6 +236,11 @@ const processCodeOutput = async ({
await createFile(file, true);
return Object.assign(file, { messageId, toolCallId });
} catch (error) {
if (error?.message === 'Path traversal detected in filename') {
logger.warn(
`[processCodeOutput] Path traversal blocked for file "${name}" | conv=${conversationId}`,
);
}
logAxiosError({
message: 'Error downloading/processing code environment file',
error,

View file

@ -58,6 +58,7 @@ jest.mock('@librechat/agents', () => ({
jest.mock('@librechat/api', () => ({
logAxiosError: jest.fn(),
getBasePath: jest.fn(() => ''),
sanitizeFilename: jest.fn((name) => name),
}));
// Mock models

View file

@ -0,0 +1,69 @@
jest.mock('@librechat/api', () => ({ deleteRagFile: jest.fn() }));
jest.mock('@librechat/data-schemas', () => ({
logger: { warn: jest.fn(), error: jest.fn() },
}));
const mockTmpBase = require('fs').mkdtempSync(
require('path').join(require('os').tmpdir(), 'crud-traversal-'),
);
jest.mock('~/config/paths', () => {
const path = require('path');
return {
publicPath: path.join(mockTmpBase, 'public'),
uploads: path.join(mockTmpBase, 'uploads'),
};
});
const fs = require('fs');
const path = require('path');
const { saveLocalBuffer } = require('../crud');
describe('saveLocalBuffer path containment', () => {
beforeAll(() => {
fs.mkdirSync(path.join(mockTmpBase, 'public', 'images'), { recursive: true });
fs.mkdirSync(path.join(mockTmpBase, 'uploads'), { recursive: true });
});
afterAll(() => {
fs.rmSync(mockTmpBase, { recursive: true, force: true });
});
test('rejects filenames with path traversal sequences', async () => {
await expect(
saveLocalBuffer({
userId: 'user1',
buffer: Buffer.from('malicious'),
fileName: '../../../etc/passwd',
basePath: 'uploads',
}),
).rejects.toThrow('Path traversal detected in filename');
});
test('rejects prefix-collision traversal (startsWith bypass)', async () => {
fs.mkdirSync(path.join(mockTmpBase, 'uploads', 'user10'), { recursive: true });
await expect(
saveLocalBuffer({
userId: 'user1',
buffer: Buffer.from('malicious'),
fileName: '../user10/evil',
basePath: 'uploads',
}),
).rejects.toThrow('Path traversal detected in filename');
});
test('allows normal filenames', async () => {
const result = await saveLocalBuffer({
userId: 'user1',
buffer: Buffer.from('safe content'),
fileName: 'file-id__output.csv',
basePath: 'uploads',
});
expect(result).toBe('/uploads/user1/file-id__output.csv');
const filePath = path.join(mockTmpBase, 'uploads', 'user1', 'file-id__output.csv');
expect(fs.existsSync(filePath)).toBe(true);
fs.unlinkSync(filePath);
});
});

View file

@ -78,7 +78,13 @@ async function saveLocalBuffer({ userId, buffer, fileName, basePath = 'images' }
fs.mkdirSync(directoryPath, { recursive: true });
}
fs.writeFileSync(path.join(directoryPath, fileName), buffer);
const resolvedDir = path.resolve(directoryPath);
const resolvedPath = path.resolve(resolvedDir, fileName);
const rel = path.relative(resolvedDir, resolvedPath);
if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) {
throw new Error('Path traversal detected in filename');
}
fs.writeFileSync(resolvedPath, buffer);
const filePath = path.posix.join('/', basePath, userId, fileName);
@ -165,9 +171,8 @@ async function getLocalFileURL({ fileName, basePath = 'images' }) {
}
/**
* Validates if a given filepath is within a specified subdirectory under a base path. This function constructs
* the expected base path using the base, subfolder, and user id from the request, and then checks if the
* provided filepath starts with this constructed base path.
* Validates that a filepath is strictly contained within a subdirectory under a base path,
* using path.relative to prevent prefix-collision bypasses.
*
* @param {ServerRequest} req - The request object from Express. It should contain a `user` property with an `id`.
* @param {string} base - The base directory path.
@ -180,7 +185,8 @@ async function getLocalFileURL({ fileName, basePath = 'images' }) {
const isValidPath = (req, base, subfolder, filepath) => {
const normalizedBase = path.resolve(base, subfolder, req.user.id);
const normalizedFilepath = path.resolve(filepath);
return normalizedFilepath.startsWith(normalizedBase);
const rel = path.relative(normalizedBase, normalizedFilepath);
return !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(`..${path.sep}`);
};
/**

View file

@ -3,7 +3,7 @@ const fetch = require('node-fetch');
const { logger } = require('@librechat/data-schemas');
const { FileSources } = require('librechat-data-provider');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { initializeS3, deleteRagFile } = require('@librechat/api');
const { initializeS3, deleteRagFile, isEnabled } = require('@librechat/api');
const {
PutObjectCommand,
GetObjectCommand,
@ -13,6 +13,8 @@ const {
const bucketName = process.env.AWS_BUCKET_NAME;
const defaultBasePath = 'images';
const endpoint = process.env.AWS_ENDPOINT_URL;
const forcePathStyle = isEnabled(process.env.AWS_FORCE_PATH_STYLE);
let s3UrlExpirySeconds = 2 * 60; // 2 minutes
let s3RefreshExpiryMs = null;
@ -252,15 +254,83 @@ function extractKeyFromS3Url(fileUrlOrKey) {
try {
const url = new URL(fileUrlOrKey);
return url.pathname.substring(1);
const hostname = url.hostname;
const pathname = url.pathname.substring(1); // Remove leading slash
// Explicit path-style with custom endpoint: use endpoint pathname for precise key extraction.
// Handles endpoints with a base path (e.g. https://example.com/storage/).
if (endpoint && forcePathStyle) {
const endpointUrl = new URL(endpoint);
const startPos =
endpointUrl.pathname.length +
(endpointUrl.pathname.endsWith('/') ? 0 : 1) +
bucketName.length +
1;
const key = url.pathname.substring(startPos);
if (!key) {
logger.warn(
`[extractKeyFromS3Url] Extracted key is empty for endpoint path-style URL: ${fileUrlOrKey}`,
);
} else {
logger.debug(`[extractKeyFromS3Url] fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${key}`);
}
return key;
}
if (
hostname === 's3.amazonaws.com' ||
hostname.match(/^s3[-.][a-z0-9-]+\.amazonaws\.com$/) ||
(bucketName && pathname.startsWith(`${bucketName}/`))
) {
// Path-style: https://s3.amazonaws.com/bucket-name/key or custom endpoint (MinIO, R2, etc.)
// Strip the bucket name (first path segment)
const firstSlashIndex = pathname.indexOf('/');
if (firstSlashIndex > 0) {
const key = pathname.substring(firstSlashIndex + 1);
if (key === '') {
logger.warn(
`[extractKeyFromS3Url] Extracted key is empty after removing bucket name from URL: ${fileUrlOrKey}`,
);
} else {
logger.debug(
`[extractKeyFromS3Url] fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${key}`,
);
}
return key;
} else {
logger.warn(
`[extractKeyFromS3Url] Unable to extract key from path-style URL: ${fileUrlOrKey}`,
);
return '';
}
}
// Virtual-hosted-style or other: https://bucket-name.s3.amazonaws.com/key
// Just return the pathname without leading slash
logger.debug(`[extractKeyFromS3Url] fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${pathname}`);
return pathname;
} catch (error) {
if (fileUrlOrKey.startsWith('http://') || fileUrlOrKey.startsWith('https://')) {
logger.error(
`[extractKeyFromS3Url] Error parsing URL: ${fileUrlOrKey}, Error: ${error.message}`,
);
} else {
logger.debug(`[extractKeyFromS3Url] Non-URL input, using fallback: ${fileUrlOrKey}`);
}
const parts = fileUrlOrKey.split('/');
if (parts.length >= 3 && !fileUrlOrKey.startsWith('http') && !fileUrlOrKey.startsWith('/')) {
return fileUrlOrKey;
}
return fileUrlOrKey.startsWith('/') ? fileUrlOrKey.substring(1) : fileUrlOrKey;
const key = fileUrlOrKey.startsWith('/') ? fileUrlOrKey.substring(1) : fileUrlOrKey;
logger.debug(
`[extractKeyFromS3Url] FALLBACK. fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${key}`,
);
return key;
}
}
@ -482,4 +552,5 @@ module.exports = {
refreshS3Url,
needsRefresh,
getNewS3URL,
extractKeyFromS3Url,
};

View file

@ -16,6 +16,7 @@ const {
removeNullishValues,
isAssistantsEndpoint,
getEndpointFileConfig,
documentParserMimeTypes,
} = require('librechat-data-provider');
const { EnvVar } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
@ -523,6 +524,12 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
* @return {Promise<void>}
*/
const createTextFile = async ({ text, bytes, filepath, type = 'text/plain' }) => {
const textBytes = Buffer.byteLength(text, 'utf8');
if (textBytes > 15 * megabyte) {
throw new Error(
`Extracted text from "${file.originalname}" exceeds the 15MB storage limit (${Math.round(textBytes / megabyte)}MB). Try a shorter document.`,
);
}
const fileInfo = removeNullishValues({
text,
bytes,
@ -553,29 +560,52 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
const fileConfig = mergeFileConfig(appConfig.fileConfig);
const shouldUseOCR =
const shouldUseConfiguredOCR =
appConfig?.ocr != null &&
fileConfig.checkType(file.mimetype, fileConfig.ocr?.supportedMimeTypes || []);
if (shouldUseOCR && !(await checkCapability(req, AgentCapabilities.ocr))) {
throw new Error('OCR capability is not enabled for Agents');
} else if (shouldUseOCR) {
const shouldUseDocumentParser =
!shouldUseConfiguredOCR && documentParserMimeTypes.some((regex) => regex.test(file.mimetype));
const shouldUseOCR = shouldUseConfiguredOCR || shouldUseDocumentParser;
const resolveDocumentText = async () => {
if (shouldUseConfiguredOCR) {
try {
const ocrStrategy = appConfig?.ocr?.strategy ?? FileSources.document_parser;
const { handleFileUpload } = getStrategyFunctions(ocrStrategy);
return await handleFileUpload({ req, file, loadAuthValues });
} catch (err) {
logger.error(
`[processAgentFileUpload] Configured OCR failed for "${file.originalname}", falling back to document_parser:`,
err,
);
}
}
try {
const { handleFileUpload: uploadOCR } = getStrategyFunctions(
appConfig?.ocr?.strategy ?? FileSources.mistral_ocr,
);
const {
text,
bytes,
filepath: ocrFileURL,
} = await uploadOCR({ req, file, loadAuthValues });
return await createTextFile({ text, bytes, filepath: ocrFileURL });
} catch (ocrError) {
const { handleFileUpload } = getStrategyFunctions(FileSources.document_parser);
return await handleFileUpload({ req, file, loadAuthValues });
} catch (err) {
logger.error(
`[processAgentFileUpload] OCR processing failed for file "${file.originalname}", falling back to text extraction:`,
ocrError,
`[processAgentFileUpload] Document parser failed for "${file.originalname}":`,
err,
);
}
};
if (shouldUseConfiguredOCR && !(await checkCapability(req, AgentCapabilities.ocr))) {
throw new Error('OCR capability is not enabled for Agents');
}
if (shouldUseOCR) {
const ocrResult = await resolveDocumentText();
if (ocrResult) {
const { text, bytes, filepath: ocrFileURL } = ocrResult;
return await createTextFile({ text, bytes, filepath: ocrFileURL });
}
throw new Error(
`Unable to extract text from "${file.originalname}". The document may be image-based and requires an OCR service to process.`,
);
}
const shouldUseSTT = fileConfig.checkType(

View file

@ -0,0 +1,347 @@
jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
jest.mock('@librechat/data-schemas', () => ({
logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
}));
jest.mock('@librechat/agents', () => ({
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
}));
jest.mock('@librechat/api', () => ({
sanitizeFilename: jest.fn((n) => n),
parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }),
processAudioFile: jest.fn(),
}));
jest.mock('librechat-data-provider', () => ({
...jest.requireActual('librechat-data-provider'),
mergeFileConfig: jest.fn(),
}));
jest.mock('~/server/services/Files/images', () => ({
convertImage: jest.fn(),
resizeAndConvert: jest.fn(),
resizeImageBuffer: jest.fn(),
}));
jest.mock('~/server/controllers/assistants/v2', () => ({
addResourceFileId: jest.fn(),
deleteResourceFileId: jest.fn(),
}));
jest.mock('~/models/Agent', () => ({
addAgentResourceFile: jest.fn().mockResolvedValue({}),
removeAgentResourceFiles: jest.fn(),
}));
jest.mock('~/server/controllers/assistants/helpers', () => ({
getOpenAIClient: jest.fn(),
}));
jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('~/models', () => ({
createFile: jest.fn().mockResolvedValue({ file_id: 'created-file-id' }),
updateFileUsage: jest.fn(),
deleteFiles: jest.fn(),
}));
jest.mock('~/server/utils/getFileStrategy', () => ({
getFileStrategy: jest.fn().mockReturnValue('local'),
}));
jest.mock('~/server/services/Config', () => ({
checkCapability: jest.fn().mockResolvedValue(true),
}));
jest.mock('~/server/utils/queue', () => ({
LB_QueueAsyncCall: jest.fn(),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
jest.mock('~/server/utils', () => ({
determineFileType: jest.fn(),
}));
jest.mock('~/server/services/Files/Audio/STTService', () => ({
STTService: { getInstance: jest.fn() },
}));
const { EToolResources, FileSources, AgentCapabilities } = require('librechat-data-provider');
const { mergeFileConfig } = require('librechat-data-provider');
const { checkCapability } = require('~/server/services/Config');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { processAgentFileUpload } = require('./process');
const PDF_MIME = 'application/pdf';
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const XLS_MIME = 'application/vnd.ms-excel';
const ODS_MIME = 'application/vnd.oasis.opendocument.spreadsheet';
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
const ODP_MIME = 'application/vnd.oasis.opendocument.presentation';
const ODG_MIME = 'application/vnd.oasis.opendocument.graphics';
const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null } = {}) => ({
user: { id: 'user-123' },
file: {
path: '/tmp/upload.bin',
originalname: 'upload.bin',
filename: 'upload-uuid.bin',
mimetype,
},
body: { model: 'gpt-4o' },
config: {
fileConfig: {},
fileStrategy: 'local',
ocr: ocrConfig,
},
});
const makeMetadata = () => ({
agent_id: 'agent-abc',
tool_resource: EToolResources.context,
file_id: 'file-uuid-123',
});
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnValue({}),
};
const makeFileConfig = ({ ocrSupportedMimeTypes = [] } = {}) => ({
checkType: (mime, types) => (types ?? []).includes(mime),
ocr: { supportedMimeTypes: ocrSupportedMimeTypes },
stt: { supportedMimeTypes: [] },
text: { supportedMimeTypes: [] },
});
describe('processAgentFileUpload', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRes.status.mockReturnThis();
mockRes.json.mockReturnValue({});
checkCapability.mockResolvedValue(true);
getStrategyFunctions.mockReturnValue({
handleFileUpload: jest
.fn()
.mockResolvedValue({ text: 'extracted text', bytes: 42, filepath: 'doc://result' }),
});
mergeFileConfig.mockReturnValue(makeFileConfig());
});
describe('OCR strategy selection', () => {
test.each([
['PDF', PDF_MIME],
['DOCX', DOCX_MIME],
['XLSX', XLSX_MIME],
['XLS', XLS_MIME],
['ODS', ODS_MIME],
['Excel variant (msexcel)', 'application/msexcel'],
['Excel variant (x-msexcel)', 'application/x-msexcel'],
])('uses document_parser automatically for %s when no OCR is configured', async (_, mime) => {
mergeFileConfig.mockReturnValue(makeFileConfig());
const req = makeReq({ mimetype: mime, ocrConfig: null });
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
});
test('does not check OCR capability when using automatic document_parser fallback', async () => {
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
});
test('uses the configured OCR strategy when OCR is set up for the file type', async () => {
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
const req = makeReq({
mimetype: PDF_MIME,
ocrConfig: { strategy: FileSources.mistral_ocr },
});
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
});
test('uses document_parser as default when OCR is configured but no strategy is specified', async () => {
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
const req = makeReq({
mimetype: PDF_MIME,
ocrConfig: { supportedMimeTypes: [PDF_MIME] },
});
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
});
test('throws when configured OCR capability is not enabled for the agent', async () => {
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
checkCapability.mockResolvedValue(false);
const req = makeReq({
mimetype: PDF_MIME,
ocrConfig: { strategy: FileSources.mistral_ocr },
});
await expect(
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
).rejects.toThrow('OCR capability is not enabled for Agents');
});
test('uses document_parser (no capability check) when OCR capability returns false but no OCR config', async () => {
checkCapability.mockResolvedValue(false);
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
});
test('uses document_parser when OCR is configured but the file type is not in OCR supported types', async () => {
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
const req = makeReq({
mimetype: DOCX_MIME,
ocrConfig: { strategy: FileSources.mistral_ocr },
});
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
expect(getStrategyFunctions).not.toHaveBeenCalledWith(FileSources.mistral_ocr);
});
test('does not invoke any OCR strategy for unsupported MIME types without OCR config', async () => {
const req = makeReq({ mimetype: 'text/plain', ocrConfig: null });
await expect(
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
).rejects.toThrow('File type text/plain is not supported for text parsing.');
expect(getStrategyFunctions).not.toHaveBeenCalled();
});
test.each([
['ODT', ODT_MIME],
['ODP', ODP_MIME],
['ODG', ODG_MIME],
])('routes %s through configured OCR when OCR supports the type', async (_, mime) => {
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [mime] }));
const req = makeReq({
mimetype: mime,
ocrConfig: { strategy: FileSources.mistral_ocr },
});
await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() });
expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr);
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
});
test('throws instead of falling back to parseText when document_parser fails for a document MIME type', async () => {
getStrategyFunctions.mockReturnValue({
handleFileUpload: jest.fn().mockRejectedValue(new Error('No text found in document')),
});
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
const { parseText } = require('@librechat/api');
await expect(
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
).rejects.toThrow(/image-based and requires an OCR service/);
expect(parseText).not.toHaveBeenCalled();
});
test('falls back to document_parser when configured OCR fails for a document MIME type', async () => {
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
const failingUpload = jest.fn().mockRejectedValue(new Error('OCR API returned 500'));
const fallbackUpload = jest
.fn()
.mockResolvedValue({ text: 'parsed text', bytes: 11, filepath: 'doc://result' });
getStrategyFunctions
.mockReturnValueOnce({ handleFileUpload: failingUpload })
.mockReturnValueOnce({ handleFileUpload: fallbackUpload });
const req = makeReq({
mimetype: PDF_MIME,
ocrConfig: { strategy: FileSources.mistral_ocr },
});
await expect(
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
).resolves.not.toThrow();
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr);
expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser);
});
test('throws when both configured OCR and document_parser fallback fail', async () => {
mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] }));
getStrategyFunctions.mockReturnValue({
handleFileUpload: jest.fn().mockRejectedValue(new Error('failure')),
});
const req = makeReq({
mimetype: PDF_MIME,
ocrConfig: { strategy: FileSources.mistral_ocr },
});
const { parseText } = require('@librechat/api');
await expect(
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
).rejects.toThrow(/image-based and requires an OCR service/);
expect(parseText).not.toHaveBeenCalled();
});
});
describe('text size guard', () => {
test('throws before writing to MongoDB when extracted text exceeds 15MB', async () => {
const oversizedText = 'x'.repeat(15 * 1024 * 1024 + 1);
getStrategyFunctions.mockReturnValue({
handleFileUpload: jest.fn().mockResolvedValue({
text: oversizedText,
bytes: Buffer.byteLength(oversizedText, 'utf8'),
filepath: 'doc://result',
}),
});
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
const { createFile } = require('~/models');
await expect(
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
).rejects.toThrow(/exceeds the 15MB storage limit/);
expect(createFile).not.toHaveBeenCalled();
});
test('succeeds when extracted text is within the 15MB limit', async () => {
const okText = 'x'.repeat(1024);
getStrategyFunctions.mockReturnValue({
handleFileUpload: jest.fn().mockResolvedValue({
text: okText,
bytes: Buffer.byteLength(okText, 'utf8'),
filepath: 'doc://result',
}),
});
const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null });
await expect(
processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }),
).resolves.not.toThrow();
});
});
});

View file

@ -1,5 +1,6 @@
const { FileSources } = require('librechat-data-provider');
const {
parseDocument,
uploadMistralOCR,
uploadAzureMistralOCR,
uploadGoogleVertexMistralOCR,
@ -246,6 +247,26 @@ const vertexMistralOCRStrategy = () => ({
handleFileUpload: uploadGoogleVertexMistralOCR,
});
const documentParserStrategy = () => ({
/** @type {typeof saveFileFromURL | null} */
saveURL: null,
/** @type {typeof getLocalFileURL | null} */
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */
prepareImagePayload: null,
/** @type {typeof deleteLocalFile | null} */
deleteFile: null,
/** @type {typeof getLocalFileStream | null} */
getDownloadStream: null,
handleFileUpload: parseDocument,
});
// Strategy Selector
const getStrategyFunctions = (fileSource) => {
if (fileSource === FileSources.firebase) {
@ -270,6 +291,8 @@ const getStrategyFunctions = (fileSource) => {
return azureMistralOCRStrategy();
} else if (fileSource === FileSources.vertexai_mistral_ocr) {
return vertexMistralOCRStrategy();
} else if (fileSource === FileSources.document_parser) {
return documentParserStrategy();
} else if (fileSource === FileSources.text) {
return localStrategy(); // Text files use local strategy
} else {

View file

@ -7,7 +7,7 @@ const getLogStores = require('~/cache/getLogStores');
/**
* Get Microsoft Graph API token using existing token exchange mechanism
* @param {Object} user - User object with OpenID information
* @param {string} accessToken - Current access token from Authorization header
* @param {string} accessToken - Federated access token used as OBO assertion
* @param {string} scopes - Graph API scopes for the token
* @param {boolean} fromCache - Whether to try getting token from cache first
* @returns {Promise<Object>} Graph API token response with access_token and expires_in

View file

@ -34,6 +34,55 @@ const { reinitMCPServer } = require('./Tools/mcp');
const { getAppConfig } = require('./Config');
const { getLogStores } = require('~/cache');
const MAX_CACHE_SIZE = 1000;
const lastReconnectAttempts = new Map();
const RECONNECT_THROTTLE_MS = 10_000;
const missingToolCache = new Map();
const MISSING_TOOL_TTL_MS = 10_000;
function evictStale(map, ttl) {
if (map.size <= MAX_CACHE_SIZE) {
return;
}
const now = Date.now();
for (const [key, timestamp] of map) {
if (now - timestamp >= ttl) {
map.delete(key);
}
if (map.size <= MAX_CACHE_SIZE) {
return;
}
}
}
const unavailableMsg =
"This tool's MCP server is temporarily unavailable. Please try again shortly.";
/**
* @param {string} toolName
* @param {string} serverName
*/
function createUnavailableToolStub(toolName, serverName) {
const normalizedToolKey = `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`;
const _call = async () => [unavailableMsg, null];
const toolInstance = tool(_call, {
schema: {
type: 'object',
properties: {
input: { type: 'string', description: 'Input for the tool' },
},
required: [],
},
name: normalizedToolKey,
description: unavailableMsg,
responseFormat: AgentConstants.CONTENT_AND_ARTIFACT,
});
toolInstance.mcp = true;
toolInstance.mcpRawServerName = serverName;
return toolInstance;
}
function isEmptyObjectSchema(jsonSchema) {
return (
jsonSchema != null &&
@ -211,6 +260,17 @@ async function reconnectServer({
logger.debug(
`[MCP][reconnectServer] serverName: ${serverName}, user: ${user?.id}, hasUserMCPAuthMap: ${!!userMCPAuthMap}`,
);
const throttleKey = `${user.id}:${serverName}`;
const now = Date.now();
const lastAttempt = lastReconnectAttempts.get(throttleKey) ?? 0;
if (now - lastAttempt < RECONNECT_THROTTLE_MS) {
logger.debug(`[MCP][reconnectServer] Throttled reconnect for ${serverName}`);
return null;
}
lastReconnectAttempts.set(throttleKey, now);
evictStale(lastReconnectAttempts, RECONNECT_THROTTLE_MS);
const runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID;
const flowId = `${user.id}:${serverName}:${Date.now()}`;
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
@ -267,7 +327,7 @@ async function reconnectServer({
userMCPAuthMap,
forceNew: true,
returnOnOAuth: false,
connectionTimeout: Time.TWO_MINUTES,
connectionTimeout: Time.THIRTY_SECONDS,
});
} finally {
// Clean up abort handler to prevent memory leaks
@ -330,9 +390,13 @@ async function createMCPTools({
userMCPAuthMap,
streamId,
});
if (result === null) {
logger.debug(`[MCP][${serverName}] Reconnect throttled, skipping tool creation.`);
return [];
}
if (!result || !result.tools) {
logger.warn(`[MCP][${serverName}] Failed to reinitialize MCP server.`);
return;
return [];
}
const serverTools = [];
@ -402,6 +466,14 @@ async function createMCPTool({
/** @type {LCTool | undefined} */
let toolDefinition = availableTools?.[toolKey]?.function;
if (!toolDefinition) {
const cachedAt = missingToolCache.get(toolKey);
if (cachedAt && Date.now() - cachedAt < MISSING_TOOL_TTL_MS) {
logger.debug(
`[MCP][${serverName}][${toolName}] Tool in negative cache, returning unavailable stub.`,
);
return createUnavailableToolStub(toolName, serverName);
}
logger.warn(
`[MCP][${serverName}][${toolName}] Requested tool not found in available tools, re-initializing MCP server.`,
);
@ -415,11 +487,18 @@ async function createMCPTool({
streamId,
});
toolDefinition = result?.availableTools?.[toolKey]?.function;
if (!toolDefinition) {
missingToolCache.set(toolKey, Date.now());
evictStale(missingToolCache, MISSING_TOOL_TTL_MS);
}
}
if (!toolDefinition) {
logger.warn(`[MCP][${serverName}][${toolName}] Tool definition not found, cannot create tool.`);
return;
logger.warn(
`[MCP][${serverName}][${toolName}] Tool definition not found, returning unavailable stub.`,
);
return createUnavailableToolStub(toolName, serverName);
}
return createToolInstance({
@ -720,4 +799,5 @@ module.exports = {
getMCPSetupData,
checkOAuthFlowStatus,
getServerConnectionStatus,
createUnavailableToolStub,
};

View file

@ -45,6 +45,7 @@ const {
getMCPSetupData,
checkOAuthFlowStatus,
getServerConnectionStatus,
createUnavailableToolStub,
} = require('./MCP');
jest.mock('./Config', () => ({
@ -1098,6 +1099,188 @@ describe('User parameter passing tests', () => {
});
});
describe('createUnavailableToolStub', () => {
it('should return a tool whose _call returns a valid CONTENT_AND_ARTIFACT two-tuple', async () => {
const stub = createUnavailableToolStub('myTool', 'myServer');
// invoke() goes through langchain's base tool, which checks responseFormat.
// CONTENT_AND_ARTIFACT requires [content, artifact] — a bare string would throw:
// "Tool response format is "content_and_artifact" but the output was not a two-tuple"
const result = await stub.invoke({});
// If we reach here without throwing, the two-tuple format is correct.
// invoke() returns the content portion of [content, artifact] as a string.
expect(result).toContain('temporarily unavailable');
});
});
describe('negative tool cache and throttle interaction', () => {
it('should cache tool as missing even when throttled (cross-user dedup)', async () => {
const mockUser = { id: 'throttle-test-user' };
const mockRes = { write: jest.fn(), flush: jest.fn() };
// First call: reconnect succeeds but tool not found
mockReinitMCPServer.mockResolvedValueOnce({
availableTools: {},
});
await createMCPTool({
res: mockRes,
user: mockUser,
toolKey: `missing-tool${D}cache-dedup-server`,
provider: 'openai',
userMCPAuthMap: {},
availableTools: undefined,
});
// Second call within 10s for DIFFERENT tool on same server:
// reconnect is throttled (returns null), tool is still cached as missing.
// This is intentional: the cache acts as cross-user dedup since the
// throttle is per-user-per-server and can't prevent N different users
// from each triggering their own reconnect.
const result2 = await createMCPTool({
res: mockRes,
user: mockUser,
toolKey: `other-tool${D}cache-dedup-server`,
provider: 'openai',
userMCPAuthMap: {},
availableTools: undefined,
});
expect(result2).toBeDefined();
expect(result2.name).toContain('other-tool');
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
});
it('should prevent user B from triggering reconnect when user A already cached the tool', async () => {
const userA = { id: 'cache-user-A' };
const userB = { id: 'cache-user-B' };
const mockRes = { write: jest.fn(), flush: jest.fn() };
// User A: real reconnect, tool not found → cached
mockReinitMCPServer.mockResolvedValueOnce({
availableTools: {},
});
await createMCPTool({
res: mockRes,
user: userA,
toolKey: `shared-tool${D}cross-user-server`,
provider: 'openai',
userMCPAuthMap: {},
availableTools: undefined,
});
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
// User B requests the SAME tool within 10s.
// The negative cache is keyed by toolKey (no user prefix), so user B
// gets a cache hit and no reconnect fires. This is the cross-user
// storm protection: without this, user B's unthrottled first request
// would trigger a second reconnect to the same server.
const result = await createMCPTool({
res: mockRes,
user: userB,
toolKey: `shared-tool${D}cross-user-server`,
provider: 'openai',
userMCPAuthMap: {},
availableTools: undefined,
});
expect(result).toBeDefined();
expect(result.name).toContain('shared-tool');
// reinitMCPServer still called only once — user B hit the cache
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
});
it('should prevent user B from triggering reconnect for throttle-cached tools', async () => {
const userA = { id: 'storm-user-A' };
const userB = { id: 'storm-user-B' };
const mockRes = { write: jest.fn(), flush: jest.fn() };
// User A: real reconnect for tool-1, tool not found → cached
mockReinitMCPServer.mockResolvedValueOnce({
availableTools: {},
});
await createMCPTool({
res: mockRes,
user: userA,
toolKey: `tool-1${D}storm-server`,
provider: 'openai',
userMCPAuthMap: {},
availableTools: undefined,
});
// User A: tool-2 on same server within 10s → throttled → cached from throttle
await createMCPTool({
res: mockRes,
user: userA,
toolKey: `tool-2${D}storm-server`,
provider: 'openai',
userMCPAuthMap: {},
availableTools: undefined,
});
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
// User B requests tool-2 — gets cache hit from the throttle-cached entry.
// Without this caching, user B would trigger a real reconnect since
// user B has their own throttle key and hasn't reconnected yet.
const result = await createMCPTool({
res: mockRes,
user: userB,
toolKey: `tool-2${D}storm-server`,
provider: 'openai',
userMCPAuthMap: {},
availableTools: undefined,
});
expect(result).toBeDefined();
expect(result.name).toContain('tool-2');
// Still only 1 real reconnect — user B was protected by the cache
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
});
});
describe('createMCPTools throttle handling', () => {
it('should return empty array with debug log when reconnect is throttled', async () => {
const mockUser = { id: 'throttle-tools-user' };
const mockRes = { write: jest.fn(), flush: jest.fn() };
// First call: real reconnect
mockReinitMCPServer.mockResolvedValueOnce({
tools: [{ name: 'tool1' }],
availableTools: {
[`tool1${D}throttle-tools-server`]: {
function: { description: 'Tool 1', parameters: {} },
},
},
});
await createMCPTools({
res: mockRes,
user: mockUser,
serverName: 'throttle-tools-server',
provider: 'openai',
userMCPAuthMap: {},
});
// Second call within 10s — throttled
const result = await createMCPTools({
res: mockRes,
user: mockUser,
serverName: 'throttle-tools-server',
provider: 'openai',
userMCPAuthMap: {},
});
expect(result).toEqual([]);
// reinitMCPServer called only once — second was throttled
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
// Should log at debug level (not warn) for throttled case
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Reconnect throttled'));
});
});
describe('User parameter integrity', () => {
it('should preserve user object properties through the call chain', async () => {
const complexUser = {

View file

@ -12,7 +12,6 @@ const {
const {
sendEvent,
getToolkitKey,
hasCustomUserVars,
getUserMCPAuthMap,
loadToolDefinitions,
GenerationJobManager,
@ -481,7 +480,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
/** @type {Record<string, Record<string, string>>} */
let userMCPAuthMap;
if (hasCustomUserVars(req.config)) {
if (agent.tools?.some((t) => t.includes(Constants.mcp_delimiter))) {
userMCPAuthMap = await getUserMCPAuthMap({
tools: agent.tools,
userId: req.user.id,
@ -860,8 +859,7 @@ async function loadAgentTools({
/** @type {Record<string, Record<string, string>>} */
let userMCPAuthMap;
//TODO pass config from registry
if (hasCustomUserVars(req.config)) {
if (agent.tools?.some((t) => t.includes(Constants.mcp_delimiter))) {
userMCPAuthMap = await getUserMCPAuthMap({
tools: agent.tools,
userId: req.user.id,

View file

@ -1,8 +1,8 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getMCPManager, getMCPServersRegistry, getFlowStateManager } = require('~/config');
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
const { updateMCPServerTools } = require('~/server/services/Config');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
/**
@ -41,6 +41,33 @@ async function reinitMCPServer({
let oauthUrl = null;
try {
const registry = getMCPServersRegistry();
const serverConfig = await registry.getServerConfig(serverName, user?.id);
if (serverConfig?.inspectionFailed) {
logger.info(
`[MCP Reinitialize] Server ${serverName} had failed inspection, attempting reinspection`,
);
try {
const storageLocation = serverConfig.dbId ? 'DB' : 'CACHE';
await registry.reinspectServer(serverName, storageLocation, user?.id);
logger.info(`[MCP Reinitialize] Reinspection succeeded for server: ${serverName}`);
} catch (reinspectError) {
logger.error(
`[MCP Reinitialize] Reinspection failed for server ${serverName}:`,
reinspectError,
);
return {
availableTools: null,
success: false,
message: `MCP server '${serverName}' is still unreachable`,
oauthRequired: false,
serverName,
oauthUrl: null,
tools: null,
};
}
}
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
const flowManager = _flowManager ?? getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const mcpManager = getMCPManager();

View file

@ -1,4 +1,8 @@
const { AgentCapabilities, defaultAgentCapabilities } = require('librechat-data-provider');
const {
Constants,
AgentCapabilities,
defaultAgentCapabilities,
} = require('librechat-data-provider');
/**
* Tests for ToolService capability checking logic.
@ -119,6 +123,55 @@ describe('ToolService - Capability Checking', () => {
});
});
describe('userMCPAuthMap gating', () => {
/**
* Simulates the guard condition used in both loadToolDefinitionsWrapper
* and loadAgentTools to decide whether getUserMCPAuthMap should be called.
*/
const shouldFetchMCPAuth = (tools) =>
tools?.some((t) => t.includes(Constants.mcp_delimiter)) ?? false;
it('should return true when agent has MCP tools', () => {
const tools = ['web_search', `search${Constants.mcp_delimiter}my-mcp-server`, 'calculator'];
expect(shouldFetchMCPAuth(tools)).toBe(true);
});
it('should return false when agent has no MCP tools', () => {
const tools = ['web_search', 'calculator', 'code_interpreter'];
expect(shouldFetchMCPAuth(tools)).toBe(false);
});
it('should return false when tools is empty', () => {
expect(shouldFetchMCPAuth([])).toBe(false);
});
it('should return false when tools is undefined', () => {
expect(shouldFetchMCPAuth(undefined)).toBe(false);
});
it('should return false when tools is null', () => {
expect(shouldFetchMCPAuth(null)).toBe(false);
});
it('should detect MCP tools with different server names', () => {
const tools = [
`listFiles${Constants.mcp_delimiter}file-server`,
`query${Constants.mcp_delimiter}db-server`,
];
expect(shouldFetchMCPAuth(tools)).toBe(true);
});
it('should return true even when only one tool is MCP', () => {
const tools = [
'web_search',
'calculator',
'code_interpreter',
`echo${Constants.mcp_delimiter}test-server`,
];
expect(shouldFetchMCPAuth(tools)).toBe(true);
});
});
describe('deferredToolsEnabled integration', () => {
it('should correctly determine deferredToolsEnabled from capabilities set', () => {
const createCheckCapability = (enabledCapabilities) => {

View file

@ -153,9 +153,11 @@ const generateBackupCodes = async (count = 10) => {
* @param {Object} params
* @param {Object} params.user
* @param {string} params.backupCode
* @param {boolean} [params.persist=true] - Whether to persist the used-mark to the database.
* Pass `false` when the caller will immediately overwrite `backupCodes` (e.g. re-enrollment).
* @returns {Promise<boolean>}
*/
const verifyBackupCode = async ({ user, backupCode }) => {
const verifyBackupCode = async ({ user, backupCode, persist = true }) => {
if (!backupCode || !user || !Array.isArray(user.backupCodes)) {
return false;
}
@ -165,17 +167,50 @@ const verifyBackupCode = async ({ user, backupCode }) => {
(codeObj) => codeObj.codeHash === hashedInput && !codeObj.used,
);
if (matchingCode) {
if (!matchingCode) {
return false;
}
if (persist) {
const updatedBackupCodes = user.backupCodes.map((codeObj) =>
codeObj.codeHash === hashedInput && !codeObj.used
? { ...codeObj, used: true, usedAt: new Date() }
: codeObj,
);
// Update the user record with the marked backup code.
await updateUser(user._id, { backupCodes: updatedBackupCodes });
return true;
}
return false;
return true;
};
/**
* Verifies a user's identity via TOTP token or backup code.
* @param {Object} params
* @param {Object} params.user - The user document (must include totpSecret and backupCodes).
* @param {string} [params.token] - A 6-digit TOTP token.
* @param {string} [params.backupCode] - An 8-character backup code.
* @param {boolean} [params.persistBackupUse=true] - Whether to mark the backup code as used in the DB.
* @returns {Promise<{ verified: boolean, status?: number, message?: string }>}
*/
const verifyOTPOrBackupCode = async ({ user, token, backupCode, persistBackupUse = true }) => {
if (!token && !backupCode) {
return { verified: false, status: 400 };
}
if (token) {
const secret = await getTOTPSecret(user.totpSecret);
if (!secret) {
return { verified: false, status: 400, message: '2FA secret is missing or corrupted' };
}
const ok = await verifyTOTP(secret, token);
return ok
? { verified: true }
: { verified: false, status: 401, message: 'Invalid token or backup code' };
}
const ok = await verifyBackupCode({ user, backupCode, persist: persistBackupUse });
return ok
? { verified: true }
: { verified: false, status: 401, message: 'Invalid token or backup code' };
};
/**
@ -213,11 +248,12 @@ const generate2FATempToken = (userId) => {
};
module.exports = {
generateTOTPSecret,
generateTOTP,
verifyTOTP,
verifyOTPOrBackupCode,
generate2FATempToken,
generateBackupCodes,
generateTOTPSecret,
verifyBackupCode,
getTOTPSecret,
generate2FATempToken,
generateTOTP,
verifyTOTP,
};

View file

@ -358,16 +358,15 @@ function splitAtTargetLevel(messages, targetMessageId) {
* @param {object} params - The parameters for duplicating the conversation.
* @param {string} params.userId - The ID of the user duplicating the conversation.
* @param {string} params.conversationId - The ID of the conversation to duplicate.
* @param {string} [params.title] - Optional title override for the duplicate.
* @returns {Promise<{ conversation: TConversation, messages: TMessage[] }>} The duplicated conversation and messages.
*/
async function duplicateConversation({ userId, conversationId }) {
// Get original conversation
async function duplicateConversation({ userId, conversationId, title }) {
const originalConvo = await getConvo(userId, conversationId);
if (!originalConvo) {
throw new Error('Conversation not found');
}
// Get original messages
const originalMessages = await getMessages({
user: userId,
conversationId,
@ -383,14 +382,11 @@ async function duplicateConversation({ userId, conversationId }) {
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
const result = importBatchBuilder.finishConversation(
originalConvo.title,
new Date(),
originalConvo,
);
const duplicateTitle = title || originalConvo.title;
const result = importBatchBuilder.finishConversation(duplicateTitle, new Date(), originalConvo);
await importBatchBuilder.saveBatch();
logger.debug(
`user: ${userId} | New conversation "${originalConvo.title}" duplicated from conversation ID ${conversationId}`,
`user: ${userId} | New conversation "${duplicateTitle}" duplicated from conversation ID ${conversationId}`,
);
const conversation = await getConvo(userId, result.conversation.conversationId);

View file

@ -1,7 +1,10 @@
const fs = require('fs').promises;
const { resolveImportMaxFileSize } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getImporter } = require('./importers');
const maxFileSize = resolveImportMaxFileSize();
/**
* Job definition for importing a conversation.
* @param {{ filepath, requestUserId }} job - The job object.
@ -11,11 +14,10 @@ const importConversations = async (job) => {
try {
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
/* error if file is too large */
const fileInfo = await fs.stat(filepath);
if (fileInfo.size > process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES) {
if (fileInfo.size > maxFileSize) {
throw new Error(
`File size is ${fileInfo.size} bytes. It exceeds the maximum limit of ${process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES} bytes.`,
`File size is ${fileInfo.size} bytes. It exceeds the maximum limit of ${maxFileSize} bytes.`,
);
}

View file

@ -1277,12 +1277,9 @@ describe('processAssistantMessage', () => {
results.push(duration);
});
// Check if processing time increases exponentially
// In a ReDoS vulnerability, time would roughly double with each size increase
for (let i = 1; i < results.length; i++) {
const ratio = results[i] / results[i - 1];
expect(ratio).toBeLessThan(3); // Allow for CI environment variability while still catching ReDoS
console.log(`Size ${sizes[i]} processing time ratio: ${ratio}`);
// Each size should complete well under 100ms; a ReDoS would cause exponential blowup
for (let i = 0; i < results.length; i++) {
expect(results[i]).toBeLessThan(100);
}
// Also test with the exact payload from the security report

View file

@ -1,4 +1,4 @@
const { setupOpenId, getOpenIdConfig } = require('./openidStrategy');
const { setupOpenId, getOpenIdConfig, getOpenIdEmail } = require('./openidStrategy');
const openIdJwtLogin = require('./openIdJwtStrategy');
const facebookLogin = require('./facebookStrategy');
const discordLogin = require('./discordStrategy');
@ -20,6 +20,7 @@ module.exports = {
facebookLogin,
setupOpenId,
getOpenIdConfig,
getOpenIdEmail,
ldapLogin,
setupSaml,
openIdJwtLogin,

View file

@ -5,6 +5,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent');
const { SystemRoles } = require('librechat-data-provider');
const { isEnabled, findOpenIDUser, math } = require('@librechat/api');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { getOpenIdEmail } = require('./openidStrategy');
const { updateUser, findUser } = require('~/models');
/**
@ -53,7 +54,7 @@ const openIdJwtLogin = (openIdConfig) => {
const { user, error, migration } = await findOpenIDUser({
findUser,
email: payload?.email,
email: payload ? getOpenIdEmail(payload) : undefined,
openidId: payload?.sub,
idOnTheSource: payload?.oid,
strategyName: 'openIdJwtLogin',

View file

@ -29,10 +29,21 @@ jest.mock('~/models', () => ({
findUser: jest.fn(),
updateUser: jest.fn(),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
})),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({}),
}));
jest.mock('~/cache/getLogStores', () =>
jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn() }),
);
const { findOpenIDUser } = require('@librechat/api');
const { updateUser } = require('~/models');
const openIdJwtLogin = require('./openIdJwtStrategy');
const { findUser, updateUser } = require('~/models');
// Helper: build a mock openIdConfig
const mockOpenIdConfig = {
@ -181,3 +192,156 @@ describe('openIdJwtStrategy token source handling', () => {
expect(user.federatedTokens.access_token).not.toBe(user.federatedTokens.id_token);
});
});
describe('openIdJwtStrategy OPENID_EMAIL_CLAIM', () => {
const payload = {
sub: 'oidc-123',
email: 'test@example.com',
preferred_username: 'testuser',
upn: 'test@corp.example.com',
exp: 9999999999,
};
beforeEach(() => {
jest.clearAllMocks();
delete process.env.OPENID_EMAIL_CLAIM;
// Use real findOpenIDUser so it delegates to the findUser mock
const realFindOpenIDUser = jest.requireActual('@librechat/api').findOpenIDUser;
findOpenIDUser.mockImplementation(realFindOpenIDUser);
findUser.mockResolvedValue(null);
updateUser.mockResolvedValue({});
openIdJwtLogin(mockOpenIdConfig);
});
afterEach(() => {
delete process.env.OPENID_EMAIL_CLAIM;
});
it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => {
const existingUser = {
_id: 'user-id-1',
provider: 'openid',
openidId: payload.sub,
email: payload.email,
role: SystemRoles.USER,
};
findUser.mockImplementation(async (query) => {
if (query.$or && query.$or.some((c) => c.openidId === payload.sub)) {
return existingUser;
}
return null;
});
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith(
expect.objectContaining({
$or: expect.arrayContaining([{ openidId: payload.sub }]),
}),
);
});
it('should use OPENID_EMAIL_CLAIM when set for email lookup', async () => {
process.env.OPENID_EMAIL_CLAIM = 'upn';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledTimes(2);
expect(findUser.mock.calls[0][0]).toMatchObject({
$or: expect.arrayContaining([{ openidId: payload.sub }]),
});
expect(findUser.mock.calls[1][0]).toEqual({ email: 'test@corp.example.com' });
expect(user).toBe(false);
});
it('should fall back to default chain when OPENID_EMAIL_CLAIM points to missing claim', async () => {
process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
expect(user).toBe(false);
});
it('should trim whitespace from OPENID_EMAIL_CLAIM', async () => {
process.env.OPENID_EMAIL_CLAIM = ' upn ';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: 'test@corp.example.com' });
});
it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => {
process.env.OPENID_EMAIL_CLAIM = '';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
});
it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => {
process.env.OPENID_EMAIL_CLAIM = ' ';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
});
it('should resolve undefined email when payload is null', async () => {
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, null);
expect(user).toBe(false);
});
it('should attempt email lookup via preferred_username fallback when email claim is absent', async () => {
const payloadNoEmail = {
sub: 'oidc-new-sub',
preferred_username: 'legacy@corp.com',
upn: 'legacy@corp.com',
exp: 9999999999,
};
const legacyUser = {
_id: 'legacy-db-id',
email: 'legacy@corp.com',
openidId: null,
role: SystemRoles.USER,
};
findUser.mockImplementation(async (query) => {
if (query.$or) {
return null;
}
if (query.email === 'legacy@corp.com') {
return legacyUser;
}
return null;
});
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, payloadNoEmail);
expect(findUser).toHaveBeenCalledTimes(2);
expect(findUser.mock.calls[1][0]).toEqual({ email: 'legacy@corp.com' });
expect(user).toBeTruthy();
expect(updateUser).toHaveBeenCalledWith(
'legacy-db-id',
expect.objectContaining({ provider: 'openid', openidId: payloadNoEmail.sub }),
);
});
});

View file

@ -267,6 +267,34 @@ function getFullName(userinfo) {
return userinfo.username || userinfo.email;
}
/**
* Resolves the user identifier from OpenID claims.
* Configurable via OPENID_EMAIL_CLAIM; defaults to: email -> preferred_username -> upn.
*
* @param {Object} userinfo - The user information object from OpenID Connect
* @returns {string|undefined} The resolved identifier string
*/
function getOpenIdEmail(userinfo) {
const claimKey = process.env.OPENID_EMAIL_CLAIM?.trim();
if (claimKey) {
const value = userinfo[claimKey];
if (typeof value === 'string' && value) {
return value;
}
if (value !== undefined && value !== null) {
logger.warn(
`[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" resolved to a non-string value (type: ${typeof value}). Falling back to: email -> preferred_username -> upn.`,
);
} else {
logger.warn(
`[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" not present in userinfo. Falling back to: email -> preferred_username -> upn.`,
);
}
}
const fallback = userinfo.email || userinfo.preferred_username || userinfo.upn;
return typeof fallback === 'string' ? fallback : undefined;
}
/**
* Converts an input into a string suitable for a username.
* If the input is a string, it will be returned as is.
@ -379,11 +407,10 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
}
const appConfig = await getAppConfig();
/** Azure AD sometimes doesn't return email, use preferred_username as fallback */
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
const email = getOpenIdEmail(userinfo);
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
`[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`,
);
throw new Error('Email domain not allowed');
}
@ -451,7 +478,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
throw new Error(`You must have ${rolesList} role to log in.`);
}
const roleValues = Array.isArray(roles) ? roles : [roles];
const roleValues = Array.isArray(roles) ? roles : roles.split(/[\s,]+/).filter(Boolean);
if (!requiredRoles.some((role) => roleValues.includes(role))) {
const rolesList =
@ -524,13 +551,14 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
}
const adminRoles = get(adminRoleObject, adminRoleParameterPath);
let adminRoleValues = [];
if (Array.isArray(adminRoles)) {
adminRoleValues = adminRoles;
} else if (typeof adminRoles === 'string') {
adminRoleValues = adminRoles.split(/[\s,]+/).filter(Boolean);
}
if (
adminRoles &&
(adminRoles === true ||
adminRoles === adminRole ||
(Array.isArray(adminRoles) && adminRoles.includes(adminRole)))
) {
if (adminRoles && (adminRoles === true || adminRoleValues.includes(adminRole))) {
user.role = SystemRoles.ADMIN;
logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`);
} else if (user.role === SystemRoles.ADMIN) {
@ -727,4 +755,5 @@ function getOpenIdConfig() {
module.exports = {
setupOpenId,
getOpenIdConfig,
getOpenIdEmail,
};

View file

@ -1,6 +1,6 @@
const undici = require('undici');
const fetch = require('node-fetch');
const jwtDecode = require('jsonwebtoken/decode');
const undici = require('undici');
const { ErrorTypes } = require('librechat-data-provider');
const { findUser, createUser, updateUser } = require('~/models');
const { setupOpenId } = require('./openidStrategy');
@ -152,6 +152,7 @@ describe('setupOpenId', () => {
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
delete process.env.OPENID_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM;
delete process.env.OPENID_EMAIL_CLAIM;
delete process.env.PROXY;
delete process.env.OPENID_USE_PKCE;
@ -384,6 +385,62 @@ describe('setupOpenId', () => {
expect(details.message).toBe('You must have "read" role to log in.');
});
it('should allow login when roles claim is a space-separated string containing the required role', async () => {
// Arrange IdP returns roles as a space-delimited string
jwtDecode.mockReturnValue({
roles: 'role1 role2 requiredRole',
});
// Act
const { user } = await validate(tokenset);
// Assert login succeeds when required role is present after splitting
expect(user).toBeTruthy();
expect(createUser).toHaveBeenCalled();
});
it('should allow login when roles claim is a comma-separated string containing the required role', async () => {
// Arrange IdP returns roles as a comma-delimited string
jwtDecode.mockReturnValue({
roles: 'role1,role2,requiredRole',
});
// Act
const { user } = await validate(tokenset);
// Assert login succeeds when required role is present after splitting
expect(user).toBeTruthy();
expect(createUser).toHaveBeenCalled();
});
it('should allow login when roles claim is a mixed comma-and-space-separated string containing the required role', async () => {
// Arrange IdP returns roles with comma-and-space delimiters
jwtDecode.mockReturnValue({
roles: 'role1, role2, requiredRole',
});
// Act
const { user } = await validate(tokenset);
// Assert login succeeds when required role is present after splitting
expect(user).toBeTruthy();
expect(createUser).toHaveBeenCalled();
});
it('should reject login when roles claim is a space-separated string that does not contain the required role', async () => {
// Arrange IdP returns a delimited string but required role is absent
jwtDecode.mockReturnValue({
roles: 'role1 role2 otherRole',
});
// Act
const { user, details } = await validate(tokenset);
// Assert login is rejected with the correct error message
expect(user).toBe(false);
expect(details.message).toBe('You must have "requiredRole" role to log in.');
});
it('should allow login when single required role is present (backward compatibility)', async () => {
// Arrange ensure single role configuration (as set in beforeEach)
// OPENID_REQUIRED_ROLE = 'requiredRole'
@ -1182,6 +1239,46 @@ describe('setupOpenId', () => {
expect(user.role).toBeUndefined();
});
it('should grant admin when admin role claim is a space-separated string containing the admin role', async () => {
// Arrange IdP returns admin roles as a space-delimited string
process.env.OPENID_ADMIN_ROLE = 'site-admin';
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
app_roles: 'user site-admin moderator',
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
// Act
const { user } = await validate(tokenset);
// Assert admin role is granted after splitting the delimited string
expect(user.role).toBe('ADMIN');
});
it('should not grant admin when admin role claim is a space-separated string that does not contain the admin role', async () => {
// Arrange delimited string present but admin role is absent
process.env.OPENID_ADMIN_ROLE = 'site-admin';
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
app_roles: 'user moderator',
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');
// Act
const { user } = await validate(tokenset);
// Assert admin role is not granted
expect(user.role).toBeUndefined();
});
it('should handle nested path with special characters in keys', async () => {
process.env.OPENID_REQUIRED_ROLE = 'app-user';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-app-123.roles';
@ -1306,4 +1403,82 @@ describe('setupOpenId', () => {
expect(user).toBe(false);
});
});
describe('OPENID_EMAIL_CLAIM', () => {
it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => {
const { user } = await validate(tokenset);
expect(user.email).toBe('test@example.com');
});
it('should use the configured claim when OPENID_EMAIL_CLAIM is set', async () => {
process.env.OPENID_EMAIL_CLAIM = 'upn';
const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
const { user } = await validate({ ...tokenset, claims: () => userinfo });
expect(user.email).toBe('user@corp.example.com');
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'user@corp.example.com' }),
expect.anything(),
true,
true,
);
});
it('should fall back to preferred_username when email is missing and OPENID_EMAIL_CLAIM is not set', async () => {
const userinfo = { ...tokenset.claims() };
delete userinfo.email;
const { user } = await validate({ ...tokenset, claims: () => userinfo });
expect(user.email).toBe('testusername');
});
it('should fall back to upn when email and preferred_username are missing and OPENID_EMAIL_CLAIM is not set', async () => {
const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
delete userinfo.email;
delete userinfo.preferred_username;
const { user } = await validate({ ...tokenset, claims: () => userinfo });
expect(user.email).toBe('user@corp.example.com');
});
it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => {
process.env.OPENID_EMAIL_CLAIM = '';
const { user } = await validate(tokenset);
expect(user.email).toBe('test@example.com');
});
it('should trim whitespace from OPENID_EMAIL_CLAIM and resolve correctly', async () => {
process.env.OPENID_EMAIL_CLAIM = ' upn ';
const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' };
const { user } = await validate({ ...tokenset, claims: () => userinfo });
expect(user.email).toBe('user@corp.example.com');
});
it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => {
process.env.OPENID_EMAIL_CLAIM = ' ';
const { user } = await validate(tokenset);
expect(user.email).toBe('test@example.com');
});
it('should fall back to default chain with warning when configured claim is missing from userinfo', async () => {
const { logger } = require('@librechat/data-schemas');
process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim';
const { user } = await validate(tokenset);
expect(user.email).toBe('test@example.com');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('OPENID_EMAIL_CLAIM="nonexistent_claim" not present in userinfo'),
);
});
});
});

View file

@ -1,5 +1,4 @@
// --- Mocks ---
jest.mock('tiktoken');
jest.mock('fs');
jest.mock('path');
jest.mock('node-fetch');

View file

@ -0,0 +1,876 @@
const fs = require('fs');
const fetch = require('node-fetch');
const { Readable } = require('stream');
const { FileSources } = require('librechat-data-provider');
const {
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
DeleteObjectCommand,
} = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
// Mock dependencies
jest.mock('fs');
jest.mock('node-fetch');
jest.mock('@aws-sdk/s3-request-presigner');
jest.mock('@aws-sdk/client-s3');
jest.mock('@librechat/api', () => ({
initializeS3: jest.fn(),
deleteRagFile: jest.fn().mockResolvedValue(undefined),
isEnabled: jest.fn((val) => val === 'true'),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
const { initializeS3, deleteRagFile } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
// Set env vars before requiring crud so module-level constants pick them up
process.env.AWS_BUCKET_NAME = 'test-bucket';
process.env.S3_URL_EXPIRY_SECONDS = '120';
const {
saveBufferToS3,
saveURLToS3,
getS3URL,
deleteFileFromS3,
uploadFileToS3,
getS3FileStream,
refreshS3FileUrls,
refreshS3Url,
needsRefresh,
getNewS3URL,
extractKeyFromS3Url,
} = require('~/server/services/Files/S3/crud');
describe('S3 CRUD Operations', () => {
let mockS3Client;
beforeEach(() => {
jest.clearAllMocks();
// Setup mock S3 client
mockS3Client = {
send: jest.fn(),
};
initializeS3.mockReturnValue(mockS3Client);
});
afterEach(() => {
delete process.env.S3_URL_EXPIRY_SECONDS;
delete process.env.S3_REFRESH_EXPIRY_MS;
delete process.env.AWS_BUCKET_NAME;
});
describe('saveBufferToS3', () => {
it('should upload a buffer to S3 and return a signed URL', async () => {
const mockBuffer = Buffer.from('test data');
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/test.jpg?signature=abc';
mockS3Client.send.mockResolvedValue({});
getSignedUrl.mockResolvedValue(mockSignedUrl);
const result = await saveBufferToS3({
userId: 'user123',
buffer: mockBuffer,
fileName: 'test.jpg',
basePath: 'images',
});
expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(PutObjectCommand));
expect(result).toBe(mockSignedUrl);
});
it('should use default basePath if not provided', async () => {
const mockBuffer = Buffer.from('test data');
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/test.jpg?signature=abc';
mockS3Client.send.mockResolvedValue({});
getSignedUrl.mockResolvedValue(mockSignedUrl);
await saveBufferToS3({
userId: 'user123',
buffer: mockBuffer,
fileName: 'test.jpg',
});
expect(getSignedUrl).toHaveBeenCalled();
});
it('should handle S3 upload errors', async () => {
const mockBuffer = Buffer.from('test data');
const error = new Error('S3 upload failed');
mockS3Client.send.mockRejectedValue(error);
await expect(
saveBufferToS3({
userId: 'user123',
buffer: mockBuffer,
fileName: 'test.jpg',
}),
).rejects.toThrow('S3 upload failed');
expect(logger.error).toHaveBeenCalledWith(
'[saveBufferToS3] Error uploading buffer to S3:',
'S3 upload failed',
);
});
});
describe('getS3URL', () => {
it('should return a signed URL for a file', async () => {
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf?signature=xyz';
getSignedUrl.mockResolvedValue(mockSignedUrl);
const result = await getS3URL({
userId: 'user123',
fileName: 'file.pdf',
basePath: 'documents',
});
expect(result).toBe(mockSignedUrl);
expect(getSignedUrl).toHaveBeenCalledWith(
mockS3Client,
expect.any(GetObjectCommand),
expect.objectContaining({ expiresIn: 120 }),
);
});
it('should add custom filename to Content-Disposition header', async () => {
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf?signature=xyz';
getSignedUrl.mockResolvedValue(mockSignedUrl);
await getS3URL({
userId: 'user123',
fileName: 'file.pdf',
customFilename: 'custom-name.pdf',
});
expect(getSignedUrl).toHaveBeenCalled();
});
it('should add custom content type', async () => {
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf?signature=xyz';
getSignedUrl.mockResolvedValue(mockSignedUrl);
await getS3URL({
userId: 'user123',
fileName: 'file.pdf',
contentType: 'application/pdf',
});
expect(getSignedUrl).toHaveBeenCalled();
});
it('should handle errors when getting signed URL', async () => {
const error = new Error('Failed to sign URL');
getSignedUrl.mockRejectedValue(error);
await expect(
getS3URL({
userId: 'user123',
fileName: 'file.pdf',
}),
).rejects.toThrow('Failed to sign URL');
expect(logger.error).toHaveBeenCalledWith(
'[getS3URL] Error getting signed URL from S3:',
'Failed to sign URL',
);
});
});
describe('saveURLToS3', () => {
it('should fetch a file from URL and save to S3', async () => {
const mockBuffer = Buffer.from('downloaded data');
const mockResponse = {
buffer: jest.fn().mockResolvedValue(mockBuffer),
};
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/downloaded.jpg?signature=abc';
fetch.mockResolvedValue(mockResponse);
mockS3Client.send.mockResolvedValue({});
getSignedUrl.mockResolvedValue(mockSignedUrl);
const result = await saveURLToS3({
userId: 'user123',
URL: 'https://example.com/image.jpg',
fileName: 'downloaded.jpg',
});
expect(fetch).toHaveBeenCalledWith('https://example.com/image.jpg');
expect(mockS3Client.send).toHaveBeenCalled();
expect(result).toBe(mockSignedUrl);
});
it('should handle fetch errors', async () => {
const error = new Error('Network error');
fetch.mockRejectedValue(error);
await expect(
saveURLToS3({
userId: 'user123',
URL: 'https://example.com/image.jpg',
fileName: 'downloaded.jpg',
}),
).rejects.toThrow('Network error');
expect(logger.error).toHaveBeenCalled();
});
});
describe('deleteFileFromS3', () => {
const mockReq = {
user: { id: 'user123' },
};
it('should delete a file from S3', async () => {
const mockFile = {
filepath: 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg',
file_id: 'file123',
};
// Mock HeadObject to verify file exists
mockS3Client.send
.mockResolvedValueOnce({}) // First HeadObject - exists
.mockResolvedValueOnce({}) // DeleteObject
.mockRejectedValueOnce({ name: 'NotFound' }); // Second HeadObject - deleted
await deleteFileFromS3(mockReq, mockFile);
expect(deleteRagFile).toHaveBeenCalledWith({ userId: 'user123', file: mockFile });
expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(HeadObjectCommand));
expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(DeleteObjectCommand));
});
it('should handle file not found gracefully', async () => {
const mockFile = {
filepath: 'https://s3.amazonaws.com/test-bucket/images/user123/nonexistent.jpg',
file_id: 'file123',
};
mockS3Client.send.mockRejectedValue({ name: 'NotFound' });
await deleteFileFromS3(mockReq, mockFile);
expect(logger.warn).toHaveBeenCalled();
});
it('should throw error if user ID does not match', async () => {
const mockFile = {
filepath: 'https://s3.amazonaws.com/test-bucket/images/different-user/file.jpg',
file_id: 'file123',
};
await expect(deleteFileFromS3(mockReq, mockFile)).rejects.toThrow('User ID mismatch');
expect(logger.error).toHaveBeenCalled();
});
it('should handle NoSuchKey error', async () => {
const mockFile = {
filepath: 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg',
file_id: 'file123',
};
mockS3Client.send
.mockResolvedValueOnce({}) // HeadObject - exists
.mockRejectedValueOnce({ code: 'NoSuchKey' }); // DeleteObject fails
await deleteFileFromS3(mockReq, mockFile);
expect(logger.debug).toHaveBeenCalled();
});
});
describe('uploadFileToS3', () => {
const mockReq = {
user: { id: 'user123' },
};
it('should upload a file from disk to S3', async () => {
const mockFile = {
path: '/tmp/upload.jpg',
originalname: 'photo.jpg',
};
const mockStats = { size: 1024 };
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/file123__photo.jpg?signature=xyz';
fs.promises = { stat: jest.fn().mockResolvedValue(mockStats) };
fs.createReadStream = jest.fn().mockReturnValue(new Readable());
mockS3Client.send.mockResolvedValue({});
getSignedUrl.mockResolvedValue(mockSignedUrl);
const result = await uploadFileToS3({
req: mockReq,
file: mockFile,
file_id: 'file123',
basePath: 'images',
});
expect(result).toEqual({
filepath: mockSignedUrl,
bytes: 1024,
});
expect(fs.createReadStream).toHaveBeenCalledWith('/tmp/upload.jpg');
expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(PutObjectCommand));
});
it('should handle upload errors and clean up temp file', async () => {
const mockFile = {
path: '/tmp/upload.jpg',
originalname: 'photo.jpg',
};
const error = new Error('Upload failed');
fs.promises = {
stat: jest.fn().mockResolvedValue({ size: 1024 }),
unlink: jest.fn().mockResolvedValue(),
};
fs.createReadStream = jest.fn().mockReturnValue(new Readable());
mockS3Client.send.mockRejectedValue(error);
await expect(
uploadFileToS3({
req: mockReq,
file: mockFile,
file_id: 'file123',
}),
).rejects.toThrow('Upload failed');
expect(logger.error).toHaveBeenCalledWith(
'[uploadFileToS3] Error streaming file to S3:',
error,
);
});
});
describe('getS3FileStream', () => {
it('should return a readable stream for a file', async () => {
const mockStream = new Readable();
const mockResponse = { Body: mockStream };
mockS3Client.send.mockResolvedValue(mockResponse);
const result = await getS3FileStream(
{},
'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf',
);
expect(result).toBe(mockStream);
expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(GetObjectCommand));
});
it('should handle errors when retrieving stream', async () => {
const error = new Error('Stream error');
mockS3Client.send.mockRejectedValue(error);
await expect(getS3FileStream({}, 'images/user123/file.pdf')).rejects.toThrow('Stream error');
expect(logger.error).toHaveBeenCalled();
});
});
describe('needsRefresh', () => {
it('should return false for non-signed URLs', () => {
const url = 'https://example.com/proxy/file.jpg';
const result = needsRefresh(url, 3600);
expect(result).toBe(false);
});
it('should return true for expired signed URLs', () => {
const now = new Date();
const past = new Date(now.getTime() - 3600 * 1000); // 1 hour ago
const dateStr = past
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const url = `https://s3.amazonaws.com/bucket/key?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`;
const result = needsRefresh(url, 60);
expect(result).toBe(true);
});
it('should return false for URLs that are not close to expiration', () => {
const now = new Date();
const recent = new Date(now.getTime() - 10 * 1000); // 10 seconds ago
const dateStr = recent
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const url = `https://s3.amazonaws.com/bucket/key?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=7200`;
const result = needsRefresh(url, 60);
expect(result).toBe(false);
});
it('should use custom refresh expiry when S3_REFRESH_EXPIRY_MS is set', () => {
process.env.S3_REFRESH_EXPIRY_MS = '30000'; // 30 seconds
const now = new Date();
const recent = new Date(now.getTime() - 31 * 1000); // 31 seconds ago
const dateStr = recent
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const url = `https://s3.amazonaws.com/bucket/key?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=7200`;
// Need to reload the module to pick up the env var change
jest.resetModules();
const { needsRefresh: needsRefreshReloaded } = require('~/server/services/Files/S3/crud');
const result = needsRefreshReloaded(url, 60);
expect(result).toBe(true);
});
it('should return true for malformed URLs', () => {
const url = 'not-a-valid-url';
const result = needsRefresh(url, 3600);
expect(result).toBe(true);
});
});
describe('getNewS3URL', () => {
it('should generate a new URL from an existing S3 URL', async () => {
const currentURL =
'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg?signature=old';
const newURL = 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg?signature=new';
getSignedUrl.mockResolvedValue(newURL);
const result = await getNewS3URL(currentURL);
expect(result).toBe(newURL);
expect(getSignedUrl).toHaveBeenCalled();
});
it('should return undefined for invalid URLs', async () => {
const result = await getNewS3URL('invalid-url');
expect(result).toBeUndefined();
});
it('should handle errors gracefully', async () => {
const currentURL = 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg';
getSignedUrl.mockRejectedValue(new Error('Failed'));
const result = await getNewS3URL(currentURL);
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith('Error getting new S3 URL:', expect.any(Error));
});
it('should construct GetObjectCommand with correct key (no bucket name duplication)', async () => {
const currentURL =
'https://s3.amazonaws.com/my-bucket/images/user123/file.jpg?X-Amz-Signature=old';
getSignedUrl.mockResolvedValue(
'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg?signature=new',
);
await getNewS3URL(currentURL);
expect(GetObjectCommand).toHaveBeenCalledWith(
expect.objectContaining({ Key: 'images/user123/file.jpg' }),
);
});
});
describe('refreshS3FileUrls', () => {
it('should refresh expired URLs for multiple files', async () => {
const now = new Date();
const past = new Date(now.getTime() - 3600 * 1000);
const dateStr = past
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const files = [
{
file_id: 'file1',
source: FileSources.s3,
filepath: `https://s3.amazonaws.com/bucket/images/user123/file1.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`,
},
{
file_id: 'file2',
source: FileSources.s3,
filepath: `https://s3.amazonaws.com/bucket/images/user123/file2.jpg?X-Amz-Signature=def&X-Amz-Date=${dateStr}&X-Amz-Expires=60`,
},
];
const newURL1 = 'https://s3.amazonaws.com/bucket/images/user123/file1.jpg?signature=new1';
const newURL2 = 'https://s3.amazonaws.com/bucket/images/user123/file2.jpg?signature=new2';
getSignedUrl.mockResolvedValueOnce(newURL1).mockResolvedValueOnce(newURL2);
const mockBatchUpdate = jest.fn().mockResolvedValue();
const result = await refreshS3FileUrls(files, mockBatchUpdate, 60);
expect(result[0].filepath).toBe(newURL1);
expect(result[1].filepath).toBe(newURL2);
expect(mockBatchUpdate).toHaveBeenCalledWith([
{ file_id: 'file1', filepath: newURL1 },
{ file_id: 'file2', filepath: newURL2 },
]);
});
it('should skip non-S3 files', async () => {
const files = [
{
file_id: 'file1',
source: 'local',
filepath: '/local/path/file.jpg',
},
];
const mockBatchUpdate = jest.fn();
const result = await refreshS3FileUrls(files, mockBatchUpdate);
expect(result).toEqual(files);
expect(mockBatchUpdate).not.toHaveBeenCalled();
});
it('should handle empty or invalid input', async () => {
const mockBatchUpdate = jest.fn();
const result1 = await refreshS3FileUrls(null, mockBatchUpdate);
expect(result1).toBe(null);
const result2 = await refreshS3FileUrls([], mockBatchUpdate);
expect(result2).toEqual([]);
expect(mockBatchUpdate).not.toHaveBeenCalled();
});
it('should handle errors for individual files gracefully', async () => {
const now = new Date();
const past = new Date(now.getTime() - 3600 * 1000);
const dateStr = past
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const files = [
{
file_id: 'file1',
source: FileSources.s3,
filepath: `https://s3.amazonaws.com/bucket/images/user123/file1.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`,
},
];
getSignedUrl.mockRejectedValue(new Error('Failed to refresh'));
const mockBatchUpdate = jest.fn();
await refreshS3FileUrls(files, mockBatchUpdate, 60);
expect(logger.error).toHaveBeenCalledWith('Error getting new S3 URL:', expect.any(Error));
expect(mockBatchUpdate).not.toHaveBeenCalled();
});
});
describe('refreshS3Url', () => {
it('should refresh an expired S3 URL', async () => {
const now = new Date();
const past = new Date(now.getTime() - 3600 * 1000);
const dateStr = past
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const fileObj = {
source: FileSources.s3,
filepath: `https://s3.amazonaws.com/bucket/images/user123/file.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`,
};
const newURL = 'https://s3.amazonaws.com/bucket/images/user123/file.jpg?signature=new';
getSignedUrl.mockResolvedValue(newURL);
const result = await refreshS3Url(fileObj, 60);
expect(result).toBe(newURL);
});
it('should return original URL if not expired', async () => {
const fileObj = {
source: FileSources.s3,
filepath: 'https://example.com/proxy/file.jpg',
};
const result = await refreshS3Url(fileObj, 3600);
expect(result).toBe(fileObj.filepath);
expect(getSignedUrl).not.toHaveBeenCalled();
});
it('should return empty string for null input', async () => {
const result = await refreshS3Url(null);
expect(result).toBe('');
});
it('should return original URL for non-S3 files', async () => {
const fileObj = {
source: 'local',
filepath: '/local/path/file.jpg',
};
const result = await refreshS3Url(fileObj);
expect(result).toBe(fileObj.filepath);
});
it('should handle errors and return original URL', async () => {
const now = new Date();
const past = new Date(now.getTime() - 3600 * 1000);
const dateStr = past
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const fileObj = {
source: FileSources.s3,
filepath: `https://s3.amazonaws.com/bucket/images/user123/file.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`,
};
getSignedUrl.mockRejectedValue(new Error('Refresh failed'));
const result = await refreshS3Url(fileObj, 60);
expect(result).toBe(fileObj.filepath);
expect(logger.error).toHaveBeenCalled();
});
});
describe('extractKeyFromS3Url', () => {
it('should extract key from a full S3 URL', () => {
const url = 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg';
const result = extractKeyFromS3Url(url);
expect(result).toBe('images/user123/file.jpg');
});
it('should extract key from a signed S3 URL with query parameters', () => {
const url =
'https://s3.amazonaws.com/test-bucket/documents/user456/report.pdf?X-Amz-Signature=abc123&X-Amz-Date=20260107';
const result = extractKeyFromS3Url(url);
expect(result).toBe('documents/user456/report.pdf');
});
it('should extract key from S3 URL with different domain format', () => {
const url = 'https://test-bucket.s3.amazonaws.com/uploads/user789/image.png';
const result = extractKeyFromS3Url(url);
expect(result).toBe('uploads/user789/image.png');
});
it('should return key as-is if already properly formatted (3+ parts, no http)', () => {
const key = 'images/user123/file.jpg';
const result = extractKeyFromS3Url(key);
expect(result).toBe('images/user123/file.jpg');
});
it('should handle key with leading slash by removing it', () => {
const key = '/images/user123/file.jpg';
const result = extractKeyFromS3Url(key);
expect(result).toBe('images/user123/file.jpg');
});
it('should handle simple key without slashes', () => {
const key = 'simple-file.txt';
const result = extractKeyFromS3Url(key);
expect(result).toBe('simple-file.txt');
});
it('should handle key with only two parts', () => {
const key = 'folder/file.txt';
const result = extractKeyFromS3Url(key);
expect(result).toBe('folder/file.txt');
});
it('should throw error for empty input', () => {
expect(() => extractKeyFromS3Url('')).toThrow('Invalid input: URL or key is empty');
});
it('should throw error for null input', () => {
expect(() => extractKeyFromS3Url(null)).toThrow('Invalid input: URL or key is empty');
});
it('should throw error for undefined input', () => {
expect(() => extractKeyFromS3Url(undefined)).toThrow('Invalid input: URL or key is empty');
});
it('should handle URLs with encoded characters', () => {
const url = 'https://s3.amazonaws.com/test-bucket/images/user123/my%20file%20name.jpg';
const result = extractKeyFromS3Url(url);
expect(result).toBe('images/user123/my%20file%20name.jpg');
});
it('should handle deep nested paths', () => {
const url = 'https://s3.amazonaws.com/bucket/a/b/c/d/e/f/file.jpg';
const result = extractKeyFromS3Url(url);
expect(result).toBe('a/b/c/d/e/f/file.jpg');
});
it('should log debug message when extracting from URL', () => {
const url = 'https://s3.amazonaws.com/bucket/images/user123/file.jpg';
extractKeyFromS3Url(url);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[extractKeyFromS3Url] fileUrlOrKey:'),
);
});
it('should log fallback debug message for non-URL input', () => {
const key = 'simple-file.txt';
extractKeyFromS3Url(key);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[extractKeyFromS3Url] FALLBACK'),
);
});
it('should handle valid URLs that contain only a bucket', () => {
const url = 'https://s3.amazonaws.com/test-bucket/';
const result = extractKeyFromS3Url(url);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'[extractKeyFromS3Url] Extracted key is empty after removing bucket name from URL: https://s3.amazonaws.com/test-bucket/',
),
);
expect(result).toBe('');
});
it('should handle invalid URLs that contain only a bucket', () => {
const url = 'https://s3.amazonaws.com/test-bucket';
const result = extractKeyFromS3Url(url);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'[extractKeyFromS3Url] Unable to extract key from path-style URL: https://s3.amazonaws.com/test-bucket',
),
);
expect(result).toBe('');
});
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html
// Path-style requests
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#path-style-access
// https://s3.region-code.amazonaws.com/bucket-name/key-name
it('should handle formatted according to Path-style regional endpoint', () => {
const url = 'https://s3.us-west-2.amazonaws.com/amzn-s3-demo-bucket1/dogs/puppy.jpg';
const result = extractKeyFromS3Url(url);
expect(result).toBe('dogs/puppy.jpg');
});
// virtual host style
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access
// https://bucket-name.s3.region-code.amazonaws.com/key-name
it('should handle formatted according to Virtual-hostedstyle Regional endpoint', () => {
const url = 'https://amzn-s3-demo-bucket1.s3.us-west-2.amazonaws.com/dogs/puppy.png';
const result = extractKeyFromS3Url(url);
expect(result).toBe('dogs/puppy.png');
});
// Legacy endpoints
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#VirtualHostingBackwardsCompatibility
// s3Region
// https://bucket-name.s3-region-code.amazonaws.com
it('should handle formatted according to s3Region', () => {
const url = 'https://amzn-s3-demo-bucket1.s3-us-west-2.amazonaws.com/puppy.png';
const result = extractKeyFromS3Url(url);
expect(result).toBe('puppy.png');
const testcase2 = 'https://amzn-s3-demo-bucket1.s3-us-west-2.amazonaws.com/cats/kitten.png';
const result2 = extractKeyFromS3Url(testcase2);
expect(result2).toBe('cats/kitten.png');
});
// Legacy global endpoint
// bucket-name.s3.amazonaws.com
it('should handle formatted according to Legacy global endpoint', () => {
const url = 'https://amzn-s3-demo-bucket1.s3.amazonaws.com/dogs/puppy.png';
const result = extractKeyFromS3Url(url);
expect(result).toBe('dogs/puppy.png');
});
it('should handle malformed URL and log error', () => {
const malformedUrl = 'https://invalid url with spaces.com/key';
const result = extractKeyFromS3Url(malformedUrl);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('[extractKeyFromS3Url] Error parsing URL:'),
);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(malformedUrl));
expect(result).toBe(malformedUrl);
});
it('should return empty string for regional path-style URL with only bucket (no key)', () => {
const url = 'https://s3.us-west-2.amazonaws.com/my-bucket';
const result = extractKeyFromS3Url(url);
expect(result).toBe('');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('[extractKeyFromS3Url] Unable to extract key from path-style URL:'),
);
});
it('should not log error when given a plain S3 key (non-URL input)', () => {
extractKeyFromS3Url('images/user123/file.jpg');
expect(logger.error).not.toHaveBeenCalled();
});
it('should strip bucket from custom endpoint URLs (MinIO, R2, etc.) using bucketName', () => {
// bucketName is the module-level const 'test-bucket', set before require at top of file
expect(
extractKeyFromS3Url('https://minio.example.com/test-bucket/images/user123/file.jpg'),
).toBe('images/user123/file.jpg');
expect(
extractKeyFromS3Url(
'https://abc123.r2.cloudflarestorage.com/test-bucket/images/user123/avatar.png',
),
).toBe('images/user123/avatar.png');
});
it('should use endpoint base path when AWS_ENDPOINT_URL and AWS_FORCE_PATH_STYLE are set', () => {
process.env.AWS_BUCKET_NAME = 'test-bucket';
process.env.AWS_ENDPOINT_URL = 'https://minio.example.com';
process.env.AWS_FORCE_PATH_STYLE = 'true';
jest.resetModules();
const { extractKeyFromS3Url: fn } = require('~/server/services/Files/S3/crud');
expect(fn('https://minio.example.com/test-bucket/images/user123/file.jpg')).toBe(
'images/user123/file.jpg',
);
delete process.env.AWS_ENDPOINT_URL;
delete process.env.AWS_FORCE_PATH_STYLE;
});
it('should handle endpoint with a base path', () => {
process.env.AWS_BUCKET_NAME = 'test-bucket';
process.env.AWS_ENDPOINT_URL = 'https://example.com/storage/';
process.env.AWS_FORCE_PATH_STYLE = 'true';
jest.resetModules();
const { extractKeyFromS3Url: fn } = require('~/server/services/Files/S3/crud');
expect(fn('https://example.com/storage/test-bucket/images/user123/file.jpg')).toBe(
'images/user123/file.jpg',
);
delete process.env.AWS_ENDPOINT_URL;
delete process.env.AWS_FORCE_PATH_STYLE;
});
});
});

View file

@ -200,6 +200,39 @@ describe('getModelMaxTokens', () => {
);
});
test('should return correct tokens for gpt-5.3 matches', () => {
expect(getModelMaxTokens('gpt-5.3')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5.3']);
expect(getModelMaxTokens('gpt-5.3-codex')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5.3']);
expect(getModelMaxTokens('openai/gpt-5.3')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5.3'],
);
expect(getModelMaxTokens('gpt-5.3-2025-03-01')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5.3'],
);
expect(getModelMaxTokens('gpt-5.3-preview')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5.3'],
);
});
test('should return correct tokens for gpt-5.4 matches', () => {
expect(getModelMaxTokens('gpt-5.4')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5.4']);
expect(getModelMaxTokens('gpt-5.4-thinking')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5.4'],
);
expect(getModelMaxTokens('openai/gpt-5.4')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5.4'],
);
});
test('should return correct tokens for gpt-5.4-pro matches', () => {
expect(getModelMaxTokens('gpt-5.4-pro')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5.4-pro'],
);
expect(getModelMaxTokens('openai/gpt-5.4-pro')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5.4-pro'],
);
});
test('should return correct tokens for Anthropic models', () => {
const models = [
'claude-2.1',
@ -237,16 +270,6 @@ describe('getModelMaxTokens', () => {
});
});
// Tests for Google models
test('should return correct tokens for exact match - Google models', () => {
expect(getModelMaxTokens('text-bison-32k', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['text-bison-32k'],
);
expect(getModelMaxTokens('codechat-bison-32k', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['codechat-bison-32k'],
);
});
test('should return undefined for no match - Google models', () => {
expect(getModelMaxTokens('unknown-google-model', EModelEndpoint.google)).toBeUndefined();
});
@ -279,6 +302,12 @@ describe('getModelMaxTokens', () => {
expect(getModelMaxTokens('gemini-3', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-3'],
);
expect(getModelMaxTokens('gemini-3.1-pro-preview', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-3.1'],
);
expect(getModelMaxTokens('gemini-3.1-pro-preview-customtools', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-3.1'],
);
expect(getModelMaxTokens('gemini-2.5-pro', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'],
);
@ -297,12 +326,6 @@ describe('getModelMaxTokens', () => {
expect(getModelMaxTokens('gemini-pro', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini'],
);
expect(getModelMaxTokens('code-', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['code-'],
);
expect(getModelMaxTokens('chat-', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['chat-'],
);
});
test('should return correct tokens for partial match - Cohere models', () => {
@ -486,7 +509,19 @@ describe('getModelMaxTokens', () => {
test('should return correct max output tokens for GPT-5 models', () => {
const { getModelMaxOutputTokens } = require('@librechat/api');
['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro'].forEach((model) => {
const gpt5Models = [
'gpt-5',
'gpt-5.1',
'gpt-5.2',
'gpt-5.3',
'gpt-5.4',
'gpt-5.4-pro',
'gpt-5-mini',
'gpt-5-nano',
'gpt-5-pro',
'gpt-5.2-pro',
];
for (const model of gpt5Models) {
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
maxOutputTokensMap[EModelEndpoint.openAI][model],
@ -494,7 +529,7 @@ describe('getModelMaxTokens', () => {
expect(getModelMaxOutputTokens(model, EModelEndpoint.azureOpenAI)).toBe(
maxOutputTokensMap[EModelEndpoint.azureOpenAI][model],
);
});
}
});
test('should return correct max output tokens for GPT-OSS models', () => {
@ -511,6 +546,184 @@ describe('getModelMaxTokens', () => {
});
});
describe('findMatchingPattern - longest match wins', () => {
test('should prefer longer matching key over shorter cross-provider pattern', () => {
const result = findMatchingPattern(
'gpt-5.2-chat-2025-12-11',
maxTokensMap[EModelEndpoint.openAI],
);
expect(result).toBe('gpt-5.2');
});
test('should match gpt-5.2 tokens for date-suffixed chat variant', () => {
expect(getModelMaxTokens('gpt-5.2-chat-2025-12-11')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5.2'],
);
});
test('should match gpt-5.2-pro over shorter patterns', () => {
expect(getModelMaxTokens('gpt-5.2-pro-chat-2025-12-11')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5.2-pro'],
);
});
test('should match gpt-5-mini over gpt-5 for mini variants', () => {
expect(getModelMaxTokens('gpt-5-mini-chat-2025-01-01')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-mini'],
);
});
test('should prefer gpt-4-1106 over gpt-4 for versioned model names', () => {
const result = findMatchingPattern('gpt-4-1106-preview', maxTokensMap[EModelEndpoint.openAI]);
expect(result).toBe('gpt-4-1106');
});
test('should prefer gpt-4-32k-0613 over gpt-4-32k for exact versioned names', () => {
const result = findMatchingPattern('gpt-4-32k-0613', maxTokensMap[EModelEndpoint.openAI]);
expect(result).toBe('gpt-4-32k-0613');
});
test('should prefer claude-3-5-sonnet over claude-3', () => {
const result = findMatchingPattern(
'claude-3-5-sonnet-20241022',
maxTokensMap[EModelEndpoint.anthropic],
);
expect(result).toBe('claude-3-5-sonnet');
});
test('should prefer gemini-2.0-flash-lite over gemini-2.0-flash', () => {
const result = findMatchingPattern(
'gemini-2.0-flash-lite-preview',
maxTokensMap[EModelEndpoint.google],
);
expect(result).toBe('gemini-2.0-flash-lite');
});
});
describe('findMatchingPattern - bestLength selection', () => {
test('should return the longest matching key when multiple keys match', () => {
const tokensMap = { short: 100, 'short-med': 200, 'short-med-long': 300 };
expect(findMatchingPattern('short-med-long-extra', tokensMap)).toBe('short-med-long');
});
test('should return the longest match regardless of key insertion order', () => {
const tokensMap = { 'a-b-c': 300, a: 100, 'a-b': 200 };
expect(findMatchingPattern('a-b-c-d', tokensMap)).toBe('a-b-c');
});
test('should return null when no key matches', () => {
const tokensMap = { alpha: 100, beta: 200 };
expect(findMatchingPattern('gamma-delta', tokensMap)).toBeNull();
});
test('should return the single matching key when only one matches', () => {
const tokensMap = { alpha: 100, beta: 200, gamma: 300 };
expect(findMatchingPattern('beta-extended', tokensMap)).toBe('beta');
});
test('should match case-insensitively against model name', () => {
const tokensMap = { 'gpt-5': 400000 };
expect(findMatchingPattern('GPT-5-turbo', tokensMap)).toBe('gpt-5');
});
test('should select the longest key among overlapping substring matches', () => {
const tokensMap = { 'gpt-': 100, 'gpt-5': 200, 'gpt-5.2': 300, 'gpt-5.2-pro': 400 };
expect(findMatchingPattern('gpt-5.2-pro-2025-01-01', tokensMap)).toBe('gpt-5.2-pro');
expect(findMatchingPattern('gpt-5.2-chat-2025-01-01', tokensMap)).toBe('gpt-5.2');
expect(findMatchingPattern('gpt-5.1-preview', tokensMap)).toBe('gpt-5');
expect(findMatchingPattern('gpt-unknown', tokensMap)).toBe('gpt-');
});
test('should not be confused by a short key that appears later in the model name', () => {
const tokensMap = { 'model-v2': 200, v2: 100 };
expect(findMatchingPattern('model-v2-extended', tokensMap)).toBe('model-v2');
});
test('should handle exact-length match as the best match', () => {
const tokensMap = { 'exact-model': 500, exact: 100 };
expect(findMatchingPattern('exact-model', tokensMap)).toBe('exact-model');
});
test('should return null for empty model name', () => {
expect(findMatchingPattern('', { 'gpt-5': 400000 })).toBeNull();
});
test('should prefer last-defined key on same-length ties', () => {
const tokensMap = { 'aa-bb': 100, 'cc-dd': 200 };
// model name contains both 5-char keys; last-defined wins in reverse iteration
expect(findMatchingPattern('aa-bb-cc-dd', tokensMap)).toBe('cc-dd');
});
test('longest match beats short cross-provider pattern even when both present', () => {
const tokensMap = { 'gpt-5.2': 400000, 'chat-': 8187 };
expect(findMatchingPattern('gpt-5.2-chat-2025-12-11', tokensMap)).toBe('gpt-5.2');
});
test('should match case-insensitively against keys', () => {
const tokensMap = { 'GPT-5': 400000 };
expect(findMatchingPattern('gpt-5-turbo', tokensMap)).toBe('GPT-5');
});
});
describe('findMatchingPattern - iteration performance', () => {
let includesSpy;
beforeEach(() => {
includesSpy = jest.spyOn(String.prototype, 'includes');
});
afterEach(() => {
includesSpy.mockRestore();
});
test('exact match early-exits with minimal includes() checks', () => {
const openAIMap = maxTokensMap[EModelEndpoint.openAI];
const keys = Object.keys(openAIMap);
const lastKey = keys[keys.length - 1];
includesSpy.mockClear();
const result = findMatchingPattern(lastKey, openAIMap);
const exactCalls = includesSpy.mock.calls.length;
expect(result).toBe(lastKey);
expect(exactCalls).toBe(1);
});
test('bestLength check skips includes() for shorter keys after a long match', () => {
const openAIMap = maxTokensMap[EModelEndpoint.openAI];
includesSpy.mockClear();
findMatchingPattern('gpt-3.5-turbo-0301-test', openAIMap);
const longKeyCalls = includesSpy.mock.calls.length;
includesSpy.mockClear();
findMatchingPattern('gpt-5.3-chat-latest', openAIMap);
const shortKeyCalls = includesSpy.mock.calls.length;
// gpt-3.5-turbo-0301 (20 chars) matches early, then bestLength prunes most keys
// gpt-5.3 (7 chars) is short, so fewer keys are pruned by the length check
expect(longKeyCalls).toBeLessThan(shortKeyCalls);
});
test('last-defined keys are checked first in reverse iteration', () => {
const tokensMap = { first: 100, second: 200, third: 300 };
includesSpy.mockClear();
const result = findMatchingPattern('third', tokensMap);
const calls = includesSpy.mock.calls.length;
// 'third' is last key, found on first reverse check, exact match exits immediately
expect(result).toBe('third');
expect(calls).toBe(1);
});
});
describe('deprecated PaLM2/Codey model removal', () => {
test('deprecated PaLM2/Codey models no longer have token entries', () => {
expect(getModelMaxTokens('text-bison-32k', EModelEndpoint.google)).toBeUndefined();
expect(getModelMaxTokens('codechat-bison-32k', EModelEndpoint.google)).toBeUndefined();
expect(getModelMaxTokens('code-bison', EModelEndpoint.google)).toBeUndefined();
expect(getModelMaxTokens('chat-bison', EModelEndpoint.google)).toBeUndefined();
});
});
describe('matchModelName', () => {
it('should return the exact model name if it exists in maxTokensMap', () => {
expect(matchModelName('gpt-4-32k-0613')).toBe('gpt-4-32k-0613');
@ -606,10 +819,16 @@ describe('matchModelName', () => {
expect(matchModelName('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro');
});
// Tests for Google models
it('should return the exact model name if it exists in maxTokensMap - Google models', () => {
expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k');
expect(matchModelName('codechat-bison-32k', EModelEndpoint.google)).toBe('codechat-bison-32k');
it('should return the closest matching key for gpt-5.3 matches', () => {
expect(matchModelName('openai/gpt-5.3')).toBe('gpt-5.3');
expect(matchModelName('gpt-5.3-codex')).toBe('gpt-5.3');
expect(matchModelName('gpt-5.3-2025-03-01')).toBe('gpt-5.3');
});
it('should return the closest matching key for gpt-5.4 matches', () => {
expect(matchModelName('openai/gpt-5.4')).toBe('gpt-5.4');
expect(matchModelName('gpt-5.4-thinking')).toBe('gpt-5.4');
expect(matchModelName('gpt-5.4-pro')).toBe('gpt-5.4-pro');
});
it('should return the input model name if no match is found - Google models', () => {
@ -617,11 +836,6 @@ describe('matchModelName', () => {
'unknown-google-model',
);
});
it('should return the closest matching key for partial matches - Google models', () => {
expect(matchModelName('code-', EModelEndpoint.google)).toBe('code-');
expect(matchModelName('chat-', EModelEndpoint.google)).toBe('chat-');
});
});
describe('Meta Models Tests', () => {

Some files were not shown because too many files have changed in this diff Show more