Compare commits

...

72 Commits

Author SHA1 Message Date
Danny Avila
f0f81945fb v0.8.1-rc2 (#10688)
*  v0.8.1-rc2

- Updated version numbers in Dockerfile, Dockerfile.multi, package.json, and various package.json files for client, api, and data-provider.
- Adjusted appVersion in Chart.yaml and constants in config.ts to reflect the new version.
- Incremented versions for @librechat/api, @librechat/client, and librechat-data-provider packages.

* chore: Update Chart version to 1.9.3

- Incremented the chart version in Chart.yaml to reflect the latest changes.
2025-11-26 11:40:08 -05:00
Danny Avila
bdc65c5713 🪵 chore: Clean up Debug Logs in OpenID Token Extraction (#10687)
Removed unnecessary debug logging statements in the extractOpenIDTokenInfo function to streamline the code and improve readability. This change enhances the clarity of the function's logic without altering its functionality.
2025-11-26 11:29:10 -05:00
Dustin Healy
07ed2cfed4 🏷️ fix: Editing Bookmark Descriptions (#10685)
* fix: exclude the currently edited bookmark from duplicate checks

* ci: Add comprehensive tests for BookmarkForm component

- Introduced a new test suite for the BookmarkForm component to validate bookmark editing functionality.
- Implemented tests for various scenarios including editing descriptions, renaming tags, and handling duplicate tags.
- Ensured proper feedback through toasts for error cases and successful submissions.
- Mocked necessary hooks and context to isolate component behavior during tests.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-26 11:15:31 -05:00
Zihao Zhou
5b8f0cba04 📄 refactor: Add Provider Fallback for Media Encoding using Client Endpoint (#10656)
When using direct endpoints (e.g., Google) instead of Agents, `this.options.agent` is undefined, causing provider detection to fail. This resulted in "Unknown content type document" errors for Google/Gemini PDF uploads.

Added `?? this.options.endpoint` fallback in addDocuments(), addVideos(), and addAudios() methods to ensure correct provider detection for all endpoint types.
2025-11-25 17:07:37 -05:00
Dustin Healy
8b7af65265 🪄 style: Improved Input Collapse UI (#10659)
* feat: shift collapse chevron inside ChatForm input area

* feat: add soft gradient on bottom of collapsed text input so there isn't a hard cut off when text overflows

* feat: add better scroll bar behavior for main chat input

* fix: smooth out purple gradient for temporary chats

* feat: better colors for gradient

* feat: use blur instead of colors

* chore: address copilot comments
2025-11-25 17:02:52 -05:00
Dustin Healy
30df16f5b5 🍞 feat: Add Toasts for Successful Conversation Deletion (#10661)
* feat: add toasts for succesful conversation deletion

* chore: address copilot comments
2025-11-25 17:02:01 -05:00
Marco Beretta
f5132a65e9 style: Update "Copy Agent" Icon for Clearer Action (#10651) 2025-11-25 15:17:15 -05:00
rossbg
959984f959 ⏱️ fix: Increase RAG API Text Parsing Timeout (#10562)
* fix: increase RAG API text parsing timeout for large files

* ci: Update text.spec.ts

---------

Co-authored-by: Rosen Simov <rosen.simov@endurosat.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-25 14:54:53 -05:00
Daniel Lew
ffcca3254e 📢 fix: Remove Side Panel Elements from Screen Reader when Hidden (#10648)
* fix: remove side panel elements from screen reader when hidden

There's both left & right side panels; elements of both of them
are hidden when dismissed. However, currently they are being hidden
by using classes to hide their UI (such as making the sidebar
zero width).

That works for visually dismissing these elements, but they can still
be viewed by a screen reader (using the tab key to jump between
interactable elements). That can be a rather confusing experience
for anyone visually impaired (such as duplicate buttons, or buttons
that do nothing).

--------

I've changed it so hidden elements are fully removed from the render.
This prevents them from being interactable via keyboard.

I leveraged Motion to duplicate the animations as they happened before.
I subtly cleaned up the animations while I was at it.

* Implemented reasonable suggestions from Copilot review
2025-11-25 13:56:32 -05:00
Danny Avila
9211d59388 🤖 feat: Claude Opus 4.5 Token Rates and Window Limits (#10653)
* 🤖 feat: Claude Opus 4.5 Token Rates and Window Limits

- Introduced new model 'claude-opus-4-5' with defined prompt and completion values in tokenValues and cacheTokenValues.
- Updated tests to validate prompt, completion, and cache rates for the new model.
- Enhanced model name handling to accommodate variations for 'claude-opus-4-5' across different contexts.
- Adjusted schemas to ensure correct max output token limits for the new model.

* ci: Add tests for "prompt-caching" beta header in Claude Opus 4.5 models

- Implemented tests to verify the addition of the "prompt-caching" beta header for the 'claude-opus-4-5' model and its variations.
- Updated future-proofing logic to ensure correct max token limits for Claude 4.x and 5.x Opus models, adjusting defaults to 64K where applicable.
- Enhanced existing tests to reflect changes in expected max token values for future Claude models.

* chore: Remove redundant max output check for Anthropic settings

- Eliminated the unnecessary check for ANTHROPIC_MAX_OUTPUT in the anthropicSettings schema, streamlining the logic for handling max output values.
2025-11-24 16:30:56 -05:00
Danny Avila
e123e5f9ec 🔗 fix: Resolve Bedrock Tool Call Streaming "Content Type Mismatch" (#10647) 2025-11-24 14:18:56 -05:00
Peter
3628619297 🛰️ fix: MCP SSE & Ping Error Handling (#10635)
Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
2025-11-24 13:57:29 -05:00
Danny Avila
35319c1354 🔧 fix: Remove Bedrock Config Transform introduced in #9931 (#10628)
* fix: Header and Environment Variable Handling Bug from #9931

* refactor: Remove warning log for missing tokens in extractOpenIDTokenInfo function

* feat: Enhance resolveNestedObject function for improved placeholder processing

- Added a new function `resolveNestedObject` to recursively process nested objects, replacing placeholders in string values while preserving the original structure.
- Updated `createTestUser` to use `IUser` type and modified user ID generation.
- Added comprehensive unit tests for `resolveNestedObject` to cover various scenarios, including nested structures, arrays, and custom user variables.
- Improved type handling in `processMCPEnv` to ensure correct processing of mixed numeric and placeholder values.

* refactor: Remove unnecessary manipulation of Bedrock options introduced in #9931

- Eliminated the resolveHeaders function call from the getOptions method in options.js, as it was no longer necessary for processing additional model request fields.
- This change simplifies the code and improves maintainability.
2025-11-21 16:42:28 -05:00
github-actions[bot]
03955bd5cf 🌍 i18n: Update translation.json with latest translations (#10622)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-21 16:20:46 -05:00
Danny Avila
3950b9ee53 📦 chore: Update Packages for Security & Remove Unnecessary (#10620)
* 🗑️ chore: Remove @microsoft/eslint-formatter-sarif from dependencies and update ESLint CI workflow

- Removed @microsoft/eslint-formatter-sarif from package.json and package-lock.json.
- Updated ESLint CI workflow to eliminate SARIF upload logic and related environment variables.

* chore: Remove ts-jest from dependencies in jest.config and package files

* chore: Update package dependencies to latest versions

- Upgraded @rollup/plugin-commonjs from 25.0.2 to 29.0.0 across multiple packages.
- Updated rimraf from 5.0.1 to 6.1.2 in packages/api, client, data-provider, and data-schemas.
- Added new dependencies: @isaacs/balanced-match and @isaacs/brace-expansion in package-lock.json.
- Updated glob from 8.1.0 to 13.0.0 and adjusted related dependencies accordingly.

* chore: remove prettier-eslint dependency from package.json

* chore: npm audit fix

* fix: correct `getBasePath` import
2025-11-21 14:53:58 -05:00
Danny Avila
1814c81888 🕸️ fix: Minor Type Issues & Anthropic Web Search (#10618)
* fix: update @librechat/agents dependency to version 3.0.29

* chore: fix typing by replacing TUser with IUser

* chore: import order

* fix: replace TUser with IUser in run and OAuthReconnectionManager modules

* fix: update @librechat/agents dependency to version 3.0.30
2025-11-21 14:25:05 -05:00
WhammyLeaf
846e34b1d7 🗑️ fix: Remove All User Metadata on Deletion (#10534)
* remove all user metadata on deletion

* chore: import order

* fix: Update JSDoc types for deleteMessages function parameters and return value

* fix: Enhance user deletion process by removing associated data and updating group memberships

* fix: Add missing config middleware to user deletion route

* fix: Refactor agent and prompt deletion processes to bulk delete and remove associated ACL entries

* fix: Add deletion of OAuth tokens and ACL entries in user deletion process

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-21 12:03:26 -05:00
catmeme
7aa8d49f3a 🧭 fix: Add Base Path Support for Login/Register and Image Paths (#10116)
* fix: add basePath pattern to support login/register and image paths

* Fix linter errors

* refactor: Update import statements for getBasePath and isEnabled, and add path utility functions with tests

- Refactored imports in addImages.js and StableDiffusion.js to use getBasePath from '@librechat/api'.
- Consolidated isEnabled and getBasePath imports in validateImageRequest.js.
- Introduced new path utility functions in path.ts and corresponding unit tests in path.spec.ts to validate base path extraction logic.

* fix: Update domain server base URL in MarkdownComponents and refactor authentication redirection logic

- Changed the domain server base URL in MarkdownComponents.tsx to use the API base URL.
- Refactored the useAuthRedirect hook to utilize React Router's navigate for redirection instead of window.location, ensuring a smoother SPA experience.
- Added unit tests for the useAuthRedirect hook to verify authentication redirection behavior.

* test: Mock isEnabled in validateImages.spec.js for improved test isolation

- Updated validateImages.spec.js to mock the isEnabled function from @librechat/api, ensuring that tests can run independently of the actual implementation.
- Cleared the DOMAIN_CLIENT environment variable before tests to avoid interference with basePath resolution.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-21 11:25:14 -05:00
Jón Levy
ef3bf0a932 🆔 feat: Add OpenID Connect Federated Provider Token Support (#9931)
* feat: Add OpenID Connect federated provider token support

Implements support for passing federated provider tokens (Cognito, Azure AD, Auth0)
as variables in LibreChat's librechat.yaml configuration for both custom endpoints
and MCP servers.

Features:
- New LIBRECHAT_OPENID_* template variables for federated provider tokens
- JWT claims parsing from ID tokens without verification (for claim extraction)
- Token validation with expiration checking
- Support for multiple token storage locations (federatedTokens, openidTokens)
- Integration with existing template variable system
- Comprehensive test suite with Cognito-specific scenarios
- Provider-agnostic design supporting Cognito, Azure AD, Auth0, etc.

Security:
- Server-side only token processing
- Automatic token expiration validation
- Graceful fallbacks for missing/invalid tokens
- No client-side token exposure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: Add federated token propagation to OIDC authentication strategies

Adds federatedTokens object to user during authentication to enable
federated provider token template variables in LibreChat configuration.

Changes:
- OpenID JWT Strategy: Extract raw JWT from Authorization header and
  attach as federatedTokens.access_token to enable {{LIBRECHAT_OPENID_TOKEN}}
  placeholder resolution
- OpenID Strategy: Attach tokenset tokens as federatedTokens object to
  standardize token access across both authentication strategies

This enables proper token propagation for custom endpoints and MCP
servers that require federated provider tokens for authorization.

Resolves missing token issue reported by @ramden in PR #9931

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Denis Ramic <denis.ramic@nfon.com>
Co-Authored-By: Claude <noreply@anthropic.com>

* test: Add federatedTokens validation tests for OIDC strategies

Adds comprehensive test coverage for the federated token propagation
feature implemented in the authentication strategies.

Tests added:
- Verify federatedTokens object is attached to user with correct structure
  (access_token, refresh_token, expires_at)
- Verify both tokenset and federatedTokens are present in user object
- Ensure tokens from OIDC provider are correctly propagated

Also fixes existing test suite by adding missing mocks:
- isEmailDomainAllowed function mock
- findOpenIDUser function mock

These tests validate the fix from commit 5874ba29f that enables
{{LIBRECHAT_OPENID_TOKEN}} template variable functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* docs: Remove implementation documentation file

The PR description already contains all necessary implementation details.
This documentation file is redundant and was requested to be removed.

* fix: skip s256 check

* fix(openid): handle missing refresh token in Cognito token refresh response

When OPENID_REUSE_TOKENS=true, the token refresh flow was failing because
Cognito (and most OAuth providers) don't return a new refresh token in the
refresh grant response - they only return new access and ID tokens.

Changes:
- Modified setOpenIDAuthTokens() to accept optional existingRefreshToken parameter
- Updated validation to only require access_token (refresh_token now optional)
- Added logic to reuse existing refresh token when not provided in tokenset
- Updated refreshController to pass original refresh token as fallback
- Added comments explaining standard OAuth 2.0 refresh token behavior

This fixes the "Token is not present. User is not authenticated." error that
occurred during silent token refresh with Cognito as the OpenID provider.

Fixes: Authentication loop with OPENID_REUSE_TOKENS=true and AWS Cognito

* fix(openid): extract refresh token from cookies for template variable replacement

When OPENID_REUSE_TOKENS=true, the openIdJwtStrategy populates user.federatedTokens
to enable template variable replacement (e.g., {{LIBRECHAT_OPENID_ACCESS_TOKEN}}).

However, the refresh_token field was incorrectly sourced from payload.refresh_token,
which is always undefined because:
1. JWTs don't contain refresh tokens in their payload
2. The JWT itself IS the access token
3. Refresh tokens are separate opaque tokens stored in HTTP-only cookies

This caused extractOpenIDTokenInfo() to receive incomplete federatedTokens,
resulting in template variables remaining unreplaced in headers.

**Root Cause:**
- Line 90: `refresh_token: payload.refresh_token` (always undefined)
- JWTs only contain access token data in their claims
- Refresh tokens are separate, stored securely in cookies

**Solution:**
- Import `cookie` module to parse cookies from request
- Extract refresh token from `refreshToken` cookie
- Populate federatedTokens with both access token (JWT) and refresh token (from cookie)

**Impact:**
- Template variables like {{LIBRECHAT_OPENID_ACCESS_TOKEN}} now work correctly
- Headers in librechat.yaml are properly replaced with actual tokens
- MCP server authentication with federated tokens now functional

**Technical Details:**
- passReqToCallback=true in JWT strategy provides req object access
- Refresh token extracted via cookies.parse(req.headers.cookie).refreshToken
- Falls back gracefully if cookie header or refreshToken is missing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: re-resolve headers on each request to pick up fresh federatedTokens

- OpenAIClient now re-resolves headers in chatCompletion() before each API call
- This ensures template variables like {{LIBRECHAT_OPENID_TOKEN}} are replaced
  with actual token values from req.user.federatedTokens
- initialize.js now stores original template headers instead of pre-resolved ones
- Fixes template variable replacement when OPENID_REUSE_TOKENS=true

The issue was that headers were only resolved once during client initialization,
before openIdJwtStrategy had populated user.federatedTokens. Now headers are
re-resolved on every request with the current user's fresh tokens.

* debug: add logging to track header resolution in OpenAIClient

* debug: log tokenset structure after refresh to diagnose missing access_token

* fix: set federatedTokens on user object after OAuth refresh

- After successful OAuth token refresh, the user object was not being
  updated with federatedTokens
- This caused template variable resolution to fail on subsequent requests
- Now sets user.federatedTokens with access_token, id_token, refresh_token
  and expires_at from the refreshed tokenset
- Fixes template variables like {{LIBRECHAT_OPENID_TOKEN}} not being
  replaced after token refresh
- Related to PR #9931 (OpenID federated token support)

* fix(openid): pass user object through agent chain for template variable resolution

Root cause: buildAgentContext in agents/run.ts called resolveHeaders without
the user parameter, preventing OpenID federated token template variables from
being resolved in agent runtime parameters.

Changes:
- packages/api/src/agents/run.ts: Add user parameter to createRun signature
- packages/api/src/agents/run.ts: Pass user to resolveHeaders in buildAgentContext
- api/server/controllers/agents/client.js: Pass user when calling createRun
- api/server/services/Endpoints/bedrock/options.js: Add resolveHeaders call with debug logging
- api/server/services/Endpoints/custom/initialize.js: Add debug logging
- packages/api/src/utils/env.ts: Add comprehensive debug logging and stack traces
- packages/api/src/utils/oidc.ts: Fix eslint errors (unused type, explicit any)

This ensures template variables like {{LIBRECHAT_OPENID_TOKEN}} and
{{LIBRECHAT_USER_OPENIDID}} are properly resolved in both custom endpoint
headers and Bedrock AgentCore runtime parameters.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* refactor: remove debug logging from OpenID token template feature

Removed excessive debug logging that was added during development to make
the PR more suitable for upstream review:

- Removed 7 debug statements from OpenAIClient.js
- Removed all console.log statements from packages/api/src/utils/env.ts
- Removed debug logging from bedrock/options.js
- Removed debug logging from custom/initialize.js
- Removed debug statement from AuthController.js

This reduces the changeset by ~50 lines while maintaining full functionality
of the OpenID federated token template variable feature.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* test(openid): add comprehensive unit tests for template variable substitution

- Add 34 unit tests for OIDC token utilities (oidc.spec.ts)
- Test coverage for token extraction, validation, and placeholder processing
- Integration tests for full OpenID token flow
- All tests pass with comprehensive edge case coverage

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>

* test: fix OpenID federated tokens test failures

- Add serverMetadata() mock to openid-client mock configuration
  * Fixes TypeError in openIdJwtStrategy.js where serverMetadata() was being called
  * Mock now returns jwks_uri and end_session_endpoint as expected by the code

- Update outdated initialize.spec.js test
  * Remove test expecting resolveHeaders call during initialization
  * Header resolution was refactored to be deferred until LLM request time
  * Update test to verify options are returned correctly with useLegacyContent flag

Fixes #9931 CI failures for backend unit tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* chore: fix package-lock.json conflict

* chore: sync package-log with upstream

* chore: cleanup

* fix: use createSafeUser

* fix: fix createSafeUser signature

* chore: remove comments

* chore: purge comments

* fix: update Jest testPathPattern to testPathPatterns for Jest 30+ compatibility

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Denis Ramic <denis.ramic@nfon.com>
Co-authored-by: kristjanaapro <kristjana@apro.is>

chore: import order and add back JSDoc for OpenID JWT callback
2025-11-21 09:51:11 -05:00
michnovka
040d083088 feat: Prevent Screen Sleep During Response Generation (#10597)
* feat: prevent screen sleep during response generation

* refactor: screen wake lock functionality during response generation

* chore: import order

* chore: reorder import statements in WakeLockManager component

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-21 09:14:32 -05:00
NalinNair
5ac9ac57cc 📑 refactor: Skip H1 Rendering for Falsy Header Values in AuthLayout (#10606) 2025-11-20 16:57:36 -05:00
Danny Avila
b49545d916 🪂 refactor: MCP Server Init Fallback (#10608)
* 🌿 refactor: MCP Server Init and Registry with Fallback Configs

* chore: Redis Cache Flushing for Cluster Support
2025-11-20 16:47:00 -05:00
Theo N. Truong
1e4c255351 🔒 fix: Disable Redis leader-only mode for shared app and user servers (#10605)
Resolving: https://github.com/danny-avila/LibreChat/discussions/10598
2025-11-20 14:00:43 -05:00
Dustin Healy
dfcaff9b00 📷 fix: Use 'media' type for Google multimodal attachments (#10586)
* fix: change google multimodal attachments to use type: 'media'

* chore: Update @librechat/agents to version 3.0.27 in package.json and package-lock.json

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-19 18:31:05 -05:00
Danny Avila
ba974604b1 🔒 feat: Implement Cross-Platform IP Validation Functionality
* Added a new `isIP` function for validating IP addresses in both Node.js and browser environments, replacing the previous reliance on the Node.js `net` module.
* Updated domain extraction and validation logic to utilize the new `isIP` function, ensuring consistent IP validation across the application.
* Enhanced handling of IPv4 and IPv6 addresses, including proper formatting for URLs.
2025-11-19 17:48:29 -05:00
Danny Avila
086e9a92dc 🔒 feat: Enhance Actions SSRF Protection with Comprehensive IP and Domain Validation (#10583)
* 🔒 feat: Enhance SSRF Protection with Comprehensive IP and Domain Validation

* Added extensive tests for validating IP addresses and domains to prevent SSRF attacks, including checks for internal, private, and link-local addresses.
* Improved domain validation logic to handle various edge cases, ensuring only legitimate requests are processed.
* Implemented security measures against common cloud provider metadata access and internal service exploitation.
* Updated existing tests to reflect changes in validation logic and ensure robust security coverage.

* chore: cleanup comments

* 🔒 feat: Improve Domain Validation Logic for Enhanced Security

* Added logic to extract and normalize hostnames from client-provided domains, including handling of URLs and IP addresses.
* Implemented checks using Node.js's net module to validate IP addresses, ensuring robust domain validation.
* Updated existing validation conditions to enhance security against potential SSRF attacks.

* feat: Additional Protocol Checks and IPv6 Support

* Added tests to reject unsupported protocols (FTP, WebSocket, file) in client domains to strengthen SSRF protection.
* Improved domain extraction logic to preserve brackets for IPv6 addresses, ensuring correct URL formatting.
* Updated validation logic to handle various edge cases for client-provided domains, enhancing overall security.

* feat: Expand Domain Validation Tests for Enhanced SSRF Protection

* Added comprehensive tests for handling various URL formats, including IPv6 addresses, authentication credentials, and special characters in paths.
* Implemented additional validation scenarios for client domains, covering edge cases such as malformed URLs, empty strings, and unsupported protocols.
* Enhanced handling of internationalized domain names and localhost variations to ensure robust domain extraction and validation.
2025-11-19 17:42:17 -05:00
Danny Avila
9f2fc25bde 🔬 refactor: Prevent Automatic MCP Server UI Deselection (#10588)
* chore: Add experimental backend server for multi-pod simulation

* Introduced a new backend script (`experimental.js`) to manage a clustered server environment with Redis cache flushing on startup.
* Updated `package.json` to include a new script command for the experimental backend.
* This setup aims to enhance scalability and performance for production environments.

* refactor: Remove server disconnection handling logic from useMCPServerManager
2025-11-19 17:10:25 -05:00
Daniel Lew
014eb10662 📢 fix: Resolved Screen Reader Issues with TooltipAnchor (#10580)
TooltipAnchor was automatically adding an `aria-describedby`
tag which often duplicated the labeling already present inside
of the anchor. E.g., the screen reader might say
"New Chat, New Chat, button" instead of just "New Chat, button."

I've removed the TooltipAnchor's automatic `aria-describedby` and
worked to make sure that anyone using TooltipAnchor properly defines
its labeling.
2025-11-19 17:10:10 -05:00
Danny Avila
8b9afd5965 🤖 feat: Gemini 3 Support (#10584)
* feat: Add support for  model in token configurations and tests

* chore: Update @librechat/agents to version 3.0.26 in package.json and package-lock.json
2025-11-19 15:05:37 -05:00
Danny Avila
4c2719a37e 🛡️ chore: Enhance Agents Error Handling via @librechat/agents@v3.0.25 (#10577)
* 🔧 fix: Enhance error handling for agents system in uncaughtException logger

* Added specific logging for errors originating from the agents system to improve debugging and maintain application stability.

* 📦 chore: Update dependencies for `@librechat/agents` and related packages to v3.0.25 and improve version consistency across modules
2025-11-19 09:20:44 -05:00
Linus Gasser
e1fdd5b7e8 🚩 feat: Add --provider flag to create-user script (#10572)
As we're using google authentication without automatic sign-up, we need
a way to pass the provider to the user creation.
2025-11-19 09:05:00 -05:00
Anthony Quéré
69c6d023e1 📨 feat: Pass Custom Headers to Model Discovery (v1/models) (#10564) 2025-11-19 08:49:51 -05:00
Danny Avila
ce1812b7c2 🐛 fix: Error Handling in MCP Tool List Controller (#10570)
* 🔧 fix: Handle errors when fetching server tools and log missing tools in MCP tools controller, to prevent all MCP tools from not getting listed

* 🔧 fix: Remove trailing colons from error messages in MCPConnection class

* chore: Update test command patterns in package.json for cache integration tests
2025-11-18 18:28:57 -05:00
Danny Avila
4a13867a47 📦 chore: Bump @librechat/agents to v3.0.22 2025-11-18 13:09:41 -05:00
Danny Avila
8f887f480d 🔧 fix: Catch Errors in ToolEndHandler and Pass Logger (#10565) 2025-11-18 13:00:33 -05:00
Joseph Licata
3dd827e9d2 🔧 refactor: Update Avatar component to improve file selection handling (#10555)
* Refactored `openFileDialog` to use `useCallback` for better performance.
* Introduced `handleSelectFileClick` to manage file selection click events, enhancing user interaction.
2025-11-17 17:11:48 -05:00
Marco Beretta
8907bd5d7c 👤 feat: Agent Avatar Removal and Decouple upload/reset from Agent Updates (#10527)
*  feat: Enhance agent avatar management with upload and reset functionality

*  feat: Refactor AvatarMenu to use DropdownPopup for improved UI and functionality

*  feat: Improve avatar upload handling in AgentPanel to suppress misleading "no changes" toast

*  feat: Refactor toast message handling and payload composition in AgentPanel for improved clarity and functionality

*  feat: Enhance agent avatar functionality with upload, reset, and validation improvements

*  feat: Refactor agent avatar upload handling and enhance related components for improved functionality and user experience

* feat(agents): tighten ACL, harden GETs/search, and sanitize action metadata
stop persisting refreshed S3 URLs on GET; compute per-response only
enforce ACL EDIT on revert route; remove legacy admin/author/collab checks
sanitize action metadata before persisting during duplication (api_key, oauth_client_id, oauth_client_secret)
escape user search input, cap length (100), and use Set for public flag mapping
add explicit req.file guard in avatar upload; fix empty catch lint; remove unused imports

* feat: Remove outdated avatar-related translation keys

* feat: Improve error logging for avatar updates and streamline file input handling

* feat(agents): implement caching for S3 avatar refresh in agent list responses

* fix: replace unconventional 'void e' with explicit comment to clarify intentionally ignored error

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(agents): enhance avatar handling and improve search functionality

* fix: clarify intentionally ignored error in agent list handler

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-17 17:04:01 -05:00
Danny Avila
c0cb48256e 🤖 refactor: Improve Agent Handoff Context Tracking (#10553)
* chore: update @librechat/agents dependency to version 3.0.18

* refactor: add optional metadata field to message schema and types

* chore: update @librechat/agents to v3.0.19

* refactor: update return type of sendCompletion method to include metadata

* chore: linting

* chore: update @librechat/agents dependency to v3.0.20

* refactor: implement agent labeling for conversation history in multi-agent scenarios

* refactor: improve error handling for capturing agent ID map in AgentClient

* refactor: clear agentIdMap and related properties during client disposal to prevent memory leaks

* chore: update sendCompletion method for FakeClient to return an object with completion and metadata fields
2025-11-17 16:57:51 -05:00
Danny Avila
bdc47dbe47 fix: Async Model End Events, Await Tool Call and Dispatch Handling (#10552) 2025-11-17 16:37:40 -05:00
Danny Avila
49c57b27fd fix: createFileSearchTool to return tuples for error messages (#10547) 2025-11-17 13:12:16 -05:00
Danny Avila
1b2f1ff09b 🚪 fix: ArtifactsPanel and SidePanel Rendering and Collapsing Behavior (#10537)
* 🚪 fix: ArtifactsPanel and SidePanel Rendering and Collapsing Behavior

* refactor: improve side panel behavior when artifacts panel renders null
2025-11-16 13:55:35 -05:00
Adaptive Garage
0a2f40cc50 🪣 feat: Init Containers and Custom ConfigMaps Support in Helm Chart (#10525) 2025-11-16 12:03:34 -05:00
Theo N. Truong
8c531b921e 🐛 fix: Redis Cluster Bug + 🧪 Enhance Test Coverage (#10518)
*  feat: Implement scanIterator method for Redis cluster client
This resolves the bug where `ServerConfigsCacheRedis#getAll` returns an empty object when a Redis Cluster (instead of a single node server is used)

*  feat: Update cache integration tests for Redis cluster support
2025-11-16 11:58:52 -05:00
Danny Avila
f228f2a91d 📦 chore: Jest & Eslint Package Updates (#10536)
* chore: update js-yaml to v4.1.1

* chore: update eslint to v9.39.1 in package.json and package-lock.json

* chore: update prettier-eslint to v16.4.2 in package.json and package-lock.json

* chore: update @eslint/eslintrc to v3.3.1 in package.json and package-lock.json

* chore: update ts-jest to v29.4.5 in package.json and package-lock.json

* chore: update jest to version 30.2.0 across multiple packages and update related dependencies
2025-11-16 11:55:18 -05:00
github-actions[bot]
59b57623f7 🌍 i18n: Update translation.json with latest translations (#10519)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-16 11:46:16 -05:00
Danny Avila
61c4736125 📜 chore: Update deployed-update.js to use 'docker compose' syntax 2025-11-14 13:53:22 -05:00
Danny Avila
d844754edf 📼 fix: Remove Legacy File Upload for Non-agents (#10517) 2025-11-14 13:17:17 -05:00
Danny Avila
6522789f5b 🤖 feat: GPT-5.1 (#10491) 2025-11-14 12:28:20 -05:00
Marco Beretta
e71c48ec3d 🎨 fix: Correct Read-Only State Logic in Code Editor (#10508)
*  style: Update ThinkingButton container background color for improved visibility

*  style: Refactor Clipboard icon rendering for improved readability

*  style: Simplify readOnly state initialization and update logic in ArtifactCodeEditor

*  style: Update Thinking component background color for improved aesthetics

* Update client/src/components/Chat/Messages/MinimalHoverButtons.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 12:27:51 -05:00
Maxim
f6868fc851 🔤 fix: Replace Translation Keys with Localized Text (#10486)
Co-authored-by: Max Dutkin <dutkinm@corning.com>
2025-11-13 17:01:24 -05:00
Marco Beretta
c2505d2bc9 🤝 feat: View Artifacts in Shared Conversations (#10477)
* feat: Integrate logger for MessageIcon component

* feat: Enhance artifact sharing functionality with updated path checks and read-only state management

* feat: Refactor Thinking and Reasoning components for improved structure and styling

* feat: Enhance artifact sharing with context value management and responsive layout

* feat: Enhance ShareView with theme and language management features

* feat: Improve ThinkingButton accessibility and styling for better user interaction

* feat: Introduce isArtifactRoute utility for route validation in Artifact components

* feat: Add latest message text extraction in SharedView for improved message display

* feat: Update locale handling in SharedView for dynamic date formatting

* feat: Refactor ArtifactsContext and SharedView for improved context handling and styling adjustments

* feat: Enhance artifact panel size management with local storage integration

* chore: imports

* refactor: move ShareArtifactsContainer out of ShareView

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-13 16:59:46 -05:00
Danny Avila
cabc8afeac 🔧 fix: Await MCP Instructions and Filter Malformed Tool Calls (#10485)
* fix: Await MCP instructions formatting in AgentClient

* fix: don't render or aggregate malformed tool calls

* fix: implement filter for malformed tool call content parts and add tests
2025-11-13 14:17:47 -05:00
github-actions[bot]
aff3cd3667 🌍 i18n: Update translation.json with latest translations (#10481)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-13 10:22:19 -05:00
Dustin Healy
c9ee0f138a 🪨 feat: Add Bedrock Prompt Caching Support (#8271)
* feat: Add Bedrock Cache Control Functionality

- fix: Update Bedrock Cache Control to Require cachePoint as a Separate Content Block

- Modified the addBedrockCacheControl function to ensure cachePoint is added as a separate content block in the content array, rather than as a property of text objects.

- refactor: move addBedrockCacheControl over to packages/api

- ci: add tests for addBedrockCacheControl until full coverage reached

* ci: add test similar to example from the langchain PR

* refactor: move addBedrockCacheControl logic and tests to agents repository

* chore: remove extraneous comment

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

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

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

* chore: update @librechat/agents to v3.0.15

* chore: update default value for prompt cache setting to true

* refactor: set default promptCache to true for claude and nova models

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-13 10:21:50 -05:00
_juliettech
bc561840bb 🌉 feat: Integrate Helicone AI Gateway Provider (#10287)
* feat: integrate Helicone AI gateway provider

- Add Helicone provider support with automatic model fetching
- Implement custom API logic for Helicone model registry endpoint
- Enable access to 75+ models from multiple AI providers through Helicone gateway
- Add Helicone to supported providers list in README
- Include Helicone configuration in example YAML

* docs: add Helicone to supported providers list

* fix comments

* fixed backgroundless helicone icon asset

* removed unecessesary changes

* replace svg helicone image instead of png
2025-11-13 08:45:32 -05:00
Danny Avila
6e19026c48 🔍 feat: DEBUG_MESSAGE_LENGTH Environment Variable (pt. 2) (#10479) 2025-11-13 08:38:38 -05:00
Danny Avila
524fc5bae4 🛡️ feat: Add Model Refusal Error Handling (Anthropic) (#10478)
* feat: Add error handling for model refusal and update translations

* refactor: error handling in AgentClient to improve logging and cleanup process

* refactor: Update error message for response refusal to improve clarity
2025-11-13 08:34:55 -05:00
Danny Avila
3f62ce054f 🔢 fix: Unescape LaTeX Numbers in Artifact Content Edit (#10476) 2025-11-13 08:19:19 -05:00
Danny Avila
b8b1217c34 feat: Artifact Management Enhancements, Version Control, and UI Refinements (#10318)
*  feat: Enhance Artifact Management with Version Control and UI Improvements

 feat: Improve mobile layout and responsiveness in Artifacts component

 feat: Refactor imports and remove unnecessary props in Artifact components

 feat: Enhance Artifacts and SidePanel components with improved mobile responsiveness and layout transitions

feat: Enhance artifact panel animations and improve UI responsiveness

- Updated Thinking component button styles for smoother transitions.
- Implemented dynamic rendering for artifacts panel with animation effects.
- Refactored localization keys for consistency across multiple languages.
- Added new CSS animations for iOS-inspired smooth transitions.
- Improved Tailwind CSS configuration to support enhanced animation effects.

 feat: Add fullWidth and icon support to Radio component for enhanced flexibility

refactor: Remove unused PreviewProps import in ArtifactPreview component

refactor: Improve button class handling and blur effect constants in Artifact components

 feat: Refactor Artifacts component structure and add mobile/desktop variants for improved UI

chore: Bump @librechat/client version to 0.3.2

refactor: Update button styles and transition durations for improved UI responsiveness

refactor: revert back localization key

refactor: remove unused scaling and animation properties for cleaner CSS

refactor: remove unused animation properties for cleaner configuration

*  refactor: Simplify className usage in ArtifactTabs, ArtifactsHeader, and SidePanelGroup components

* refactor: Remove cycleArtifact function from useArtifacts hook

*  feat: Implement Chromium resize lag fix with performance optimizations and new ArtifactsPanel component

*  feat: Update Badge component for responsive design and improve tap scaling behavior

* chore: Update react-resizable-panels dependency to version 3.0.6

*  feat: Refactor Artifacts components for improved structure and performance; remove unused files and optimize styles

*  style: Update text color for improved visibility in Artifacts component

*  style: Remove text color class for improved Spinner styling in Artifacts component

* refactor: Split EditorContext into MutationContext and CodeContext to optimize re-renders; update related components to use new hooks

* refactor: Optimize debounced mutation handling in CodeEditor component using refs to maintain current values and reduce re-renders

* fix: Correct endpoint for message artifacts by changing URL segment from 'artifacts' to 'artifact'

* feat: Enhance useEditArtifact mutation with optimistic updates and rollback on error; improve type safety with context management

* fix: proper switch to preview as soon as artifact becomes enclosed

* refactor: Remove optimistic updates from useEditArtifact mutation to prevent errors; simplify onMutate logic

* test: Add comprehensive unit tests for useArtifacts hook to validate artifact handling, tab switching, and state management

* test: Enhance unit tests for useArtifacts hook to cover new conversation transitions and null message handling

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
2025-11-12 13:32:47 -05:00
Danny Avila
4186db3ce2 📦 chore: Bump @modelcontextprotocol/sdk to v1.21.0 (#10469) 2025-11-12 09:10:21 -05:00
github-actions[bot]
7670cd9ee5 🌍 i18n: Update translation.json with latest translations (#10458)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-12 08:47:15 -05:00
Danny Avila
dd35f42073 🔒 feat: Idempotency Check for OAuth Flow Completion (#10468)
* 🔒 feat: Implement idempotency check for OAuth flow completion

- Added a check to prevent duplicate token exchanges if the OAuth flow has already been completed.
- Updated the OAuth callback route to redirect appropriately when a completed flow is detected.
- Refactored token storage logic to use original flow state credentials instead of updated ones.
- Enhanced tests to cover the new idempotency behavior and ensure correct handling of OAuth flow states.

* chore: add back scope for logging

* refactor: Add isFlowStale method to FlowStateManager for stale flow detection

- Implemented a new method to check if a flow is stale based on its age and status.
- Updated MCPConnectionFactory to utilize the isFlowStale method for cleaning up stale OAuth flows.
- Enhanced logging to provide more informative messages regarding flow status and age during cleanup.

* test: Add unit tests for isFlowStale method in FlowStateManager

- Implemented comprehensive tests for the isFlowStale method to verify its behavior across various flow statuses (PENDING, COMPLETED, FAILED) and age thresholds.
- Ensured correct handling of edge cases, including flows with missing timestamps and custom stale thresholds.
- Enhanced test coverage to validate the logic for determining flow staleness based on createdAt, completedAt, and failedAt timestamps.
2025-11-12 08:44:45 -05:00
Danny Avila
a49c509ebc 📐 chore: Update extractDefaultParams to return undefined for invalid input 2025-11-11 15:36:07 -05:00
Danny Avila
970a7510bb 🛝 feat: Default Params via Custom Params (#10457) 2025-11-11 15:31:52 -05:00
Danny Avila
2b0fe036a8 🔍 feat: Anthropic/Google Web Search Support via addParams / dropParams (#10456)
* feat: add support for known/add/drop parameters in Anthropic and Google LLM configurations

* ci: add tests for web search support for Anthropic and Google configurations with addParams and dropParams handling
2025-11-11 14:39:12 -05:00
github-actions[bot]
4685a063f5 🌍 i18n: Update translation.json with latest translations (#10448)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-11 14:15:41 -05:00
Danny Avila
b6ba2711f9 Merge commit from fork
- Implemented validation for OpenAPI specifications to ensure the server URL matches the client-provided domain, preventing SSRF attacks.
- Added domain extraction and validation functions to improve security checks.
- Updated relevant services and routes to utilize the new validation logic, ensuring robust handling of client-provided domains against the OpenAPI spec.
- Introduced comprehensive tests to validate the new security features and ensure correct behavior across various scenarios.
2025-11-11 14:14:55 -05:00
Danny Avila
4e4c8d0c0e 📜 feat: Configurable Debug Message Length for Logs (#10447)
- Added DEBUG_MESSAGE_LENGTH constant to allow dynamic adjustment of debug message length based on environment variable.
- Updated logging format to utilize the new constant for truncating debug messages, enhancing flexibility in log output.
2025-11-10 21:40:37 -05:00
Danny Avila
937563f645 🖼️ feat: File Size and MIME Type Filtering at Agent level (#10446)
* refactor: add image file size validation as part of payload build

* feat: implement file size and MIME type filtering in endpoint configuration

* chore: import order
2025-11-10 21:36:48 -05:00
Sean McGrath
b443254151 🔐 fix: persist new MCP oauth tokens properly (#10439)
* fix: re-fetch OAuth flow state after completeOAuthFlow

* test: add tests for MCP OAuth flow state bugs
2025-11-10 19:51:20 -05:00
Danny Avila
2524d33362 📂 refactor: Cleanup File Filtering Logic, Improve Validation (#10414)
* feat: add filterFilesByEndpointConfig to filter disabled file processing by provider

* chore: explicit define of endpointFileConfig for better debugging

* refactor: move `normalizeEndpointName` to data-provider as used app-wide

* chore: remove overrideEndpoint from useFileHandling

* refactor: improve endpoint file config selection

* refactor: update filterFilesByEndpointConfig to accept structured parameters and improve endpoint file config handling

* refactor: replace defaultFileConfig with getEndpointFileConfig for improved file configuration handling across components

* test: add comprehensive unit tests for getEndpointFileConfig to validate endpoint configuration handling

* refactor: streamline agent endpoint assignment and improve file filtering logic

* feat: add error handling for disabled file uploads in endpoint configuration

* refactor: update encodeAndFormat functions to accept structured parameters for provider and endpoint

* refactor: streamline requestFiles handling in initializeAgent function

* fix: getEndpointFileConfig partial config merging scenarios

* refactor: enhance mergeWithDefault function to support document-supported providers with comprehensive MIME types

* refactor: user-configured default file config in getEndpointFileConfig

* fix: prevent file handling when endpoint is disabled and file is dragged to chat

* refactor: move `getEndpointField` to `data-provider` and update usage across components and hooks

* fix: prioritize endpointType based on agent.endpoint in file filtering logic

* fix: prioritize agent.endpoint in file filtering logic and remove unnecessary endpointType defaulting
2025-11-10 19:05:30 -05:00
Danny Avila
06c060b983 🧰 fix: Unprocessed Tool Calls Edge Case (#10440)
* chore: temp. remove @librechat/agents

* 🔧 chore: update @langchain/core to version 0.3.79

* chore: update dependencies for @langchain/core and add back latest @librechat/agents

* chore: update @librechat/agents to version 3.0.11

* fix: enhance error handling for uncaught exceptions due to abort errors

* fix: standardize warning message for uncatchable abort errors

* fix: improve tool call handling in ModelEndHandler for unprocessed edge case

* fix: prevent content type mismatch in message updates and preserve args in final updates

* chore: add debug logging for client disposal in disposeClient function
2025-11-10 17:12:06 -05:00
290 changed files with 18632 additions and 4797 deletions

View File

@@ -785,3 +785,7 @@ OPENWEATHER_API_KEY=
# Cache connection status checks for this many milliseconds to avoid expensive verification
# MCP_CONNECTION_CHECK_TTL=60000
# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
# MCP_SKIP_CODE_CHALLENGE_CHECK=false

View File

@@ -61,30 +61,23 @@ jobs:
npm run build:data-schemas
npm run build:api
- name: Run cache integration tests
- name: Run all cache integration tests (Single Redis Node)
working-directory: packages/api
env:
NODE_ENV: test
USE_REDIS: true
USE_REDIS_CLUSTER: false
REDIS_URI: redis://127.0.0.1:6379
REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
run: npm run test:cache-integration:core
run: npm run test:cache-integration
- name: Run cluster integration tests
- name: Run all cache integration tests (Redis Cluster)
working-directory: packages/api
env:
NODE_ENV: test
USE_REDIS: true
REDIS_URI: redis://127.0.0.1:6379
run: npm run test:cache-integration:cluster
- name: Run mcp integration tests
working-directory: packages/api
env:
NODE_ENV: test
USE_REDIS: true
REDIS_URI: redis://127.0.0.1:6379
run: npm run test:cache-integration:mcp
USE_REDIS_CLUSTER: true
REDIS_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
run: npm run test:cache-integration
- name: Stop Redis Cluster
if: always()

View File

@@ -35,8 +35,6 @@ jobs:
# Run ESLint on changed files within the api/ and client/ directories.
- name: Run ESLint on changed files
env:
SARIF_ESLINT_IGNORE_SUPPRESSED: "true"
run: |
# Extract the base commit SHA from the pull_request event payload.
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
@@ -52,22 +50,10 @@ jobs:
# Ensure there are files to lint before running ESLint
if [[ -z "$CHANGED_FILES" ]]; then
echo "No matching files changed. Skipping ESLint."
echo "UPLOAD_SARIF=false" >> $GITHUB_ENV
exit 0
fi
# Set variable to allow SARIF upload
echo "UPLOAD_SARIF=true" >> $GITHUB_ENV
# Run ESLint
npx eslint --no-error-on-unmatched-pattern \
--config eslint.config.mjs \
--format @microsoft/eslint-formatter-sarif \
--output-file eslint-results.sarif $CHANGED_FILES || true
- name: Upload analysis results to GitHub
if: env.UPLOAD_SARIF == 'true'
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: eslint-results.sarif
wait-for-processing: true
$CHANGED_FILES

31
.gitignore vendored
View File

@@ -138,3 +138,34 @@ helm/**/.values.yaml
/.tabnine/
/.codeium
*.local.md
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt
# Claude Flow generated files
.claude/settings.local.json
.mcp.json
claude-flow.config.json
.swarm/
.hive-mind/
.claude-flow/
memory/
coordination/
memory/claude-flow-data.json
memory/sessions/*
!memory/sessions/README.md
memory/agents/*
!memory/agents/README.md
coordination/memory_bank/*
coordination/subtasks/*
coordination/orchestration/*
*.db
*.db-journal
*.db-wal
*.sqlite
*.sqlite-journal
*.sqlite-wal
claude-flow
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt

View File

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

View File

@@ -1,5 +1,5 @@
# Dockerfile.multi
# v0.8.1-rc1
# v0.8.1-rc2
# Base for all builds
FROM node:20-alpine AS base-min

View File

@@ -56,7 +56,7 @@
- [Custom Endpoints](https://www.librechat.ai/docs/quick_start/custom_endpoints): Use any OpenAI-compatible API with LibreChat, no proxy required
- Compatible with [Local & Remote AI Providers](https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints):
- Ollama, groq, Cohere, Mistral AI, Apple MLX, koboldcpp, together.ai,
- OpenRouter, Perplexity, ShuttleAI, Deepseek, Qwen, and more
- OpenRouter, Helicone, Perplexity, ShuttleAI, Deepseek, Qwen, and more
- 🔧 **[Code Interpreter API](https://www.librechat.ai/docs/features/code_interpreter)**:
- Secure, Sandboxed Execution in Python, Node.js (JS/TS), Go, C/C++, Java, PHP, Rust, and Fortran

View File

@@ -305,11 +305,9 @@ class AnthropicClient extends BaseClient {
}
async addImageURLs(message, attachments) {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
EModelEndpoint.anthropic,
);
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
endpoint: EModelEndpoint.anthropic,
});
message.image_urls = image_urls.length ? image_urls : undefined;
return files;
}

View File

@@ -81,6 +81,7 @@ class BaseClient {
throw new Error("Method 'getCompletion' must be implemented.");
}
/** @type {sendCompletion} */
async sendCompletion() {
throw new Error("Method 'sendCompletion' must be implemented.");
}
@@ -689,8 +690,7 @@ class BaseClient {
});
}
/** @type {string|string[]|undefined} */
const completion = await this.sendCompletion(payload, opts);
const { completion, metadata } = await this.sendCompletion(payload, opts);
if (this.abortController) {
this.abortController.requestCompleted = true;
}
@@ -708,6 +708,7 @@ class BaseClient {
iconURL: this.options.iconURL,
endpoint: this.options.endpoint,
...(this.metadata ?? {}),
metadata,
};
if (typeof completion === 'string') {
@@ -1212,7 +1213,8 @@ class BaseClient {
this.options.req,
attachments,
{
provider: this.options.agent?.provider,
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
},
getStrategyFunctions,
@@ -1228,7 +1230,10 @@ class BaseClient {
const videoResult = await encodeAndFormatVideos(
this.options.req,
attachments,
this.options.agent.provider,
{
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
},
getStrategyFunctions,
);
message.videos =
@@ -1240,7 +1245,10 @@ class BaseClient {
const audioResult = await encodeAndFormatAudios(
this.options.req,
attachments,
this.options.agent.provider,
{
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
},
getStrategyFunctions,
);
message.audios =

View File

@@ -305,7 +305,9 @@ class GoogleClient extends BaseClient {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
EModelEndpoint.google,
{
endpoint: EModelEndpoint.google,
},
mode,
);
message.image_urls = image_urls.length ? image_urls : undefined;

View File

@@ -354,11 +354,9 @@ class OpenAIClient extends BaseClient {
* @returns {Promise<MongoFile[]>}
*/
async addImageURLs(message, attachments) {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
this.options.endpoint,
);
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
endpoint: this.options.endpoint,
});
message.image_urls = image_urls.length ? image_urls : undefined;
return files;
}

View File

@@ -1,3 +1,4 @@
const { getBasePath } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
/**
@@ -32,6 +33,8 @@ function addImages(intermediateSteps, responseMessage) {
return;
}
const basePath = getBasePath();
// Correct any erroneous URLs in the responseMessage.text first
intermediateSteps.forEach((step) => {
const { observation } = step;
@@ -44,12 +47,14 @@ function addImages(intermediateSteps, responseMessage) {
return;
}
const essentialImagePath = match[0];
const fullImagePath = `${basePath}${essentialImagePath}`;
const regex = /!\[.*?\]\((.*?)\)/g;
let matchErroneous;
while ((matchErroneous = regex.exec(responseMessage.text)) !== null) {
if (matchErroneous[1] && !matchErroneous[1].startsWith('/images/')) {
responseMessage.text = responseMessage.text.replace(matchErroneous[1], essentialImagePath);
if (matchErroneous[1] && !matchErroneous[1].startsWith(`${basePath}/images/`)) {
// Replace with the full path including base path
responseMessage.text = responseMessage.text.replace(matchErroneous[1], fullImagePath);
}
}
});
@@ -61,9 +66,23 @@ function addImages(intermediateSteps, responseMessage) {
return;
}
const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g);
if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) {
responseMessage.text += '\n' + observedImagePath[0];
logger.debug('[addImages] added image from intermediateSteps:', observedImagePath[0]);
if (observedImagePath) {
// Fix the image path to include base path if it doesn't already
let imageMarkdown = observedImagePath[0];
const urlMatch = imageMarkdown.match(/\(([^)]+)\)/);
if (
urlMatch &&
urlMatch[1] &&
!urlMatch[1].startsWith(`${basePath}/images/`) &&
urlMatch[1].startsWith('/images/')
) {
imageMarkdown = imageMarkdown.replace(urlMatch[1], `${basePath}${urlMatch[1]}`);
}
if (!responseMessage.text.includes(imageMarkdown)) {
responseMessage.text += '\n' + imageMarkdown;
logger.debug('[addImages] added image from intermediateSteps:', imageMarkdown);
}
}
});
}

View File

@@ -74,7 +74,7 @@ describe('addImages', () => {
it('should append correctly from a real scenario', () => {
responseMessage.text =
'Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there\'s a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?';
"Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there's a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?";
const originalText = responseMessage.text;
const imageMarkdown = '![generated image](/images/img-RnVWaYo2Yg4x3e0isICiMuf5.png)';
intermediateSteps.push({ observation: imageMarkdown });
@@ -139,4 +139,108 @@ describe('addImages', () => {
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/images/image1.png)');
});
describe('basePath functionality', () => {
let originalDomainClient;
beforeEach(() => {
originalDomainClient = process.env.DOMAIN_CLIENT;
});
afterEach(() => {
process.env.DOMAIN_CLIENT = originalDomainClient;
});
it('should prepend base path to image URLs when DOMAIN_CLIENT is set', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/librechat/images/test.png)');
});
it('should not prepend base path when image URL already has base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](/librechat/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/librechat/images/test.png)');
});
it('should correct erroneous URLs with base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
responseMessage.text = '![desc](sandbox:/images/test.png)';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('![desc](/librechat/images/test.png)');
});
it('should handle empty base path (root deployment)', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/images/test.png)');
});
it('should handle missing DOMAIN_CLIENT', () => {
delete process.env.DOMAIN_CLIENT;
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/images/test.png)');
});
it('should handle observation without image path match', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](not-an-image-path)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](not-an-image-path)');
});
it('should handle nested subdirectories in base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/apps/librechat/images/test.png)');
});
it('should handle multiple observations with mixed base path scenarios', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc1](/images/test1.png)' });
intermediateSteps.push({ observation: '![desc2](/librechat/images/test2.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe(
'\n![desc1](/librechat/images/test1.png)\n![desc2](/librechat/images/test2.png)',
);
});
it('should handle complex markdown with base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const complexMarkdown = `
# Document Title
![image1](/images/image1.png)
Some text between images
![image2](/images/image2.png)
`;
intermediateSteps.push({ observation: complexMarkdown });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/librechat/images/image1.png)');
});
it('should handle URLs that are already absolute', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](https://example.com/image.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](https://example.com/image.png)');
});
it('should handle data URLs', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({
observation:
'![desc](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==)',
});
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe(
'\n![desc](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==)',
);
});
});
});

View File

@@ -130,7 +130,7 @@ describe('formatAgentMessages', () => {
content: [
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: 'I\'ll search for that information.',
[ContentTypes.TEXT]: "I'll search for that information.",
tool_call_ids: ['search_1'],
},
{
@@ -144,7 +144,7 @@ describe('formatAgentMessages', () => {
},
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: 'Now, I\'ll convert the temperature.',
[ContentTypes.TEXT]: "Now, I'll convert the temperature.",
tool_call_ids: ['convert_1'],
},
{
@@ -156,7 +156,7 @@ describe('formatAgentMessages', () => {
output: '23.89°C',
},
},
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s your answer.' },
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's your answer." },
],
},
];
@@ -171,7 +171,7 @@ describe('formatAgentMessages', () => {
expect(result[4]).toBeInstanceOf(AIMessage);
// Check first AIMessage
expect(result[0].content).toBe('I\'ll search for that information.');
expect(result[0].content).toBe("I'll search for that information.");
expect(result[0].tool_calls).toHaveLength(1);
expect(result[0].tool_calls[0]).toEqual({
id: 'search_1',
@@ -187,7 +187,7 @@ describe('formatAgentMessages', () => {
);
// Check second AIMessage
expect(result[2].content).toBe('Now, I\'ll convert the temperature.');
expect(result[2].content).toBe("Now, I'll convert the temperature.");
expect(result[2].tool_calls).toHaveLength(1);
expect(result[2].tool_calls[0]).toEqual({
id: 'convert_1',
@@ -202,7 +202,7 @@ describe('formatAgentMessages', () => {
// Check final AIMessage
expect(result[4].content).toStrictEqual([
{ [ContentTypes.TEXT]: 'Here\'s your answer.', type: ContentTypes.TEXT },
{ [ContentTypes.TEXT]: "Here's your answer.", type: ContentTypes.TEXT },
]);
});
@@ -217,7 +217,7 @@ describe('formatAgentMessages', () => {
role: 'assistant',
content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }],
},
{ role: 'user', content: 'What\'s the weather?' },
{ role: 'user', content: "What's the weather?" },
{
role: 'assistant',
content: [
@@ -240,7 +240,7 @@ describe('formatAgentMessages', () => {
{
role: 'assistant',
content: [
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s the weather information.' },
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's the weather information." },
],
},
];
@@ -265,12 +265,12 @@ describe('formatAgentMessages', () => {
{ [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT },
]);
expect(result[2].content).toStrictEqual([
{ [ContentTypes.TEXT]: 'What\'s the weather?', type: ContentTypes.TEXT },
{ [ContentTypes.TEXT]: "What's the weather?", type: ContentTypes.TEXT },
]);
expect(result[3].content).toBe('Let me check that for you.');
expect(result[4].content).toBe('Sunny, 75°F');
expect(result[5].content).toStrictEqual([
{ [ContentTypes.TEXT]: 'Here\'s the weather information.', type: ContentTypes.TEXT },
{ [ContentTypes.TEXT]: "Here's the weather information.", type: ContentTypes.TEXT },
]);
// Check that there are no consecutive AIMessages

View File

@@ -82,7 +82,10 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => {
});
TestClient.sendCompletion = jest.fn(async () => {
return 'Mock response text';
return {
completion: 'Mock response text',
metadata: undefined,
};
});
TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => {

View File

@@ -8,6 +8,7 @@ const { v4: uuidv4 } = require('uuid');
const { Tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { FileContext, ContentTypes } = require('librechat-data-provider');
const { getBasePath } = require('@librechat/api');
const paths = require('~/config/paths');
const displayMessage =
@@ -36,7 +37,7 @@ class StableDiffusionAPI extends Tool {
this.description_for_model = `// Generate images and visuals using text.
// Guidelines:
// - ALWAYS use {{"prompt": "7+ detailed keywords", "negative_prompt": "7+ detailed keywords"}} structure for queries.
// - ALWAYS include the markdown url in your final response to show the user: ![caption](/images/id.png)
// - ALWAYS include the markdown url in your final response to show the user: ![caption](${getBasePath()}/images/id.png)
// - Visually describe the moods, details, structures, styles, and/or proportions of the image. Remember, the focus is on visual attributes.
// - Craft your input by "showing" and not "telling" the imagery. Think in terms of what you'd want to see in a photograph or a painting.
// - Here's an example for generating a realistic portrait photo of a man:

View File

@@ -78,11 +78,11 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
return tool(
async ({ query }) => {
if (files.length === 0) {
return 'No files to search. Instruct the user to add files for the search.';
return ['No files to search. Instruct the user to add files for the search.', undefined];
}
const jwtToken = generateShortLivedToken(userId);
if (!jwtToken) {
return 'There was an error authenticating the file search request.';
return ['There was an error authenticating the file search request.', undefined];
}
/**
@@ -122,7 +122,7 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
const validResults = results.filter((result) => result !== null);
if (validResults.length === 0) {
return 'No results found or errors occurred while searching the files.';
return ['No results found or errors occurred while searching the files.', undefined];
}
const formattedResults = validResults

View File

@@ -5,6 +5,7 @@ const traverse = require('traverse');
const SPLAT_SYMBOL = Symbol.for('splat');
const MESSAGE_SYMBOL = Symbol.for('message');
const CONSOLE_JSON_STRING_LENGTH = parseInt(process.env.CONSOLE_JSON_STRING_LENGTH) || 255;
const DEBUG_MESSAGE_LENGTH = parseInt(process.env.DEBUG_MESSAGE_LENGTH) || 150;
const sensitiveKeys = [
/^(sk-)[^\s]+/, // OpenAI API key pattern
@@ -118,7 +119,7 @@ const debugTraverse = winston.format.printf(({ level, message, timestamp, ...met
return `${timestamp} ${level}: ${JSON.stringify(message)}`;
}
let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), 150)}`;
let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), DEBUG_MESSAGE_LENGTH)}`;
try {
if (level !== 'debug') {
return msg;

View File

@@ -12,8 +12,8 @@ const {
} = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService');
const { getMCPServerTools } = require('~/server/services/Config');
const { Agent, AclEntry } = require('~/db/models');
const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
/**
* Create an agent with the provided data.
@@ -539,6 +539,37 @@ const deleteAgent = async (searchParameter) => {
return agent;
};
/**
* Deletes all agents created by a specific user.
* @param {string} userId - The ID of the user whose agents should be deleted.
* @returns {Promise<void>} A promise that resolves when all user agents have been deleted.
*/
const deleteUserAgents = async (userId) => {
try {
const userAgents = await getAgents({ author: userId });
if (userAgents.length === 0) {
return;
}
const agentIds = userAgents.map((agent) => agent.id);
const agentObjectIds = userAgents.map((agent) => agent._id);
for (const agentId of agentIds) {
await removeAgentFromAllProjects(agentId);
}
await AclEntry.deleteMany({
resourceType: ResourceType.AGENT,
resourceId: { $in: agentObjectIds },
});
await Agent.deleteMany({ author: userId });
} catch (error) {
logger.error('[deleteUserAgents] General error:', error);
}
};
/**
* Get agents by accessible IDs with optional cursor-based pagination.
* @param {Object} params - The parameters for getting accessible agents.
@@ -856,6 +887,7 @@ module.exports = {
createAgent,
updateAgent,
deleteAgent,
deleteUserAgents,
getListAgents,
revertAgentVersion,
updateAgentProjects,

View File

@@ -346,8 +346,8 @@ async function getMessage({ user, messageId }) {
*
* @async
* @function deleteMessages
* @param {Object} filter - The filter criteria to find messages to delete.
* @returns {Promise<Object>} The metadata with count of deleted messages.
* @param {import('mongoose').FilterQuery<import('mongoose').Document>} filter - The filter criteria to find messages to delete.
* @returns {Promise<import('mongoose').DeleteResult>} The metadata with count of deleted messages.
* @throws {Error} If there is an error in deleting messages.
*/
async function deleteMessages(filter) {

View File

@@ -13,7 +13,7 @@ const {
getProjectByName,
} = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService');
const { PromptGroup, Prompt } = require('~/db/models');
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
const { escapeRegExp } = require('~/server/utils');
/**
@@ -591,6 +591,36 @@ module.exports = {
return { prompt: 'Prompt deleted successfully' };
}
},
/**
* Delete all prompts and prompt groups created by a specific user.
* @param {ServerRequest} req - The server request object.
* @param {string} userId - The ID of the user whose prompts and prompt groups are to be deleted.
*/
deleteUserPrompts: async (req, userId) => {
try {
const promptGroups = await getAllPromptGroups(req, { author: new ObjectId(userId) });
if (promptGroups.length === 0) {
return;
}
const groupIds = promptGroups.map((group) => group._id);
for (const groupId of groupIds) {
await removeGroupFromAllProjects(groupId);
}
await AclEntry.deleteMany({
resourceType: ResourceType.PROMPTGROUP,
resourceId: { $in: groupIds },
});
await PromptGroup.deleteMany({ author: new ObjectId(userId) });
await Prompt.deleteMany({ author: new ObjectId(userId) });
} catch (error) {
logger.error('[deleteUserPrompts] General error:', error);
}
},
/**
* Update prompt group
* @param {Partial<MongoPromptGroup>} filter - Filter to find prompt group

View File

@@ -136,6 +136,7 @@ const tokenValues = Object.assign(
'claude-3.7-sonnet': { prompt: 3, completion: 15 },
'claude-haiku-4-5': { prompt: 1, completion: 5 },
'claude-opus-4': { prompt: 15, completion: 75 },
'claude-opus-4-5': { prompt: 5, completion: 25 },
'claude-sonnet-4': { prompt: 3, completion: 15 },
'command-r': { prompt: 0.5, completion: 1.5 },
'command-r-plus': { prompt: 3, completion: 15 },
@@ -156,6 +157,7 @@ const tokenValues = Object.assign(
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
'gemini-3': { prompt: 2, completion: 12 },
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
'grok-beta': { prompt: 5.0, completion: 15.0 },
@@ -237,8 +239,10 @@ const cacheTokenValues = {
'claude-3.5-haiku': { write: 1, read: 0.08 },
'claude-3-5-haiku': { write: 1, read: 0.08 },
'claude-3-haiku': { write: 0.3, read: 0.03 },
'claude-haiku-4-5': { write: 1.25, read: 0.1 },
'claude-sonnet-4': { write: 3.75, read: 0.3 },
'claude-opus-4': { write: 18.75, read: 1.5 },
'claude-opus-4-5': { write: 6.25, read: 0.5 },
};
/**

View File

@@ -1040,6 +1040,7 @@ describe('getCacheMultiplier', () => {
describe('Google Model Tests', () => {
const googleModels = [
'gemini-3',
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
@@ -1083,6 +1084,7 @@ describe('Google Model Tests', () => {
it('should map to the correct model keys', () => {
const expected = {
'gemini-3': 'gemini-3',
'gemini-2.5-pro': 'gemini-2.5-pro',
'gemini-2.5-flash': 'gemini-2.5-flash',
'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite',
@@ -1370,6 +1372,15 @@ describe('Claude Model Tests', () => {
);
});
it('should return correct prompt and completion rates for Claude Opus 4.5', () => {
expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'prompt' })).toBe(
tokenValues['claude-opus-4-5'].prompt,
);
expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'completion' })).toBe(
tokenValues['claude-opus-4-5'].completion,
);
});
it('should handle Claude Haiku 4.5 model name variations', () => {
const modelVariations = [
'claude-haiku-4-5',
@@ -1392,6 +1403,28 @@ describe('Claude Model Tests', () => {
});
});
it('should handle Claude Opus 4.5 model name variations', () => {
const modelVariations = [
'claude-opus-4-5',
'claude-opus-4-5-20250420',
'claude-opus-4-5-latest',
'anthropic/claude-opus-4-5',
'claude-opus-4-5/anthropic',
'claude-opus-4-5-preview',
];
modelVariations.forEach((model) => {
const valueKey = getValueKey(model);
expect(valueKey).toBe('claude-opus-4-5');
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
tokenValues['claude-opus-4-5'].prompt,
);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
tokenValues['claude-opus-4-5'].completion,
);
});
});
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
const modelVariations = [
'claude-sonnet-4',
@@ -1438,6 +1471,15 @@ describe('Claude Model Tests', () => {
);
});
it('should return correct cache rates for Claude Opus 4.5', () => {
expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'write' })).toBe(
cacheTokenValues['claude-opus-4-5'].write,
);
expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'read' })).toBe(
cacheTokenValues['claude-opus-4-5'].read,
);
});
it('should handle Claude 4 model cache rates with different prefixes and suffixes', () => {
const modelVariations = [
'claude-sonnet-4',

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.1-rc1",
"version": "v0.8.1-rc2",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -43,15 +43,15 @@
"@google/generative-ai": "^0.24.0",
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.72",
"@langchain/core": "^0.3.79",
"@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^3.0.5",
"@librechat/agents": "^3.0.32",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@modelcontextprotocol/sdk": "^1.17.1",
"@modelcontextprotocol/sdk": "^1.21.0",
"@node-saml/passport-saml": "^5.1.0",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.12.1",
@@ -76,7 +76,7 @@
"handlebars": "^4.7.7",
"https-proxy-agent": "^7.0.6",
"ioredis": "^5.3.2",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
"jwks-rsa": "^3.2.0",
"keyv": "^5.3.2",
@@ -117,7 +117,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"jest": "^29.7.0",
"jest": "^30.2.0",
"mongodb-memory-server": "^10.1.4",
"nodemon": "^3.0.3",
"supertest": "^7.1.0"

View File

@@ -350,6 +350,9 @@ function disposeClient(client) {
if (client.agentConfigs) {
client.agentConfigs = null;
}
if (client.agentIdMap) {
client.agentIdMap = null;
}
if (client.artifactPromises) {
client.artifactPromises = null;
}
@@ -376,6 +379,8 @@ function disposeClient(client) {
client.options = null;
} catch {
// Ignore errors during disposal
} finally {
logger.debug('[disposeClient] Client disposed');
}
}

View File

@@ -82,7 +82,15 @@ const refreshController = async (req, res) => {
if (error || !user) {
return res.status(401).redirect('/login');
}
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString());
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString(), refreshToken);
user.federatedTokens = {
access_token: tokenset.access_token,
id_token: tokenset.id_token,
refresh_token: refreshToken,
expires_at: claims.exp,
};
return res.status(200).send({ token, user });
} catch (error) {
logger.error('[refreshController] OpenID token refresh error', error);

View File

@@ -3,32 +3,45 @@ const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-pro
const {
MCPOAuthHandler,
MCPTokenStorage,
mcpServersRegistry,
normalizeHttpError,
extractWebSearchEnvVars,
} = require('@librechat/api');
const {
getFiles,
findToken,
updateUser,
deleteFiles,
deleteConvos,
deletePresets,
deleteMessages,
deleteUserById,
deleteAllSharedLinks,
deleteAllUserSessions,
deleteAllSharedLinks,
deleteUserById,
deleteMessages,
deletePresets,
deleteConvos,
deleteFiles,
updateUser,
findToken,
getFiles,
} = require('~/models');
const {
ConversationTag,
Transaction,
MemoryEntry,
Assistant,
AclEntry,
Balance,
Action,
Group,
Token,
User,
} = require('~/db/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { Transaction, Balance, User, Token } = require('~/db/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getAppConfig } = require('~/server/services/Config');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteUserPrompts } = require('~/models/Prompt');
const { deleteUserAgents } = require('~/models/Agent');
const { getLogStores } = require('~/cache');
const { mcpServersRegistry } = require('@librechat/api');
const getUserController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
@@ -237,7 +250,6 @@ const deleteUserController = async (req, res) => {
await deleteUserKey({ userId: user.id, all: true }); // delete user keys
await Balance.deleteMany({ user: user._id }); // delete user balances
await deletePresets(user.id); // delete user presets
/* TODO: Delete Assistant Threads */
try {
await deleteConvos(user.id); // delete user convos
} catch (error) {
@@ -249,7 +261,19 @@ const deleteUserController = async (req, res) => {
await deleteUserFiles(req); // delete user files
await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps
await deleteToolCalls(user.id); // delete user tool calls
/* TODO: queue job for cleaning actions and assistants of non-existant users */
await deleteUserAgents(user.id); // delete user agents
await Assistant.deleteMany({ user: user.id }); // delete user assistants
await ConversationTag.deleteMany({ user: user.id }); // delete user conversation tags
await MemoryEntry.deleteMany({ userId: user.id }); // delete user memory entries
await deleteUserPrompts(req, user.id); // delete user prompts
await Action.deleteMany({ user: user.id }); // delete user actions
await Token.deleteMany({ userId: user.id }); // delete user OAuth tokens
await Group.updateMany(
// remove user from all groups
{ memberIds: user.id },
{ $pull: { memberIds: user.id } },
);
await AclEntry.deleteMany({ principalId: user._id }); // delete user ACL entries
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
res.status(200).send({ message: 'User deleted' });
} catch (err) {

View File

@@ -1,7 +1,7 @@
const { nanoid } = require('nanoid');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Tools, StepTypes, FileContext } = require('librechat-data-provider');
const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider');
const {
EnvVar,
Providers,
@@ -27,31 +27,64 @@ class ModelEndHandler {
this.collectedUsage = collectedUsage;
}
finalize(errorMessage) {
if (!errorMessage) {
return;
}
throw new Error(errorMessage);
}
/**
* @param {string} event
* @param {ModelEndData | undefined} data
* @param {Record<string, unknown> | undefined} metadata
* @param {StandardGraph} graph
* @returns
* @returns {Promise<void>}
*/
handle(event, data, metadata, graph) {
async handle(event, data, metadata, graph) {
if (!graph || !metadata) {
console.warn(`Graph or metadata not found in ${event} event`);
return;
}
/** @type {string | undefined} */
let errorMessage;
try {
const agentContext = graph.getAgentContext(metadata);
if (
agentContext.provider === Providers.GOOGLE ||
agentContext.clientOptions?.disableStreaming
) {
handleToolCalls(data?.output?.tool_calls, metadata, graph);
const isGoogle = agentContext.provider === Providers.GOOGLE;
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
if (data?.output?.additional_kwargs?.stop_reason === 'refusal') {
const info = { ...data.output.additional_kwargs };
errorMessage = JSON.stringify({
type: ErrorTypes.REFUSAL,
info,
});
logger.debug(`[ModelEndHandler] Model refused to respond`, {
...info,
userId: metadata.user_id,
messageId: metadata.run_id,
conversationId: metadata.thread_id,
});
}
const toolCalls = data?.output?.tool_calls;
let hasUnprocessedToolCalls = false;
if (Array.isArray(toolCalls) && toolCalls.length > 0 && graph?.toolCallStepIds?.has) {
try {
hasUnprocessedToolCalls = toolCalls.some(
(tc) => tc?.id && !graph.toolCallStepIds.has(tc.id),
);
} catch {
hasUnprocessedToolCalls = false;
}
}
if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
await handleToolCalls(toolCalls, metadata, graph);
}
const usage = data?.output?.usage_metadata;
if (!usage) {
return;
return this.finalize(errorMessage);
}
const modelName = metadata?.ls_model_name || agentContext.clientOptions?.model;
if (modelName) {
@@ -59,17 +92,16 @@ class ModelEndHandler {
}
this.collectedUsage.push(usage);
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
if (!streamingDisabled) {
return;
return this.finalize(errorMessage);
}
if (!data.output.content) {
return;
return this.finalize(errorMessage);
}
const stepKey = graph.getStepKey(metadata);
const message_id = getMessageId(stepKey, graph) ?? '';
if (message_id) {
graph.dispatchRunStep(stepKey, {
await graph.dispatchRunStep(stepKey, {
type: StepTypes.MESSAGE_CREATION,
message_creation: {
message_id,
@@ -79,7 +111,7 @@ class ModelEndHandler {
const stepId = graph.getStepIdByKey(stepKey);
const content = data.output.content;
if (typeof content === 'string') {
graph.dispatchMessageDelta(stepId, {
await graph.dispatchMessageDelta(stepId, {
content: [
{
type: 'text',
@@ -88,12 +120,13 @@ class ModelEndHandler {
],
});
} else if (content.every((c) => c.type?.startsWith('text'))) {
graph.dispatchMessageDelta(stepId, {
await graph.dispatchMessageDelta(stepId, {
content,
});
}
} catch (error) {
logger.error('Error handling model end event:', error);
return this.finalize(errorMessage);
}
}
}
@@ -129,7 +162,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
}
const handlers = {
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage),
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback),
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger),
[GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
[GraphEvents.ON_RUN_STEP]: {
/**

View File

@@ -9,16 +9,19 @@ const {
logAxiosError,
sanitizeTitle,
resolveHeaders,
createSafeUser,
getBalanceConfig,
memoryInstructions,
getTransactionsConfig,
createMemoryProcessor,
filterMalformedContentParts,
} = require('@librechat/api');
const {
Callback,
Providers,
TitleMethod,
formatMessage,
labelContentByAgent,
formatAgentMessages,
getTokenCountForMessage,
createMetadataAggregator,
@@ -91,6 +94,61 @@ function logToolError(graph, error, toolId) {
});
}
/**
* Applies agent labeling to conversation history when multi-agent patterns are detected.
* Labels content parts by their originating agent to prevent identity confusion.
*
* @param {TMessage[]} orderedMessages - The ordered conversation messages
* @param {Agent} primaryAgent - The primary agent configuration
* @param {Map<string, Agent>} agentConfigs - Map of additional agent configurations
* @returns {TMessage[]} Messages with agent labels applied where appropriate
*/
function applyAgentLabelsToHistory(orderedMessages, primaryAgent, agentConfigs) {
const shouldLabelByAgent = (primaryAgent.edges?.length ?? 0) > 0 || (agentConfigs?.size ?? 0) > 0;
if (!shouldLabelByAgent) {
return orderedMessages;
}
const processedMessages = [];
for (let i = 0; i < orderedMessages.length; i++) {
const message = orderedMessages[i];
/** @type {Record<string, string>} */
const agentNames = { [primaryAgent.id]: primaryAgent.name || 'Assistant' };
if (agentConfigs) {
for (const [agentId, agentConfig] of agentConfigs.entries()) {
agentNames[agentId] = agentConfig.name || agentConfig.id;
}
}
if (
!message.isCreatedByUser &&
message.metadata?.agentIdMap &&
Array.isArray(message.content)
) {
try {
const labeledContent = labelContentByAgent(
message.content,
message.metadata.agentIdMap,
agentNames,
);
processedMessages.push({ ...message, content: labeledContent });
} catch (error) {
logger.error('[AgentClient] Error applying agent labels to message:', error);
processedMessages.push(message);
}
} else {
processedMessages.push(message);
}
}
return processedMessages;
}
class AgentClient extends BaseClient {
constructor(options = {}) {
super(null, options);
@@ -140,6 +198,8 @@ class AgentClient extends BaseClient {
this.indexTokenCountMap = {};
/** @type {(messages: BaseMessage[]) => Promise<void>} */
this.processMemory;
/** @type {Record<number, string> | null} */
this.agentIdMap = null;
}
/**
@@ -210,7 +270,10 @@ class AgentClient extends BaseClient {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
this.options.agent.provider,
{
provider: this.options.agent.provider,
endpoint: this.options.endpoint,
},
VisionModes.agents,
);
message.image_urls = image_urls.length ? image_urls : undefined;
@@ -229,6 +292,12 @@ class AgentClient extends BaseClient {
summary: this.shouldSummarize,
});
orderedMessages = applyAgentLabelsToHistory(
orderedMessages,
this.options.agent,
this.agentConfigs,
);
let payload;
/** @type {number | undefined} */
let promptTokens;
@@ -341,7 +410,7 @@ class AgentClient extends BaseClient {
if (mcpServers.length > 0) {
try {
const mcpInstructions = getMCPManager().formatInstructionsForContext(mcpServers);
const mcpInstructions = await getMCPManager().formatInstructionsForContext(mcpServers);
if (mcpInstructions) {
systemContent = [systemContent, mcpInstructions].filter(Boolean).join('\n\n');
logger.debug('[AgentClient] Injected MCP instructions for servers:', mcpServers);
@@ -608,7 +677,11 @@ class AgentClient extends BaseClient {
userMCPAuthMap: opts.userMCPAuthMap,
abortController: opts.abortController,
});
return this.contentParts;
const completion = filterMalformedContentParts(this.contentParts);
const metadata = this.agentIdMap ? { agentIdMap: this.agentIdMap } : undefined;
return { completion, metadata };
}
/**
@@ -761,12 +834,14 @@ class AgentClient extends BaseClient {
let run;
/** @type {Promise<(TAttachment | null)[] | undefined>} */
let memoryPromise;
const appConfig = this.options.req.config;
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
try {
if (!abortController) {
abortController = new AbortController();
}
const appConfig = this.options.req.config;
/** @type {AppConfig['endpoints']['agents']} */
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
@@ -782,7 +857,7 @@ class AgentClient extends BaseClient {
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
},
user: this.options.req.user,
user: createSafeUser(this.options.req.user),
},
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
signal: abortController.signal,
@@ -858,6 +933,7 @@ class AgentClient extends BaseClient {
signal: abortController.signal,
customHandlers: this.options.eventHandlers,
requestBody: config.configurable.requestBody,
user: createSafeUser(this.options.req?.user),
tokenCounter: createTokenCounter(this.getEncoding()),
});
@@ -898,29 +974,23 @@ class AgentClient extends BaseClient {
}
try {
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
/** Capture agent ID map if we have edges or multiple agents */
const shouldStoreAgentMap =
(this.options.agent.edges?.length ?? 0) > 0 || (this.agentConfigs?.size ?? 0) > 0;
if (shouldStoreAgentMap && run?.Graph) {
const contentPartAgentMap = run.Graph.getContentPartAgentMap();
if (contentPartAgentMap && contentPartAgentMap.size > 0) {
this.agentIdMap = Object.fromEntries(contentPartAgentMap);
logger.debug('[AgentClient] Captured agent ID map:', {
totalParts: this.contentParts.length,
mappedParts: Object.keys(this.agentIdMap).length,
});
}
}
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
await this.recordCollectedUsage({
context: 'message',
balance: balanceConfig,
transactions: transactionsConfig,
});
} catch (err) {
logger.error(
'[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage',
err,
);
} catch (error) {
logger.error('[AgentClient] Error capturing agent ID map:', error);
}
} catch (err) {
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
logger.error(
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
err,
@@ -935,6 +1005,27 @@ class AgentClient extends BaseClient {
[ContentTypes.ERROR]: `An error occurred while processing the request${err?.message ? `: ${err.message}` : ''}`,
});
}
} finally {
try {
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
await this.recordCollectedUsage({
context: 'message',
balance: balanceConfig,
transactions: transactionsConfig,
});
} catch (err) {
logger.error(
'[api/server/controllers/agents/client.js #chatCompletion] Error in cleanup phase',
err,
);
}
run = null;
config = null;
memoryPromise = null;
}
}
@@ -1063,6 +1154,7 @@ class AgentClient extends BaseClient {
if (clientOptions?.configuration?.defaultHeaders != null) {
clientOptions.configuration.defaultHeaders = resolveHeaders({
headers: clientOptions.configuration.defaultHeaders,
user: createSafeUser(this.options.req?.user),
body: {
messageId: this.responseMessageId,
conversationId: this.conversationId,

View File

@@ -14,6 +14,14 @@ jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
}));
// Mock getMCPManager
const mockFormatInstructions = jest.fn();
jest.mock('~/config', () => ({
getMCPManager: jest.fn(() => ({
formatInstructionsForContext: mockFormatInstructions,
})),
}));
describe('AgentClient - titleConvo', () => {
let client;
let mockRun;
@@ -981,7 +989,7 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic that handles GPT-5+ models
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1001,7 +1009,7 @@ describe('AgentClient - titleConvo', () => {
useResponsesApi: true,
};
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
@@ -1026,7 +1034,7 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1047,7 +1055,7 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1060,6 +1068,9 @@ describe('AgentClient - titleConvo', () => {
it('should handle various GPT-5+ model formats', () => {
const testCases = [
{ model: 'gpt-5.1', shouldTransform: true },
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
{ model: 'gpt-5.1-codex', shouldTransform: true },
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
@@ -1079,7 +1090,10 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1097,6 +1111,9 @@ describe('AgentClient - titleConvo', () => {
it('should not swap max token param for older models when using useResponsesApi', () => {
const testCases = [
{ model: 'gpt-5.1', shouldTransform: true },
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
{ model: 'gpt-5.1-codex', shouldTransform: true },
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
@@ -1116,7 +1133,10 @@ describe('AgentClient - titleConvo', () => {
useResponsesApi: true,
};
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
@@ -1149,7 +1169,10 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1168,6 +1191,200 @@ describe('AgentClient - titleConvo', () => {
});
});
describe('buildMessages with MCP server instructions', () => {
let client;
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
beforeEach(() => {
jest.clearAllMocks();
// Reset the mock to default behavior
mockFormatInstructions.mockResolvedValue(
'# MCP Server Instructions\n\nTest MCP instructions here',
);
const { DynamicStructuredTool } = require('@langchain/core/tools');
// Create mock MCP tools with the delimiter pattern
const mockMCPTool1 = new DynamicStructuredTool({
name: `tool1${Constants.mcp_delimiter}server1`,
description: 'Test MCP tool 1',
schema: {},
func: async () => 'result',
});
const mockMCPTool2 = new DynamicStructuredTool({
name: `tool2${Constants.mcp_delimiter}server2`,
description: 'Test MCP tool 2',
schema: {},
func: async () => 'result',
});
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Base agent instructions',
model_parameters: {
model: 'gpt-4',
},
tools: [mockMCPTool1, mockMCPTool2],
};
mockReq = {
user: {
id: 'user-123',
},
body: {
endpoint: EModelEndpoint.openAI,
},
config: {},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
endpoint: EModelEndpoint.agents,
};
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
client.shouldSummarize = false;
client.maxContextTokens = 4096;
});
it('should await MCP instructions and not include [object Promise] in agent instructions', async () => {
// Set specific return value for this test
mockFormatInstructions.mockResolvedValue(
'# MCP Server Instructions\n\nUse these tools carefully',
);
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
});
// Verify formatInstructionsForContext was called with correct server names
expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2']);
// Verify the instructions do NOT contain [object Promise]
expect(client.options.agent.instructions).not.toContain('[object Promise]');
// Verify the instructions DO contain the MCP instructions
expect(client.options.agent.instructions).toContain('# MCP Server Instructions');
expect(client.options.agent.instructions).toContain('Use these tools carefully');
// Verify the base instructions are also included
expect(client.options.agent.instructions).toContain('Base instructions');
});
it('should handle MCP instructions with ephemeral agent', async () => {
// Set specific return value for this test
mockFormatInstructions.mockResolvedValue(
'# Ephemeral MCP Instructions\n\nSpecial ephemeral instructions',
);
// Set up ephemeral agent with MCP servers
mockReq.body.ephemeralAgent = {
mcp: ['ephemeral-server1', 'ephemeral-server2'],
};
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Test ephemeral',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Ephemeral instructions',
additional_instructions: null,
});
// Verify formatInstructionsForContext was called with ephemeral server names
expect(mockFormatInstructions).toHaveBeenCalledWith([
'ephemeral-server1',
'ephemeral-server2',
]);
// Verify no [object Promise] in instructions
expect(client.options.agent.instructions).not.toContain('[object Promise]');
// Verify ephemeral MCP instructions are included
expect(client.options.agent.instructions).toContain('# Ephemeral MCP Instructions');
expect(client.options.agent.instructions).toContain('Special ephemeral instructions');
});
it('should handle empty MCP instructions gracefully', async () => {
// Set empty return value for this test
mockFormatInstructions.mockResolvedValue('');
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Base instructions only',
additional_instructions: null,
});
// Verify the instructions still work without MCP content
expect(client.options.agent.instructions).toBe('Base instructions only');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
it('should handle MCP instructions error gracefully', async () => {
// Set error return for this test
mockFormatInstructions.mockRejectedValue(new Error('MCP error'));
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
// Should not throw
await client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
});
// Should still have base instructions without MCP content
expect(client.options.agent.instructions).toContain('Base instructions');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
});
describe('runMemory method', () => {
let client;
let mockReq;

View File

@@ -11,7 +11,6 @@ const {
const {
Tools,
Constants,
SystemRoles,
FileSources,
ResourceType,
AccessRoleIds,
@@ -20,6 +19,8 @@ const {
PermissionBits,
actionDelimiter,
removeNullishValues,
CacheKeys,
Time,
} = require('librechat-data-provider');
const {
getListAgentsByAccess,
@@ -45,6 +46,7 @@ const { updateAction, getActions } = require('~/models/Action');
const { getCachedTools } = require('~/server/services/Config');
const { deleteFileByFilter } = require('~/models/File');
const { getCategoriesWithCounts } = require('~/models');
const { getLogStores } = require('~/cache');
const systemTools = {
[Tools.execute_code]: true,
@@ -52,6 +54,49 @@ const systemTools = {
[Tools.web_search]: true,
};
const MAX_SEARCH_LEN = 100;
const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Opportunistically refreshes S3-backed avatars for agent list responses.
* Only list responses are refreshed because they're the highest-traffic surface and
* the avatar URLs have a short-lived TTL. The refresh is cached per-user for 30 minutes
* via {@link CacheKeys.S3_EXPIRY_INTERVAL} so we refresh once per interval at most.
* @param {Array} agents - Agents being enriched with S3-backed avatars
* @param {string} userId - User identifier used for the cache refresh key
*/
const refreshListAvatars = async (agents, userId) => {
if (!agents?.length) {
return;
}
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
const refreshKey = `${userId}:agents_list`;
const alreadyChecked = await cache.get(refreshKey);
if (alreadyChecked) {
return;
}
await Promise.all(
agents.map(async (agent) => {
if (agent?.avatar?.source !== FileSources.s3 || !agent?.avatar?.filepath) {
return;
}
try {
const newPath = await refreshS3Url(agent.avatar);
if (newPath && newPath !== agent.avatar.filepath) {
agent.avatar = { ...agent.avatar, filepath: newPath };
}
} catch (err) {
logger.debug('[/Agents] Avatar refresh error for list item', err);
}
}),
);
await cache.set(refreshKey, true, Time.THIRTY_MINUTES);
};
/**
* Creates an Agent.
* @route POST /Agents
@@ -142,10 +187,13 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
agent.version = agent.versions ? agent.versions.length : 0;
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
const originalUrl = agent.avatar.filepath;
agent.avatar.filepath = await refreshS3Url(agent.avatar);
if (originalUrl !== agent.avatar.filepath) {
await updateAgent({ id }, { avatar: agent.avatar }, { updatingUserId: req.user.id });
try {
agent.avatar = {
...agent.avatar,
filepath: await refreshS3Url(agent.avatar),
};
} catch (e) {
logger.warn('[/Agents/:id] Failed to refresh S3 URL', e);
}
}
@@ -209,7 +257,12 @@ const updateAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const validatedData = agentUpdateSchema.parse(req.body);
const { _id, ...updateData } = removeNullishValues(validatedData);
// Preserve explicit null for avatar to allow resetting the avatar
const { avatar: avatarField, _id, ...rest } = validatedData;
const updateData = removeNullishValues(rest);
if (avatarField === null) {
updateData.avatar = avatarField;
}
// Convert OCR to context in incoming updateData
convertOcrToContextInPlace(updateData);
@@ -342,21 +395,21 @@ const duplicateAgentHandler = async (req, res) => {
const [domain] = action.action_id.split(actionDelimiter);
const fullActionId = `${domain}${actionDelimiter}${newActionId}`;
// Sanitize sensitive metadata before persisting
const filteredMetadata = { ...(action.metadata || {}) };
for (const field of sensitiveFields) {
delete filteredMetadata[field];
}
const newAction = await updateAction(
{ action_id: newActionId },
{
metadata: action.metadata,
metadata: filteredMetadata,
agent_id: newAgentId,
user: userId,
},
);
const filteredMetadata = { ...newAction.metadata };
for (const field of sensitiveFields) {
delete filteredMetadata[field];
}
newAction.metadata = filteredMetadata;
newActionsList.push(newAction);
return fullActionId;
};
@@ -463,13 +516,13 @@ const getListAgentsHandler = async (req, res) => {
filter.is_promoted = { $ne: true };
}
// Handle search filter
// Handle search filter (escape regex and cap length)
if (search && search.trim() !== '') {
filter.$or = [
{ name: { $regex: search.trim(), $options: 'i' } },
{ description: { $regex: search.trim(), $options: 'i' } },
];
const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN));
const regex = new RegExp(safeSearch, 'i');
filter.$or = [{ name: regex }, { description: regex }];
}
// Get agent IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({
userId,
@@ -477,10 +530,12 @@ const getListAgentsHandler = async (req, res) => {
resourceType: ResourceType.AGENT,
requiredPermissions: requiredPermission,
});
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: ResourceType.AGENT,
requiredPermissions: PermissionBits.VIEW,
});
// Use the new ACL-aware function
const data = await getListAgentsByAccess({
accessibleIds,
@@ -488,13 +543,31 @@ const getListAgentsHandler = async (req, res) => {
limit,
after: cursor,
});
if (data?.data?.length) {
data.data = data.data.map((agent) => {
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
const agents = data?.data ?? [];
if (!agents.length) {
return res.json(data);
}
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
data.data = agents.map((agent) => {
try {
if (agent?._id && publicSet.has(agent._id.toString())) {
agent.isPublic = true;
}
return agent;
});
} catch (e) {
// Silently ignore mapping errors
void e;
}
return agent;
});
// Opportunistically refresh S3 avatar URLs for list results with caching
try {
await refreshListAvatars(data.data, req.user.id);
} catch (err) {
logger.debug('[/Agents] Skipping avatar refresh for list', err);
}
return res.json(data);
} catch (error) {
@@ -517,28 +590,21 @@ const getListAgentsHandler = async (req, res) => {
const uploadAgentAvatarHandler = async (req, res) => {
try {
const appConfig = req.config;
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
filterFile({ req, file: req.file, image: true, isAvatar: true });
const { agent_id } = req.params;
if (!agent_id) {
return res.status(400).json({ message: 'Agent ID is required' });
}
const isAdmin = req.user.role === SystemRoles.ADMIN;
const existingAgent = await getAgent({ id: agent_id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
const isAuthor = existingAgent.author.toString() === req.user.id.toString();
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
if (!hasEditPermission) {
return res.status(403).json({
error: 'You do not have permission to modify this non-collaborative agent',
});
}
const buffer = await fs.readFile(req.file.path);
const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
const resizedBuffer = await resizeAvatar({
@@ -571,8 +637,6 @@ const uploadAgentAvatarHandler = async (req, res) => {
}
}
const promises = [];
const data = {
avatar: {
filepath: image.filepath,
@@ -580,17 +644,16 @@ const uploadAgentAvatarHandler = async (req, res) => {
},
};
promises.push(
await updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
}),
);
const resolved = await Promise.all(promises);
res.status(201).json(resolved[0]);
const updatedAgent = await updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
});
res.status(201).json(updatedAgent);
} catch (error) {
const message = 'An error occurred while updating the Agent Avatar';
logger.error(message, error);
logger.error(
`[/:agent_id/avatar] ${message} (${req.params?.agent_id ?? 'unknown agent'})`,
error,
);
res.status(500).json({ message });
} finally {
try {
@@ -629,21 +692,13 @@ const revertAgentVersionHandler = async (req, res) => {
return res.status(400).json({ error: 'version_index is required' });
}
const isAdmin = req.user.role === SystemRoles.ADMIN;
const existingAgent = await getAgent({ id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
const isAuthor = existingAgent.author.toString() === req.user.id.toString();
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
if (!hasEditPermission) {
return res.status(403).json({
error: 'You do not have permission to modify this non-collaborative agent',
});
}
// Permissions are enforced via route middleware (ACL EDIT)
const updatedAgent = await revertAgentVersion({ id }, version_index);

View File

@@ -47,6 +47,7 @@ jest.mock('~/server/services/PermissionService', () => ({
findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
grantPermission: jest.fn(),
hasPublicPermission: jest.fn().mockResolvedValue(false),
checkPermission: jest.fn().mockResolvedValue(true),
}));
jest.mock('~/models', () => ({
@@ -573,6 +574,68 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(updatedAgent.version).toBe(agentInDb.versions.length);
});
test('should allow resetting avatar when value is explicitly null', async () => {
await Agent.updateOne(
{ id: existingAgentId },
{
avatar: {
filepath: 'https://example.com/avatar.png',
source: 's3',
},
},
);
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
avatar: null,
};
await updateAgentHandler(mockReq, mockRes);
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.avatar).toBeNull();
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.avatar).toBeNull();
});
test('should ignore avatar field when value is undefined', async () => {
const originalAvatar = {
filepath: 'https://example.com/original.png',
source: 's3',
};
await Agent.updateOne({ id: existingAgentId }, { avatar: originalAvatar });
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
avatar: undefined,
};
await updateAgentHandler(mockReq, mockRes);
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.avatar.filepath).toBe(originalAvatar.filepath);
expect(agentInDb.avatar.source).toBe(originalAvatar.source);
});
test('should not bump version when no mutable fields change', async () => {
const existingAgent = await Agent.findOne({ id: existingAgentId });
const originalVersionCount = existingAgent.versions.length;
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
avatar: undefined,
};
await updateAgentHandler(mockReq, mockRes);
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.versions.length).toBe(originalVersionCount);
});
test('should handle validation errors properly', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;

View File

@@ -44,7 +44,13 @@ const getMCPTools = async (req, res) => {
continue;
}
const serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
let serverTools;
try {
serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
} catch (error) {
logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
continue;
}
if (!serverTools) {
logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
continue;

416
api/server/experimental.js Normal file
View File

@@ -0,0 +1,416 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
require('module-alias')({ base: path.resolve(__dirname, '..') });
const cluster = require('cluster');
const Redis = require('ioredis');
const cors = require('cors');
const axios = require('axios');
const express = require('express');
const passport = require('passport');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const {
isEnabled,
ErrorController,
performStartupChecks,
initializeFileStorage,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
const createValidateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const { updateInterfacePermissions } = require('~/models/interface');
const { checkMigrations } = require('./services/start/migration');
const initializeMCPs = require('./services/initializeMCPs');
const configureSocialLogins = require('./socialLogins');
const { getAppConfig } = require('./services/Config');
const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex');
const { seedDatabase } = require('~/models');
const routes = require('./routes');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
/** Allow PORT=0 to be used for automatic free port assignment */
const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
const host = HOST || 'localhost';
const trusted_proxy = Number(TRUST_PROXY) || 1;
/** Number of worker processes to spawn (simulating multiple pods) */
const workers = Number(process.env.CLUSTER_WORKERS) || 4;
/** Helper to wrap log messages for better visibility */
const wrapLogMessage = (msg) => {
return `\n${'='.repeat(50)}\n${msg}\n${'='.repeat(50)}`;
};
/**
* Flushes the Redis cache on startup
* This ensures a clean state for testing multi-pod MCP connection issues
*/
const flushRedisCache = async () => {
/** Skip cache flush if Redis is not enabled */
if (!isEnabled(process.env.USE_REDIS)) {
logger.info('Redis is not enabled, skipping cache flush');
return;
}
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
};
if (process.env.REDIS_PASSWORD) {
redisConfig.password = process.env.REDIS_PASSWORD;
}
/** Handle Redis Cluster configuration */
if (isEnabled(process.env.USE_REDIS_CLUSTER) || process.env.REDIS_URI?.includes(',')) {
logger.info('Detected Redis Cluster configuration');
const uris = process.env.REDIS_URI?.split(',').map((uri) => {
const url = new URL(uri.trim());
return {
host: url.hostname,
port: parseInt(url.port || '6379', 10),
};
});
const redis = new Redis.Cluster(uris, {
redisOptions: {
password: process.env.REDIS_PASSWORD,
},
});
try {
logger.info('Attempting to connect to Redis Cluster...');
await redis.ping();
logger.info('Connected to Redis Cluster. Executing flushall...');
const result = await Promise.race([
redis.flushall(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 10000)),
]);
logger.info('Redis Cluster cache flushed successfully', { result });
} catch (err) {
logger.error('Error while flushing Redis Cluster cache:', err);
throw err;
} finally {
redis.disconnect();
}
return;
}
/** Handle single Redis instance */
const redis = new Redis(redisConfig);
try {
logger.info('Attempting to connect to Redis...');
await redis.ping();
logger.info('Connected to Redis. Executing flushall...');
const result = await Promise.race([
redis.flushall(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 5000)),
]);
logger.info('Redis cache flushed successfully', { result });
} catch (err) {
logger.error('Error while flushing Redis cache:', err);
throw err;
} finally {
redis.disconnect();
}
};
/**
* Master process
* Manages worker processes and handles graceful shutdowns
*/
if (cluster.isMaster) {
logger.info(wrapLogMessage(`Master ${process.pid} is starting...`));
logger.info(`Spawning ${workers} workers to simulate multi-pod environment`);
let activeWorkers = 0;
const startTime = Date.now();
/** Flush Redis cache before starting workers */
flushRedisCache()
.then(() => {
logger.info('Cache flushed, forking workers...');
for (let i = 0; i < workers; i++) {
cluster.fork();
}
})
.catch((err) => {
logger.error('Unable to flush Redis cache, not forking workers:', err);
process.exit(1);
});
/** Track worker lifecycle */
cluster.on('online', (worker) => {
activeWorkers++;
const uptime = ((Date.now() - startTime) / 1000).toFixed(2);
logger.info(
`Worker ${worker.process.pid} is online (${activeWorkers}/${workers}) after ${uptime}s`,
);
/** Notify the last worker to perform one-time initialization tasks */
if (activeWorkers === workers) {
const allWorkers = Object.values(cluster.workers);
const lastWorker = allWorkers[allWorkers.length - 1];
if (lastWorker) {
logger.info(wrapLogMessage(`All ${workers} workers are online`));
lastWorker.send({ type: 'last-worker' });
}
}
});
cluster.on('exit', (worker, code, signal) => {
activeWorkers--;
logger.error(
`Worker ${worker.process.pid} died (${activeWorkers}/${workers}). Code: ${code}, Signal: ${signal}`,
);
logger.info('Starting a new worker to replace it...');
cluster.fork();
});
/** Graceful shutdown on SIGTERM/SIGINT */
const shutdown = () => {
logger.info('Master received shutdown signal, terminating workers...');
for (const id in cluster.workers) {
cluster.workers[id].kill();
}
setTimeout(() => {
logger.info('Forcing shutdown after timeout');
process.exit(0);
}, 10000);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
} else {
/**
* Worker process
* Each worker runs a full Express server instance
*/
const app = express();
const startServer = async () => {
logger.info(`Worker ${process.pid} initializing...`);
if (typeof Bun !== 'undefined') {
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
}
/** Connect to MongoDB */
await connectDb();
logger.info(`Worker ${process.pid}: Connected to MongoDB`);
/** Background index sync (non-blocking) */
indexSync().catch((err) => {
logger.error(`[Worker ${process.pid}][indexSync] Background sync failed:`, err);
});
app.disable('x-powered-by');
app.set('trust proxy', trusted_proxy);
/** Seed database (idempotent) */
await seedDatabase();
/** Initialize app configuration */
const appConfig = await getAppConfig();
initializeFileStorage(appConfig);
await performStartupChecks(appConfig);
await updateInterfacePermissions(appConfig);
/** Load index.html for SPA serving */
const indexPath = path.join(appConfig.paths.dist, 'index.html');
let indexHTML = fs.readFileSync(indexPath, 'utf8');
/** Support serving in subdirectory if DOMAIN_CLIENT is set */
if (process.env.DOMAIN_CLIENT) {
const clientUrl = new URL(process.env.DOMAIN_CLIENT);
const baseHref = clientUrl.pathname.endsWith('/')
? clientUrl.pathname
: `${clientUrl.pathname}/`;
if (baseHref !== '/') {
logger.info(`Setting base href to ${baseHref}`);
indexHTML = indexHTML.replace(/base href="\/"/, `base href="${baseHref}"`);
}
}
/** Health check endpoint */
app.get('/health', (_req, res) => res.status(200).send('OK'));
/** Middleware */
app.use(noIndex);
app.use(express.json({ limit: '3mb' }));
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
app.use(mongoSanitize());
app.use(cors());
app.use(cookieParser());
if (!isEnabled(DISABLE_COMPRESSION)) {
app.use(compression());
} else {
logger.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
}
app.use(staticCache(appConfig.paths.dist));
app.use(staticCache(appConfig.paths.fonts));
app.use(staticCache(appConfig.paths.assets));
if (!ALLOW_SOCIAL_LOGIN) {
logger.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
}
/** OAUTH */
app.use(passport.initialize());
passport.use(jwtLogin());
passport.use(passportLogin());
/** LDAP Auth */
if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
passport.use(ldapLogin);
}
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
await configureSocialLogins(app);
}
/** Routes */
app.use('/oauth', routes.oauth);
app.use('/api/auth', routes.auth);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/user', routes.user);
app.use('/api/search', routes.search);
app.use('/api/edit', routes.edit);
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);
app.use('/api/presets', routes.presets);
app.use('/api/prompts', routes.prompts);
app.use('/api/categories', routes.categories);
app.use('/api/tokenizer', routes.tokenizer);
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
app.use('/api/models', routes.models);
app.use('/api/plugins', routes.plugins);
app.use('/api/config', routes.config);
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
app.use('/api/share', routes.share);
app.use('/api/roles', routes.roles);
app.use('/api/agents', routes.agents);
app.use('/api/banner', routes.banner);
app.use('/api/memories', routes.memories);
app.use('/api/permissions', routes.accessPermissions);
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
/** Error handler */
app.use(ErrorController);
/** SPA fallback - serve index.html for all unmatched routes */
app.use((req, res) => {
res.set({
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
Pragma: process.env.INDEX_PRAGMA || 'no-cache',
Expires: process.env.INDEX_EXPIRES || '0',
});
const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
const saneLang = lang.replace(/"/g, '&quot;');
let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
res.type('html');
res.send(updatedIndexHtml);
});
/** Start listening on shared port (cluster will distribute connections) */
app.listen(port, host, async () => {
logger.info(
`Worker ${process.pid} started: Server listening at http://${
host == '0.0.0.0' ? 'localhost' : host
}:${port}`,
);
/** Initialize MCP servers and OAuth reconnection for this worker */
await initializeMCPs();
await initializeOAuthReconnectManager();
await checkMigrations();
});
/** Handle inter-process messages from master */
process.on('message', async (msg) => {
if (msg.type === 'last-worker') {
logger.info(
wrapLogMessage(
`Worker ${process.pid} is the last worker and can perform special initialization tasks`,
),
);
/** Add any one-time initialization tasks here */
/** For example: scheduled jobs, cleanup tasks, etc. */
}
});
};
startServer().catch((err) => {
logger.error(`Failed to start worker ${process.pid}:`, err);
process.exit(1);
});
/** Export app for testing purposes (only available in worker processes) */
module.exports = app;
}
/**
* Uncaught exception handler
* Filters out known non-critical errors
*/
let messageCount = 0;
process.on('uncaughtException', (err) => {
if (!err.message.includes('fetch failed')) {
logger.error('There was an uncaught error:', err);
}
if (err.message && err.message?.toLowerCase()?.includes('abort')) {
logger.warn('There was an uncatchable abort error.');
return;
}
if (err.message.includes('GoogleGenerativeAI')) {
logger.warn(
'\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303',
);
return;
}
if (err.message.includes('fetch failed')) {
if (messageCount === 0) {
logger.warn('Meilisearch error, search will be disabled');
messageCount++;
}
return;
}
if (err.message.includes('OpenAIError') || err.message.includes('ChatCompletionMessage')) {
logger.error(
'\n\nAn Uncaught `OpenAIError` error may be due to your reverse-proxy setup or stream configuration, or a bug in the `openai` node package.',
);
return;
}
if (err.stack && err.stack.includes('@librechat/agents')) {
logger.error(
'\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.',
{
message: err.message,
stack: err.stack,
},
);
return;
}
process.exit(1);
});

View File

@@ -185,8 +185,8 @@ process.on('uncaughtException', (err) => {
logger.error('There was an uncaught error:', err);
}
if (err.message.includes('abort')) {
logger.warn('There was an uncatchable AbortController error.');
if (err.message && err.message?.toLowerCase()?.includes('abort')) {
logger.warn('There was an uncatchable abort error.');
return;
}
@@ -213,6 +213,17 @@ process.on('uncaughtException', (err) => {
return;
}
if (err.stack && err.stack.includes('@librechat/agents')) {
logger.error(
'\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.',
{
message: err.message,
stack: err.stack,
},
);
return;
}
process.exit(1);
});

View File

@@ -1,11 +1,14 @@
const jwt = require('jsonwebtoken');
const { isEnabled } = require('@librechat/api');
const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(),
}));
const { isEnabled } = require('@librechat/api');
describe('validateImageRequest middleware', () => {
let req, res, next, validateImageRequest;
const validObjectId = '65cfb246f7ecadb8b1e8036b';
@@ -23,6 +26,7 @@ describe('validateImageRequest middleware', () => {
next = jest.fn();
process.env.JWT_REFRESH_SECRET = 'test-secret';
process.env.OPENID_REUSE_TOKENS = 'false';
delete process.env.DOMAIN_CLIENT; // Clear for tests without basePath
// Default: OpenID token reuse disabled
isEnabled.mockReturnValue(false);
@@ -296,4 +300,175 @@ describe('validateImageRequest middleware', () => {
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
});
describe('basePath functionality', () => {
let originalDomainClient;
beforeEach(() => {
originalDomainClient = process.env.DOMAIN_CLIENT;
});
afterEach(() => {
process.env.DOMAIN_CLIENT = originalDomainClient;
});
test('should validate image paths with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should validate agent avatar paths with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/agent-avatar.png`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should reject image paths without base path when DOMAIN_CLIENT is set', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should handle empty base path (root deployment)', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle missing DOMAIN_CLIENT', async () => {
delete process.env.DOMAIN_CLIENT;
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle nested subdirectories in base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/apps/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should prevent path traversal with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/../../../etc/passwd`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should handle URLs with query parameters and base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg?version=1`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle URLs with fragments and base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg#section`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle HTTPS URLs with base path', async () => {
process.env.DOMAIN_CLIENT = 'https://example.com/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle invalid DOMAIN_CLIENT gracefully', async () => {
process.env.DOMAIN_CLIENT = 'not-a-valid-url';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle OpenID flow with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
process.env.OPENID_REUSE_TOKENS = 'true';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}; token_provider=openid; openid_user_id=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});

View File

@@ -1,7 +1,7 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, getBasePath } = require('@librechat/api');
const OBJECT_ID_LENGTH = 24;
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
@@ -124,14 +124,21 @@ function createValidateImageRequest(secureImageLinks) {
return res.status(403).send('Access Denied');
}
const agentAvatarPattern = /^\/images\/[a-f0-9]{24}\/agent-[^/]*$/;
const basePath = getBasePath();
const imagesPath = `${basePath}/images`;
const agentAvatarPattern = new RegExp(
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`,
);
if (agentAvatarPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated');
return next();
}
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`);
const pathPattern = new RegExp(
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`,
);
if (pathPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated');

View File

@@ -290,6 +290,7 @@ describe('MCP Routes', () => {
it('should handle OAuth callback successfully', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@@ -382,6 +383,7 @@ describe('MCP Routes', () => {
it('should handle system-level OAuth completion', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@@ -417,6 +419,7 @@ describe('MCP Routes', () => {
it('should handle reconnection failure after OAuth', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@@ -498,6 +501,108 @@ describe('MCP Routes', () => {
expect(response.headers.location).toBe('/oauth/error?error=callback_failed');
expect(mockMcpManager.getUserConnection).not.toHaveBeenCalled();
});
it('should use original flow state credentials when storing tokens', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn(),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
const clientInfo = {
client_id: 'client123',
client_secret: 'client_secret',
};
const flowState = {
serverName: 'test-server',
userId: 'test-user-id',
metadata: { toolFlowId: 'tool-flow-123', serverUrl: 'http://example.com' },
clientInfo: clientInfo,
codeVerifier: 'test-verifier',
status: 'PENDING',
};
const mockTokens = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
};
// First call checks idempotency (status PENDING = not completed)
// Second call retrieves flow state for processing
mockFlowManager.getFlowState
.mockResolvedValueOnce({ status: 'PENDING' })
.mockResolvedValueOnce(flowState);
MCPOAuthHandler.getFlowState.mockResolvedValue(flowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const mockUserConnection = {
fetchTools: jest.fn().mockResolvedValue([]),
};
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getOAuthReconnectionManager = jest.fn().mockReturnValue({
clearReconnection: jest.fn(),
});
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
code: 'test-auth-code',
state: 'test-flow-id',
});
expect(response.status).toBe(302);
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
// Verify storeTokens was called with ORIGINAL flow state credentials
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'test-user-id',
serverName: 'test-server',
tokens: mockTokens,
clientInfo: clientInfo, // Uses original flow state, not any "updated" credentials
metadata: flowState.metadata,
}),
);
});
it('should prevent duplicate token exchange with idempotency check', async () => {
const mockFlowManager = {
getFlowState: jest.fn(),
};
// Flow is already completed
mockFlowManager.getFlowState.mockResolvedValue({
status: 'COMPLETED',
serverName: 'test-server',
userId: 'test-user-id',
});
MCPOAuthHandler.getFlowState.mockResolvedValue({
status: 'COMPLETED',
serverName: 'test-server',
userId: 'test-user-id',
});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
code: 'test-auth-code',
state: 'test-flow-id',
});
expect(response.status).toBe(302);
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
// Verify completeOAuthFlow was NOT called (prevented duplicate)
expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
});
});
describe('GET /oauth/tokens/:flowId', () => {
@@ -1242,7 +1347,9 @@ describe('MCP Routes', () => {
mcpServersRegistry.getServerConfig.mockResolvedValue({});
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);

View File

@@ -9,6 +9,8 @@ const {
PermissionTypes,
actionDelimiter,
removeNullishValues,
validateActionDomain,
validateAndParseOpenAPISpec,
} = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { findAccessibleResources } = require('~/server/services/PermissionService');
@@ -83,6 +85,32 @@ router.post(
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const appConfig = req.config;
// SECURITY: Validate the OpenAPI spec and extract the server URL
if (metadata.raw_spec) {
const validationResult = validateAndParseOpenAPISpec(metadata.raw_spec);
if (!validationResult.status || !validationResult.serverUrl) {
return res.status(400).json({
message: validationResult.message || 'Invalid OpenAPI specification',
});
}
// SECURITY: Validate the client-provided domain matches the spec's server URL domain
// This prevents SSRF attacks where an attacker provides a whitelisted domain
// but uses a different (potentially internal) URL in the raw_spec
const domainValidation = validateActionDomain(metadata.domain, validationResult.serverUrl);
if (!domainValidation.isValid) {
logger.warn(`Domain mismatch detected: ${domainValidation.message}`, {
userId: req.user.id,
agent_id,
});
return res.status(400).json({
message:
'Domain mismatch: The domain in the OpenAPI spec does not match the provided domain',
});
}
}
const isDomainAllowed = await isActionDomainAllowed(
metadata.domain,
appConfig?.actions?.allowedDomains,

View File

@@ -146,7 +146,15 @@ router.delete(
* @param {number} req.body.version_index - Index of the version to revert to.
* @returns {Agent} 200 - success response - application/json
*/
router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion);
router.post(
'/:id/revert',
checkGlobalAgentShare,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
v1.revertAgentVersion,
);
/**
* Returns a list of agents.

View File

@@ -30,11 +30,46 @@ const publicSharedLinksEnabled =
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
/**
* Fetches MCP servers from registry and adds them to the payload.
* Registry now includes all configured servers (from YAML) plus inspection data when available.
* Always fetches fresh to avoid caching incomplete initialization state.
*/
const getMCPServers = async (payload, appConfig) => {
try {
if (appConfig?.mcpConfig == null) {
return;
}
const mcpManager = getMCPManager();
if (!mcpManager) {
return;
}
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
if (!mcpServers) return;
for (const serverName in mcpServers) {
if (!payload.mcpServers) {
payload.mcpServers = {};
}
const serverConfig = mcpServers[serverName];
payload.mcpServers[serverName] = removeNullishValues({
startup: serverConfig?.startup,
chatMenu: serverConfig?.chatMenu,
isOAuth: serverConfig.requiresOAuth,
customUserVars: serverConfig?.customUserVars,
});
}
} catch (error) {
logger.error('Error loading MCP servers', error);
}
};
router.get('/', async function (req, res) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
if (cachedStartupConfig) {
const appConfig = await getAppConfig({ role: req.user?.role });
await getMCPServers(cachedStartupConfig, appConfig);
res.send(cachedStartupConfig);
return;
}
@@ -126,35 +161,6 @@ router.get('/', async function (req, res) {
payload.minPasswordLength = minPasswordLength;
}
const getMCPServers = async () => {
try {
if (appConfig?.mcpConfig == null) {
return;
}
const mcpManager = getMCPManager();
if (!mcpManager) {
return;
}
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
if (!mcpServers) return;
for (const serverName in mcpServers) {
if (!payload.mcpServers) {
payload.mcpServers = {};
}
const serverConfig = mcpServers[serverName];
payload.mcpServers[serverName] = removeNullishValues({
startup: serverConfig?.startup,
chatMenu: serverConfig?.chatMenu,
isOAuth: serverConfig.requiresOAuth,
customUserVars: serverConfig?.customUserVars,
});
}
} catch (error) {
logger.error('Error loading MCP servers', error);
}
};
await getMCPServers();
const webSearchConfig = appConfig?.webSearch;
if (
webSearchConfig != null &&
@@ -184,6 +190,7 @@ router.get('/', async function (req, res) {
}
await cache.set(CacheKeys.STARTUP_CONFIG, payload);
await getMCPServers(payload, appConfig);
return res.status(200).send(payload);
} catch (err) {
logger.error('Error in startup config', err);

View File

@@ -10,8 +10,8 @@ const {
ResourceType,
EModelEndpoint,
PermissionBits,
isAgentsEndpoint,
checkOpenAIStorage,
isAssistantsEndpoint,
} = require('librechat-data-provider');
const {
filterFile,
@@ -376,11 +376,11 @@ router.post('/', async (req, res) => {
metadata.temp_file_id = metadata.file_id;
metadata.file_id = req.file_id;
if (isAgentsEndpoint(metadata.endpoint)) {
return await processAgentFileUpload({ req, res, metadata });
if (isAssistantsEndpoint(metadata.endpoint)) {
return await processFileUpload({ req, res, metadata });
}
await processFileUpload({ req, res, metadata });
return await processAgentFileUpload({ req, res, metadata });
} catch (error) {
let message = 'Error processing file';
logger.error('[/files] Error processing file:', error);

View File

@@ -3,7 +3,11 @@ const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const { sanitizeFilename } = require('@librechat/api');
const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider');
const {
mergeFileConfig,
getEndpointFileConfig,
fileConfig: defaultFileConfig,
} = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config');
const storage = multer.diskStorage({
@@ -53,12 +57,14 @@ const createFileFilter = (customFileConfig) => {
}
const endpoint = req.body.endpoint;
const supportedTypes =
customFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes ??
customFileConfig?.endpoints?.default.supportedMimeTypes ??
defaultFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes;
const endpointType = req.body.endpointType;
const endpointFileConfig = getEndpointFileConfig({
fileConfig: customFileConfig,
endpoint,
endpointType,
});
if (!defaultFileConfig.checkType(file.mimetype, supportedTypes)) {
if (!defaultFileConfig.checkType(file.mimetype, endpointFileConfig.supportedMimeTypes)) {
return cb(new Error('Unsupported file type: ' + file.mimetype), false);
}

View File

@@ -134,6 +134,16 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
hasCodeVerifier: !!flowState.codeVerifier,
});
/** Check if this flow has already been completed (idempotency protection) */
const currentFlowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (currentFlowState?.status === 'COMPLETED') {
logger.warn('[MCP OAuth] Flow already completed, preventing duplicate token exchange', {
flowId,
serverName,
});
return res.redirect(`/oauth/success?serverName=${encodeURIComponent(serverName)}`);
}
logger.debug('[MCP OAuth] Completing OAuth flow');
const oauthHeaders = await getOAuthHeaders(serverName, flowState.userId);
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager, oauthHeaders);

View File

@@ -1,4 +1,5 @@
const express = require('express');
const { unescapeLaTeX } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { ContentTypes } = require('librechat-data-provider');
const {
@@ -134,17 +135,32 @@ router.post('/artifact/:messageId', async (req, res) => {
return res.status(400).json({ error: 'Artifact index out of bounds' });
}
// Unescape LaTeX preprocessing done by the frontend
// The frontend escapes $ signs for display, but the database has unescaped versions
const unescapedOriginal = unescapeLaTeX(original);
const unescapedUpdated = unescapeLaTeX(updated);
const targetArtifact = artifacts[index];
let updatedText = null;
if (targetArtifact.source === 'content') {
const part = message.content[targetArtifact.partIndex];
updatedText = replaceArtifactContent(part.text, targetArtifact, original, updated);
updatedText = replaceArtifactContent(
part.text,
targetArtifact,
unescapedOriginal,
unescapedUpdated,
);
if (updatedText) {
part.text = updatedText;
}
} else {
updatedText = replaceArtifactContent(message.text, targetArtifact, original, updated);
updatedText = replaceArtifactContent(
message.text,
targetArtifact,
unescapedOriginal,
unescapedUpdated,
);
if (updatedText) {
message.text = updatedText;
}

View File

@@ -8,7 +8,12 @@ const {
deleteUserController,
getUserController,
} = require('~/server/controllers/UserController');
const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware');
const {
verifyEmailLimiter,
configMiddleware,
canDeleteAccount,
requireJwtAuth,
} = require('~/server/middleware');
const router = express.Router();
@@ -16,7 +21,7 @@ router.get('/', requireJwtAuth, getUserController);
router.get('/terms', requireJwtAuth, getTermsStatusController);
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController);
router.delete('/delete', requireJwtAuth, canDeleteAccount, configMiddleware, deleteUserController);
router.post('/verify', verifyEmailController);
router.post('/verify/resend', verifyEmailLimiter, resendVerificationController);

View File

@@ -176,7 +176,7 @@ const registerUser = async (user, additionalData = {}) => {
return { status: 404, message: errorMessage };
}
const { email, password, name, username } = user;
const { email, password, name, username, provider } = user;
let newUserId;
try {
@@ -207,7 +207,7 @@ const registerUser = async (user, additionalData = {}) => {
const salt = bcrypt.genSaltSync(10);
const newUserData = {
provider: 'local',
provider: provider ?? 'local',
email,
username,
name,
@@ -412,7 +412,7 @@ const setAuthTokens = async (userId, res, _session = null) => {
* @param {string} [userId] - Optional MongoDB user ID for image path validation
* @returns {String} - access token
*/
const setOpenIDAuthTokens = (tokenset, res, userId) => {
const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => {
try {
if (!tokenset) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
@@ -427,11 +427,25 @@ const setOpenIDAuthTokens = (tokenset, res, userId) => {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return;
}
if (!tokenset.access_token || !tokenset.refresh_token) {
logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset');
if (!tokenset.access_token) {
logger.error('[setOpenIDAuthTokens] No access token found in tokenset');
return;
}
res.cookie('refreshToken', tokenset.refresh_token, {
const refreshToken = tokenset.refresh_token || existingRefreshToken;
if (!refreshToken) {
logger.error('[setOpenIDAuthTokens] No refresh token available');
return;
}
res.cookie('refreshToken', refreshToken, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
res.cookie('openid_access_token', tokenset.access_token, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,

View File

@@ -109,7 +109,7 @@ async function getEndpointsConfig(req) {
* @returns {Promise<boolean>}
*/
const checkCapability = async (req, capability) => {
const isAgents = isAgentsEndpoint(req.body?.original_endpoint || req.body?.endpoint);
const isAgents = isAgentsEndpoint(req.body?.endpointType || req.body?.endpoint);
const endpointsConfig = await getEndpointsConfig(req);
const capabilities =
isAgents || endpointsConfig?.[EModelEndpoint.agents]?.capabilities != null

View File

@@ -1,5 +1,9 @@
const { isUserProvided, normalizeEndpointName } = require('@librechat/api');
const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider');
const { isUserProvided } = require('@librechat/api');
const {
EModelEndpoint,
extractEnvVariable,
normalizeEndpointName,
} = require('librechat-data-provider');
const { fetchModels } = require('~/server/services/ModelService');
const { getAppConfig } = require('./app');

View File

@@ -16,6 +16,11 @@ async function updateMCPServerTools({ userId, serverName, tools }) {
const serverTools = {};
const mcpDelimiter = Constants.mcp_delimiter;
if (tools == null || tools.length === 0) {
logger.debug(`[MCP Cache] No tools to update for server ${serverName} (user: ${userId})`);
return serverTools;
}
for (const tool of tools) {
const name = `${tool.name}${mcpDelimiter}${serverName}`;
serverTools[name] = {

View File

@@ -3,12 +3,14 @@ const {
primeResources,
getModelMaxTokens,
extractLibreChatParams,
filterFilesByEndpointConfig,
optionalChainWithEmptyCheck,
} = require('@librechat/api');
const {
ErrorTypes,
EModelEndpoint,
EToolResources,
paramEndpoints,
isAgentsEndpoint,
replaceSpecialVars,
providerEndpointMap,
@@ -71,6 +73,9 @@ const initializeAgent = async ({
const { resendFiles, maxContextTokens, modelOptions } = extractLibreChatParams(_modelOptions);
const provider = agent.provider;
agent.endpoint = provider;
if (isInitialAgent && conversationId != null && resendFiles) {
const fileIds = (await getConvoFiles(conversationId)) ?? [];
/** @type {Set<EToolResources>} */
@@ -88,6 +93,19 @@ const initializeAgent = async ({
currentFiles = await processFiles(requestFiles);
}
if (currentFiles && currentFiles.length) {
let endpointType;
if (!paramEndpoints.has(agent.endpoint)) {
endpointType = EModelEndpoint.custom;
}
currentFiles = filterFilesByEndpointConfig(req, {
files: currentFiles,
endpoint: agent.endpoint,
endpointType,
});
}
const { attachments, tool_resources } = await primeResources({
req,
getFiles,
@@ -98,7 +116,6 @@ const initializeAgent = async ({
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
});
const provider = agent.provider;
const {
tools: structuredTools,
toolContextMap,
@@ -113,7 +130,6 @@ const initializeAgent = async ({
tool_resources,
})) ?? {};
agent.endpoint = provider;
const { getOptions, overrideProvider } = getProviderConfig({ provider, appConfig });
if (overrideProvider !== agent.provider) {
agent.provider = overrideProvider;

View File

@@ -1,9 +1,4 @@
const {
resolveHeaders,
isUserProvided,
getOpenAIConfig,
getCustomEndpointConfig,
} = require('@librechat/api');
const { isUserProvided, getOpenAIConfig, getCustomEndpointConfig } = require('@librechat/api');
const {
CacheKeys,
ErrorTypes,
@@ -34,14 +29,6 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
/** Intentionally excludes passing `body`, i.e. `req.body`, as
* values may not be accurate until `AgentClient` is initialized
*/
let resolvedHeaders = resolveHeaders({
headers: endpointConfig.headers,
user: req.user,
});
if (CUSTOM_API_KEY.match(envVarRegex)) {
throw new Error(`Missing API Key for ${endpoint}.`);
}
@@ -108,7 +95,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
}
const customOptions = {
headers: resolvedHeaders,
headers: endpointConfig.headers,
addParams: endpointConfig.addParams,
dropParams: endpointConfig.dropParams,
customParams: endpointConfig.customParams,

View File

@@ -69,17 +69,21 @@ describe('custom/initializeClient', () => {
});
});
it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => {
const { resolveHeaders } = require('@librechat/api');
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
expect(resolveHeaders).toHaveBeenCalledWith({
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
user: { id: 'user-123', email: 'test@example.com', role: 'user' },
/**
* Note: Request-based Header Resolution is deferred until right before LLM request is made
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
*/
it('stores original template headers for deferred resolution', async () => {
/**
* Note: Request-based Header Resolution is deferred until right before LLM request is made
* in the OpenAIClient or AgentClient, not during initialization.
* This test verifies that the initialize function completes successfully with optionsOnly flag,
* and that headers are passed through to be resolved later during the actual LLM request.
*/
const result = await initializeClient({
req: mockRequest,
res: mockResponse,
optionsOnly: true,
});
// Verify that options are returned for later use
expect(result).toBeDefined();
expect(result).toHaveProperty('useLegacyContent', true);
});
it('throws if endpoint config is missing', async () => {

View File

@@ -1,9 +1,9 @@
const path = require('path');
const { v4 } = require('uuid');
const axios = require('axios');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getCodeBaseURL } = require('@librechat/agents');
const { logAxiosError, getBasePath } = require('@librechat/api');
const {
Tools,
FileContext,
@@ -41,11 +41,12 @@ const processCodeOutput = async ({
const appConfig = req.config;
const currentDate = new Date();
const baseURL = getCodeBaseURL();
const basePath = getBasePath();
const fileExt = path.extname(name);
if (!fileExt || !imageExtRegex.test(name)) {
return {
filename: name,
filepath: `/api/files/code/download/${session_id}/${id}`,
filepath: `${basePath}/api/files/code/download/${session_id}/${id}`,
/** Note: expires 24 hours after creation */
expiresAt: currentDate.getTime() + 86400000,
conversationId,

View File

@@ -1,12 +1,14 @@
const axios = require('axios');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { logAxiosError, validateImage } = require('@librechat/api');
const {
FileSources,
VisionModes,
ImageDetail,
ContentTypes,
EModelEndpoint,
mergeFileConfig,
getEndpointFileConfig,
} = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@@ -84,11 +86,15 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]);
* Encodes and formats the given files.
* @param {ServerRequest} req - The request object.
* @param {Array<MongoFile>} files - The array of files to encode and format.
* @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image.
* @param {object} params - Object containing provider/endpoint information
* @param {Providers | EModelEndpoint | string} [params.provider] - The provider for the image
* @param {string} [params.endpoint] - Optional: The endpoint for the image
* @param {string} [mode] - Optional: The endpoint mode for the image.
* @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
*/
async function encodeAndFormat(req, files, endpoint, mode) {
async function encodeAndFormat(req, files, params, mode) {
const { provider, endpoint } = params;
const effectiveEndpoint = endpoint ?? provider;
const promises = [];
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */
const encodingMethods = {};
@@ -134,7 +140,7 @@ async function encodeAndFormat(req, files, endpoint, mode) {
} catch (error) {
logger.error('Error processing image from blob storage:', error);
}
} else if (source !== FileSources.local && base64Only.has(endpoint)) {
} else if (source !== FileSources.local && base64Only.has(effectiveEndpoint)) {
const [_file, imageURL] = await preparePayload(req, file);
promises.push([_file, await fetchImageToBase64(imageURL)]);
continue;
@@ -148,6 +154,17 @@ async function encodeAndFormat(req, files, endpoint, mode) {
const formattedImages = await Promise.all(promises);
promises.length = 0;
/** Extract configured file size limit from fileConfig for this endpoint */
let configuredFileSizeLimit;
if (req.config?.fileConfig) {
const fileConfig = mergeFileConfig(req.config.fileConfig);
const endpointConfig = getEndpointFileConfig({
fileConfig,
endpoint: effectiveEndpoint,
});
configuredFileSizeLimit = endpointConfig?.fileSizeLimit;
}
for (const [file, imageContent] of formattedImages) {
const fileMetadata = {
type: file.type,
@@ -168,6 +185,26 @@ async function encodeAndFormat(req, files, endpoint, mode) {
continue;
}
/** Validate image buffer against size limits */
if (file.height && file.width) {
const imageBuffer = imageContent.startsWith('http')
? null
: Buffer.from(imageContent, 'base64');
if (imageBuffer) {
const validation = await validateImage(
imageBuffer,
imageBuffer.length,
effectiveEndpoint,
configuredFileSizeLimit,
);
if (!validation.isValid) {
throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`);
}
}
}
const imagePart = {
type: ContentTypes.IMAGE_URL,
image_url: {
@@ -184,15 +221,19 @@ async function encodeAndFormat(req, files, endpoint, mode) {
continue;
}
if (endpoint && endpoint === EModelEndpoint.google && mode === VisionModes.generative) {
if (
effectiveEndpoint &&
effectiveEndpoint === EModelEndpoint.google &&
mode === VisionModes.generative
) {
delete imagePart.image_url;
imagePart.inlineData = {
mimeType: file.type,
data: imageContent,
};
} else if (endpoint && endpoint === EModelEndpoint.google) {
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.google) {
imagePart.image_url = imagePart.image_url.url;
} else if (endpoint && endpoint === EModelEndpoint.anthropic) {
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.anthropic) {
imagePart.type = 'image';
imagePart.source = {
type: 'base64',

View File

@@ -15,6 +15,7 @@ const {
checkOpenAIStorage,
removeNullishValues,
isAssistantsEndpoint,
getEndpointFileConfig,
} = require('librechat-data-provider');
const { EnvVar } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
@@ -994,7 +995,7 @@ async function saveBase64Image(
*/
function filterFile({ req, image, isAvatar }) {
const { file } = req;
const { endpoint, file_id, width, height } = req.body;
const { endpoint, endpointType, file_id, width, height } = req.body;
if (!file_id && !isAvatar) {
throw new Error('No file_id provided');
@@ -1016,9 +1017,13 @@ function filterFile({ req, image, isAvatar }) {
const appConfig = req.config;
const fileConfig = mergeFileConfig(appConfig.fileConfig);
const { fileSizeLimit: sizeLimit, supportedMimeTypes } =
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
const fileSizeLimit = isAvatar === true ? fileConfig.avatarSizeLimit : sizeLimit;
const endpointFileConfig = getEndpointFileConfig({
endpoint,
fileConfig,
endpointType,
});
const fileSizeLimit =
isAvatar === true ? fileConfig.avatarSizeLimit : endpointFileConfig.fileSizeLimit;
if (file.size > fileSizeLimit) {
throw new Error(
@@ -1028,7 +1033,10 @@ function filterFile({ req, image, isAvatar }) {
);
}
const isSupportedMimeType = fileConfig.checkType(file.mimetype, supportedMimeTypes);
const isSupportedMimeType = fileConfig.checkType(
file.mimetype,
endpointFileConfig.supportedMimeTypes,
);
if (!isSupportedMimeType) {
throw new Error('Unsupported file type');

View File

@@ -80,7 +80,9 @@ const fetchModels = async ({
try {
const options = {
headers: {},
headers: {
...(headers ?? {}),
},
timeout: 5000,
};

View File

@@ -81,6 +81,70 @@ describe('fetchModels', () => {
);
});
it('should pass custom headers to the API request', async () => {
const customHeaders = {
'X-Custom-Header': 'custom-value',
'X-API-Version': 'v2',
};
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com',
name: 'TestAPI',
headers: customHeaders,
});
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('https://api.test.com/models'),
expect.objectContaining({
headers: expect.objectContaining({
'X-Custom-Header': 'custom-value',
'X-API-Version': 'v2',
Authorization: 'Bearer testApiKey',
}),
}),
);
});
it('should handle null headers gracefully', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com',
name: 'TestAPI',
headers: null,
});
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('https://api.test.com/models'),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer testApiKey',
}),
}),
);
});
it('should handle undefined headers gracefully', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com',
name: 'TestAPI',
headers: undefined,
});
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('https://api.test.com/models'),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer testApiKey',
}),
}),
);
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -410,6 +474,64 @@ describe('getAnthropicModels', () => {
const models = await getAnthropicModels();
expect(models).toEqual(['claude-1', 'claude-2']);
});
it('should use Anthropic-specific headers when fetching models', async () => {
delete process.env.ANTHROPIC_MODELS;
process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
axios.get.mockResolvedValue({
data: {
data: [{ id: 'claude-3' }, { id: 'claude-4' }],
},
});
await fetchModels({
user: 'user123',
apiKey: 'test-anthropic-key',
baseURL: 'https://api.anthropic.com/v1',
name: EModelEndpoint.anthropic,
});
expect(axios.get).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: {
'x-api-key': 'test-anthropic-key',
'anthropic-version': expect.any(String),
},
}),
);
});
it('should pass custom headers for Anthropic endpoint', async () => {
const customHeaders = {
'X-Custom-Header': 'custom-value',
};
axios.get.mockResolvedValue({
data: {
data: [{ id: 'claude-3' }],
},
});
await fetchModels({
user: 'user123',
apiKey: 'test-anthropic-key',
baseURL: 'https://api.anthropic.com/v1',
name: EModelEndpoint.anthropic,
headers: customHeaders,
});
expect(axios.get).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: {
'x-api-key': 'test-anthropic-key',
'anthropic-version': expect.any(String),
},
}),
);
});
});
describe('getGoogleModels', () => {

View File

@@ -18,6 +18,7 @@ const {
ImageVisionTool,
openapiToFunction,
AgentCapabilities,
validateActionDomain,
defaultAgentCapabilities,
validateAndParseOpenAPISpec,
} = require('librechat-data-provider');
@@ -236,12 +237,26 @@ async function processRequiredActions(client, requiredActions) {
// Validate and parse OpenAPI spec
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec) {
if (!validationResult.spec || !validationResult.serverUrl) {
throw new Error(
`Invalid spec: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
);
}
// SECURITY: Validate the domain from the spec matches the stored domain
// This is defense-in-depth to prevent any stored malicious actions
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: client.req.user.id,
action_id: action.action_id,
});
continue; // Skip this action rather than failing the entire request
}
// Process the OpenAPI spec
const { requestBuilders } = openapiToFunction(validationResult.spec);
@@ -525,10 +540,25 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
// Validate and parse OpenAPI spec once per action set
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec) {
if (!validationResult.spec || !validationResult.serverUrl) {
continue;
}
// SECURITY: Validate the domain from the spec matches the stored domain
// This is defense-in-depth to prevent any stored malicious actions
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: req.user.id,
agent_id: agent.id,
action_id: action.action_id,
});
continue; // Skip this action rather than failing the entire request
}
const encrypted = {
oauth_client_id: action.metadata.oauth_client_id,
oauth_client_secret: action.metadata.oauth_client_secret,

View File

@@ -1,3 +1,4 @@
const cookies = require('cookie');
const jwksRsa = require('jwks-rsa');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
@@ -40,13 +41,18 @@ const openIdJwtLogin = (openIdConfig) => {
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
passReqToCallback: true,
},
/**
* @param {import('@librechat/api').ServerRequest} req
* @param {import('openid-client').IDToken} payload
* @param {import('passport-jwt').VerifyCallback} done
*/
async (payload, done) => {
async (req, payload, done) => {
try {
const authHeader = req.headers.authorization;
const rawToken = authHeader?.replace('Bearer ', '');
const { user, error, migration } = await findOpenIDUser({
findUser,
email: payload?.email,
@@ -77,6 +83,18 @@ const openIdJwtLogin = (openIdConfig) => {
await updateUser(user.id, updateData);
}
const cookieHeader = req.headers.cookie;
const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
const accessToken = parsedCookies.openid_access_token;
const refreshToken = parsedCookies.refreshToken;
user.federatedTokens = {
access_token: accessToken || rawToken,
id_token: rawToken,
refresh_token: refreshToken,
expires_at: payload.exp,
};
done(null, user);
} else {
logger.warn(

View File

@@ -543,7 +543,15 @@ async function setupOpenId() {
},
);
done(null, { ...user, tokenset });
done(null, {
...user,
tokenset,
federatedTokens: {
access_token: tokenset.access_token,
refresh_token: tokenset.refresh_token,
expires_at: tokenset.expires_at,
},
});
} catch (err) {
logger.error('[openidStrategy] login failed', err);
done(err);

View File

@@ -18,6 +18,8 @@ jest.mock('~/server/services/Config', () => ({
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(() => false),
isEmailDomainAllowed: jest.fn(() => true),
findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser,
getBalanceConfig: jest.fn(() => ({
enabled: false,
})),
@@ -446,6 +448,46 @@ describe('setupOpenId', () => {
expect(callOptions.params?.code_challenge_method).toBeUndefined();
});
it('should attach federatedTokens to user object for token propagation', async () => {
// Arrange - setup tokenset with access token, refresh token, and expiration
const tokensetWithTokens = {
...tokenset,
access_token: 'mock_access_token_abc123',
refresh_token: 'mock_refresh_token_xyz789',
expires_at: 1234567890,
};
// Act - validate with the tokenset containing tokens
const { user } = await validate(tokensetWithTokens);
// Assert - verify federatedTokens object is attached with correct values
expect(user.federatedTokens).toBeDefined();
expect(user.federatedTokens).toEqual({
access_token: 'mock_access_token_abc123',
refresh_token: 'mock_refresh_token_xyz789',
expires_at: 1234567890,
});
});
it('should include tokenset along with federatedTokens', async () => {
// Arrange
const tokensetWithTokens = {
...tokenset,
access_token: 'test_access_token',
refresh_token: 'test_refresh_token',
expires_at: 9999999999,
};
// Act
const { user } = await validate(tokensetWithTokens);
// Assert - both tokenset and federatedTokens should be present
expect(user.tokenset).toBeDefined();
expect(user.federatedTokens).toBeDefined();
expect(user.tokenset.access_token).toBe('test_access_token');
expect(user.federatedTokens.access_token).toBe('test_access_token');
});
it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
// Act
const { user } = await validate(tokenset);

View File

@@ -40,6 +40,10 @@ module.exports = {
clientId: 'fake_client_id',
clientSecret: 'fake_client_secret',
issuer: 'https://fake-issuer.com',
serverMetadata: jest.fn().mockReturnValue({
jwks_uri: 'https://fake-issuer.com/.well-known/jwks.json',
end_session_endpoint: 'https://fake-issuer.com/logout',
}),
Client: jest.fn().mockImplementation(() => ({
authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
callback: jest.fn().mockResolvedValue({

View File

@@ -1,17 +1,11 @@
const { createFileSearchTool } = require('../../../../../app/clients/tools/util/fileSearch');
const axios = require('axios');
// Mock dependencies
jest.mock('../../../../../models', () => ({
Files: {
find: jest.fn(),
},
jest.mock('axios');
jest.mock('@librechat/api', () => ({
generateShortLivedToken: jest.fn(),
}));
jest.mock('../../../../../server/services/Files/VectorDB/crud', () => ({
queryVectors: jest.fn(),
}));
jest.mock('../../../../../config', () => ({
jest.mock('@librechat/data-schemas', () => ({
logger: {
warn: jest.fn(),
error: jest.fn(),
@@ -19,68 +13,220 @@ jest.mock('../../../../../config', () => ({
},
}));
const { queryVectors } = require('../../../../../server/services/Files/VectorDB/crud');
jest.mock('~/models/File', () => ({
getFiles: jest.fn().mockResolvedValue([]),
}));
describe('fileSearch.js - test only new file_id and page additions', () => {
jest.mock('~/server/services/Files/permissions', () => ({
filterFilesByAgentAccess: jest.fn((options) => Promise.resolve(options.files)),
}));
const { createFileSearchTool } = require('~/app/clients/tools/util/fileSearch');
const { generateShortLivedToken } = require('@librechat/api');
describe('fileSearch.js - tuple return validation', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.RAG_API_URL = 'http://localhost:8000';
});
// Test only the specific changes: file_id and page metadata additions
it('should add file_id and page to search result format', async () => {
const mockFiles = [{ file_id: 'test-file-123' }];
const mockResults = [
{
describe('error cases should return tuple with undefined as second value', () => {
it('should return tuple when no files provided', async () => {
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(result[0]).toBe('No files to search. Instruct the user to add files for the search.');
expect(result[1]).toBeUndefined();
});
it('should return tuple when JWT token generation fails', async () => {
generateShortLivedToken.mockReturnValue(null);
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'file-1', filename: 'test.pdf' }],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(result[0]).toBe('There was an error authenticating the file search request.');
expect(result[1]).toBeUndefined();
});
it('should return tuple when no valid results found', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
axios.post.mockRejectedValue(new Error('API Error'));
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'file-1', filename: 'test.pdf' }],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(result[0]).toBe('No results found or errors occurred while searching the files.');
expect(result[1]).toBeUndefined();
});
});
describe('success cases should return tuple with artifact object', () => {
it('should return tuple with formatted results and sources artifact', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
const mockApiResponse = {
data: [
[
{
page_content: 'test content',
metadata: { source: 'test.pdf', page: 1 },
page_content: 'This is test content from the document',
metadata: { source: '/path/to/test.pdf', page: 1 },
},
0.3,
0.2,
],
[
{
page_content: 'Additional relevant content',
metadata: { source: '/path/to/test.pdf', page: 2 },
},
0.35,
],
],
},
];
};
queryVectors.mockResolvedValue(mockResults);
axios.post.mockResolvedValue(mockApiResponse);
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: mockFiles,
entity_id: 'agent-123',
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'file-123', filename: 'test.pdf' }],
entity_id: 'agent-456',
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
const [formattedString, artifact] = result;
expect(typeof formattedString).toBe('string');
expect(formattedString).toContain('File: test.pdf');
expect(formattedString).toContain('Relevance:');
expect(formattedString).toContain('This is test content from the document');
expect(formattedString).toContain('Additional relevant content');
expect(artifact).toBeDefined();
expect(artifact).toHaveProperty('file_search');
expect(artifact.file_search).toHaveProperty('sources');
expect(artifact.file_search).toHaveProperty('fileCitations', false);
expect(Array.isArray(artifact.file_search.sources)).toBe(true);
expect(artifact.file_search.sources.length).toBe(2);
const source = artifact.file_search.sources[0];
expect(source).toMatchObject({
type: 'file',
fileId: 'file-123',
fileName: 'test.pdf',
content: expect.any(String),
relevance: expect.any(Number),
pages: [1],
pageRelevance: { 1: expect.any(Number) },
});
});
// Mock the tool's function to return the formatted result
fileSearchTool.func = jest.fn().mockImplementation(async () => {
// Simulate the new format with file_id and page
const formattedResults = [
{
filename: 'test.pdf',
content: 'test content',
distance: 0.3,
file_id: 'test-file-123', // NEW: added file_id
page: 1, // NEW: added page
},
];
it('should include file citations in description when enabled', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
// NEW: Internal data section for processAgentResponse
const internalData = formattedResults
.map(
(result) =>
`File: ${result.filename}\nFile_ID: ${result.file_id}\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nPage: ${result.page || 'N/A'}\nContent: ${result.content}\n`,
)
.join('\n---\n');
const mockApiResponse = {
data: [
[
{
page_content: 'Content with citations',
metadata: { source: '/path/to/doc.pdf', page: 3 },
},
0.15,
],
],
};
return `File: test.pdf\nRelevance: 0.7000\nContent: test content\n\n<!-- INTERNAL_DATA_START -->\n${internalData}\n<!-- INTERNAL_DATA_END -->`;
axios.post.mockResolvedValue(mockApiResponse);
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'file-789', filename: 'doc.pdf' }],
fileCitations: true,
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
const [formattedString, artifact] = result;
expect(formattedString).toContain('Anchor:');
expect(formattedString).toContain('\\ue202turn0file0');
expect(artifact.file_search.fileCitations).toBe(true);
});
const result = await fileSearchTool.func('test');
it('should handle multiple files correctly', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
// Verify the new additions
expect(result).toContain('File_ID: test-file-123');
expect(result).toContain('Page: 1');
expect(result).toContain('<!-- INTERNAL_DATA_START -->');
expect(result).toContain('<!-- INTERNAL_DATA_END -->');
const mockResponse1 = {
data: [
[
{
page_content: 'Content from file 1',
metadata: { source: '/path/to/file1.pdf', page: 1 },
},
0.25,
],
],
};
const mockResponse2 = {
data: [
[
{
page_content: 'Content from file 2',
metadata: { source: '/path/to/file2.pdf', page: 1 },
},
0.15,
],
],
};
axios.post.mockResolvedValueOnce(mockResponse1).mockResolvedValueOnce(mockResponse2);
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [
{ file_id: 'file-1', filename: 'file1.pdf' },
{ file_id: 'file-2', filename: 'file2.pdf' },
],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
const [formattedString, artifact] = result;
expect(formattedString).toContain('file1.pdf');
expect(formattedString).toContain('file2.pdf');
expect(artifact.file_search.sources).toHaveLength(2);
// Results are sorted by distance (ascending), so file-2 (0.15) comes before file-1 (0.25)
expect(artifact.file_search.sources[0].fileId).toBe('file-2');
expect(artifact.file_search.sources[1].fileId).toBe('file-1');
});
});
});

View File

@@ -1828,7 +1828,7 @@
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
* @param {AbortController} opts.abortController - AbortController instance
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
* @returns {Promise<string>}
* @returns {Promise<{ content: Promise<MessageContentComplex[]>; metadata: Record<string, unknown>; }>}
* @memberof typedefs
*/

View File

@@ -275,6 +275,9 @@ describe('getModelMaxTokens', () => {
expect(getModelMaxTokens('gemini-1.5-pro-preview-0409', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-1.5'],
);
expect(getModelMaxTokens('gemini-3', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-3'],
);
expect(getModelMaxTokens('gemini-2.5-pro', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'],
);
@@ -861,6 +864,15 @@ describe('Claude Model Tests', () => {
);
});
it('should return correct context length for Claude Opus 4.5', () => {
expect(getModelMaxTokens('claude-opus-4-5', EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
);
expect(getModelMaxTokens('claude-opus-4-5')).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
);
});
it('should handle Claude Haiku 4.5 model name variations', () => {
const modelVariations = [
'claude-haiku-4-5',
@@ -880,6 +892,25 @@ describe('Claude Model Tests', () => {
});
});
it('should handle Claude Opus 4.5 model name variations', () => {
const modelVariations = [
'claude-opus-4-5',
'claude-opus-4-5-20250420',
'claude-opus-4-5-latest',
'anthropic/claude-opus-4-5',
'claude-opus-4-5/anthropic',
'claude-opus-4-5-preview',
];
modelVariations.forEach((model) => {
const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]);
expect(modelKey).toBe('claude-opus-4-5');
expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
);
});
});
it('should match model names correctly for Claude Haiku 4.5', () => {
const modelVariations = [
'claude-haiku-4-5',
@@ -895,6 +926,21 @@ describe('Claude Model Tests', () => {
});
});
it('should match model names correctly for Claude Opus 4.5', () => {
const modelVariations = [
'claude-opus-4-5',
'claude-opus-4-5-20250420',
'claude-opus-4-5-latest',
'anthropic/claude-opus-4-5',
'claude-opus-4-5/anthropic',
'claude-opus-4-5-preview',
];
modelVariations.forEach((model) => {
expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-opus-4-5');
});
});
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
const modelVariations = [
'claude-sonnet-4',

View File

@@ -1,4 +1,4 @@
/** v0.8.1-rc1 */
/** v0.8.1-rc2 */
module.exports = {
roots: ['<rootDir>/src'],
testEnvironment: 'jsdom',
@@ -41,7 +41,6 @@ module.exports = {
'jest-file-loader',
},
transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'],
preset: 'ts-jest',
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
clearMocks: true,
};

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.8.1-rc1",
"version": "v0.8.1-rc2",
"description": "",
"type": "module",
"scripts": {
@@ -93,7 +93,7 @@
"react-i18next": "^15.4.0",
"react-lazy-load-image-component": "^1.6.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.2",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.11.2",
"react-speech-recognition": "^3.10.0",
"react-textarea-autosize": "^8.4.0",
@@ -135,10 +135,10 @@
"babel-plugin-root-import": "^6.6.0",
"babel-plugin-transform-import-meta": "^2.3.2",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-jest": "^29.1.0",
"fs-extra": "^11.3.2",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest": "^30.2.0",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0",
"jest-file-loader": "^1.0.3",
@@ -147,7 +147,6 @@
"postcss-loader": "^7.1.0",
"postcss-preset-env": "^8.2.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.2.5",
"typescript": "^5.3.3",
"vite": "^6.4.1",
"vite-plugin-compression2": "^2.2.1",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -8,6 +8,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
import WakeLockManager from '~/components/System/WakeLockManager';
import { getThemeFromEnv } from './utils/getThemeFromEnv';
import { initializeFontSize } from '~/store/fontSize';
import { LiveAnnouncer } from '~/a11y';
@@ -51,6 +52,7 @@ const App = () => {
<ToastProvider>
<DndProvider backend={HTML5Backend}>
<RouterProvider router={router} />
<WakeLockManager />
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
<Toast />
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />

View File

@@ -3,7 +3,7 @@ import type { TMessage } from 'librechat-data-provider';
import { useChatContext } from './ChatContext';
import { getLatestText } from '~/utils';
interface ArtifactsContextValue {
export interface ArtifactsContextValue {
isSubmitting: boolean;
latestMessageId: string | null;
latestMessageText: string;
@@ -12,10 +12,15 @@ interface ArtifactsContextValue {
const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined);
export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
interface ArtifactsProviderProps {
children: React.ReactNode;
value?: Partial<ArtifactsContextValue>;
}
export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
const { isSubmitting, latestMessage, conversation } = useChatContext();
const latestMessageText = useMemo(() => {
const chatLatestMessageText = useMemo(() => {
return getLatestText({
messageId: latestMessage?.messageId ?? null,
text: latestMessage?.text ?? null,
@@ -23,15 +28,20 @@ export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
} as TMessage);
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
/** Context value only created when relevant values change */
const contextValue = useMemo<ArtifactsContextValue>(
const defaultContextValue = useMemo<ArtifactsContextValue>(
() => ({
isSubmitting,
latestMessageText,
latestMessageText: chatLatestMessageText,
latestMessageId: latestMessage?.messageId ?? null,
conversationId: conversation?.conversationId ?? null,
}),
[isSubmitting, latestMessage?.messageId, latestMessageText, conversation?.conversationId],
[isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversation?.conversationId],
);
/** Context value only created when relevant values change */
const contextValue = useMemo<ArtifactsContextValue>(
() => (value ? { ...defaultContextValue, ...value } : defaultContextValue),
[defaultContextValue, value],
);
return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>;

View File

@@ -1,7 +1,7 @@
import React, { createContext, useContext, useMemo } from 'react';
import { getEndpointField } from 'librechat-data-provider';
import type { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField } from '~/utils/endpoints';
import { useChatContext } from './ChatContext';
interface DragDropContextValue {

View File

@@ -1,29 +1,76 @@
import React, { createContext, useContext, useState } from 'react';
import React, { createContext, useContext, useState, useMemo } from 'react';
interface EditorContextType {
/**
* Mutation state context - for components that need to know about save/edit status
* Separated from code state to prevent unnecessary re-renders
*/
interface MutationContextType {
isMutating: boolean;
setIsMutating: React.Dispatch<React.SetStateAction<boolean>>;
}
/**
* Code state context - for components that need the current code content
* Changes frequently (on every keystroke), so only subscribe if needed
*/
interface CodeContextType {
currentCode?: string;
setCurrentCode: React.Dispatch<React.SetStateAction<string | undefined>>;
}
const EditorContext = createContext<EditorContextType | undefined>(undefined);
const MutationContext = createContext<MutationContextType | undefined>(undefined);
const CodeContext = createContext<CodeContextType | undefined>(undefined);
/**
* Provides editor state management for artifact code editing
* Split into two contexts to prevent unnecessary re-renders:
* - MutationContext: for save/edit status (changes rarely)
* - CodeContext: for code content (changes on every keystroke)
*/
export function EditorProvider({ children }: { children: React.ReactNode }) {
const [isMutating, setIsMutating] = useState(false);
const [currentCode, setCurrentCode] = useState<string | undefined>();
const mutationValue = useMemo(() => ({ isMutating, setIsMutating }), [isMutating]);
const codeValue = useMemo(() => ({ currentCode, setCurrentCode }), [currentCode]);
return (
<EditorContext.Provider value={{ isMutating, setIsMutating, currentCode, setCurrentCode }}>
{children}
</EditorContext.Provider>
<MutationContext.Provider value={mutationValue}>
<CodeContext.Provider value={codeValue}>{children}</CodeContext.Provider>
</MutationContext.Provider>
);
}
export function useEditorContext() {
const context = useContext(EditorContext);
/**
* Hook to access mutation state only
* Use this when you only need to know about save/edit status
*/
export function useMutationState() {
const context = useContext(MutationContext);
if (context === undefined) {
throw new Error('useEditorContext must be used within an EditorProvider');
throw new Error('useMutationState must be used within an EditorProvider');
}
return context;
}
/**
* Hook to access code state only
* Use this when you need the current code content
*/
export function useCodeState() {
const context = useContext(CodeContext);
if (context === undefined) {
throw new Error('useCodeState must be used within an EditorProvider');
}
return context;
}
/**
* @deprecated Use useMutationState() and/or useCodeState() instead
* This hook causes components to re-render on every keystroke
*/
export function useEditorContext() {
const mutation = useMutationState();
const code = useCodeState();
return { ...mutation, ...code };
}

View File

@@ -41,4 +41,8 @@ export type AgentForm = {
recursion_limit?: number;
support_contact?: SupportContact;
category: string;
// Avatar management fields
avatar_file?: File | null;
avatar_preview?: string | null;
avatar_action?: 'upload' | 'reset' | null;
} & TAgentCapabilities;

View File

@@ -6,8 +6,8 @@ import { useLocation } from 'react-router-dom';
import type { Pluggable } from 'unified';
import type { Artifact } from '~/common';
import { useMessageContext, useArtifactContext } from '~/Providers';
import { logger, extractContent, isArtifactRoute } from '~/utils';
import { artifactsState } from '~/store/artifacts';
import { logger, extractContent } from '~/utils';
import ArtifactButton from './ArtifactButton';
export const artifactPlugin: Pluggable = () => {
@@ -88,7 +88,7 @@ export function Artifact({
lastUpdateTime: now,
};
if (!location.pathname.includes('/c/')) {
if (!isArtifactRoute(location.pathname)) {
return setArtifact(currentArtifact);
}

View File

@@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
import type { Artifact } from '~/common';
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
import { getFileType, logger } from '~/utils';
import { cn, getFileType, logger, isArtifactRoute } from '~/utils';
import { useLocalize } from '~/hooks';
import store from '~/store';
@@ -13,8 +13,9 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
const location = useLocation();
const setVisible = useSetRecoilState(store.artifactsVisibility);
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
const [currentArtifactId, setCurrentArtifactId] = useRecoilState(store.currentArtifactId);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const isSelected = artifact?.id === currentArtifactId;
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
const debouncedSetVisibleRef = useRef(
@@ -36,7 +37,7 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
return;
}
if (!location.pathname.includes('/c/')) {
if (!isArtifactRoute(location.pathname)) {
return;
}
@@ -54,35 +55,52 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
return (
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
<button
type="button"
onClick={() => {
if (!location.pathname.includes('/c/')) {
{(() => {
const handleClick = () => {
if (isSelected) {
resetCurrentArtifactId();
setVisible(false);
return;
}
resetCurrentArtifactId();
setVisible(true);
if (artifacts?.[artifact.id] == null) {
setArtifacts(visibleArtifacts);
}
setTimeout(() => {
setCurrentArtifactId(artifact.id);
}, 15);
}}
className="relative overflow-hidden rounded-xl border border-border-medium transition-all duration-300 hover:border-border-xheavy hover:shadow-lg"
>
<div className="w-fit bg-surface-tertiary p-2">
<div className="flex flex-row items-center gap-2">
<FilePreview fileType={fileType} className="relative" />
<div className="overflow-hidden text-left">
<div className="truncate font-medium">{artifact.title}</div>
<div className="truncate text-text-secondary">
{localize('com_ui_artifact_click')}
};
const buttonClass = cn(
'relative overflow-hidden rounded-xl transition-all duration-300 hover:border-border-medium hover:bg-surface-hover hover:shadow-lg active:scale-[0.98]',
{
'border-border-medium bg-surface-hover shadow-lg': isSelected,
'border-border-light bg-surface-tertiary shadow-sm': !isSelected,
},
);
const actionLabel = isSelected
? localize('com_ui_click_to_close')
: localize('com_ui_artifact_click');
return (
<button type="button" onClick={handleClick} className={buttonClass}>
<div className="w-fit p-2">
<div className="flex flex-row items-center gap-2">
<FilePreview fileType={fileType} className="relative" />
<div className="overflow-hidden text-left">
<div className="truncate font-medium">{artifact.title}</div>
<div className="truncate text-text-secondary">{actionLabel}</div>
</div>
</div>
</div>
</div>
</div>
</button>
</button>
);
})()}
<br />
</div>
);

View File

@@ -1,5 +1,7 @@
import React, { useMemo, useState, useEffect, useRef, memo } from 'react';
import debounce from 'lodash/debounce';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { KeyBinding } from '@codemirror/view';
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
import {
useSandpack,
SandpackCodeEditor,
@@ -10,116 +12,143 @@ 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 { useEditorContext, useArtifactsContext } from '~/Providers';
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
import { useArtifactsContext } from '~/Providers';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
const createDebouncedMutation = (
callback: (params: {
index: number;
messageId: string;
original: string;
updated: string;
}) => void,
) => debounce(callback, 500);
const CodeEditor = ({
fileKey,
readOnly,
artifact,
editorRef,
}: {
fileKey: string;
readOnly?: boolean;
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) => {
const { sandpack } = useSandpack();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating, setCurrentCode } = useEditorContext();
const editArtifact = useEditArtifact({
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
},
});
const mutationCallback = useCallback(
(params: { index: number; messageId: string; original: string; updated: string }) => {
editArtifact.mutate(params);
},
[editArtifact],
);
const debouncedMutation = useMemo(
() => createDebouncedMutation(mutationCallback),
[mutationCallback],
);
useEffect(() => {
if (readOnly) {
return;
}
if (isMutating) {
return;
}
if (artifact.index == null) {
return;
}
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
const isNotOriginal =
currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim();
const isNotRepeated =
currentUpdate == null
? true
: currentCode != null && currentCode.trim() !== currentUpdate.trim();
if (artifact.content && isNotOriginal && isNotRepeated) {
setCurrentCode(currentCode);
debouncedMutation({
index: artifact.index,
messageId: artifact.messageId ?? '',
original: artifact.content,
updated: currentCode,
});
}
return () => {
debouncedMutation.cancel();
};
}, [
const CodeEditor = memo(
({
fileKey,
artifact.index,
artifact.content,
artifact.messageId,
readOnly,
isMutating,
currentUpdate,
setIsMutating,
sandpack.files,
setCurrentCode,
debouncedMutation,
]);
artifact,
editorRef,
}: {
fileKey: string;
readOnly?: boolean;
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) => {
const { sandpack } = useSandpack();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating } = useMutationState();
const { setCurrentCode } = useCodeState();
const editArtifact = useEditArtifact({
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
},
});
return (
<SandpackCodeEditor
ref={editorRef}
showTabs={false}
showRunButton={false}
showLineNumbers={true}
showInlineErrors={true}
readOnly={readOnly === true}
className="hljs language-javascript bg-black"
/>
);
};
/**
* Create stable debounced mutation that doesn't depend on changing callbacks
* Use refs to always access the latest values without recreating the debounce
*/
const artifactRef = useRef(artifact);
const isMutatingRef = useRef(isMutating);
const currentUpdateRef = useRef(currentUpdate);
const editArtifactRef = useRef(editArtifact);
const setCurrentCodeRef = useRef(setCurrentCode);
useEffect(() => {
artifactRef.current = artifact;
}, [artifact]);
useEffect(() => {
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,
@@ -128,6 +157,7 @@ export const ArtifactCodeEditor = function ({
artifact,
editorRef,
sharedProps,
readOnly: externalReadOnly,
}: {
fileKey: string;
artifact: Artifact;
@@ -135,6 +165,7 @@ export const ArtifactCodeEditor = function ({
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
editorRef: React.MutableRefObject<CodeEditorRef>;
readOnly?: boolean;
}) {
const { data: config } = useGetStartupConfig();
const { isSubmitting } = useArtifactsContext();
@@ -148,10 +179,11 @@ export const ArtifactCodeEditor = function ({
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
};
}, [config, template, fileKey]);
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false);
const [readOnly, setReadOnly] = useState(initialReadOnly);
useEffect(() => {
setReadOnly(isSubmitting ?? false);
}, [isSubmitting]);
setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false));
}, [isSubmitting, externalReadOnly]);
if (Object.keys(files).length === 0) {
return null;

View File

@@ -1,10 +1,9 @@
import React, { memo, useMemo } from 'react';
import {
SandpackPreview,
SandpackProvider,
import React, { memo, useMemo, type MutableRefObject } from 'react';
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
import type {
SandpackProviderProps,
SandpackPreviewRef,
} from '@codesandbox/sandpack-react/unstyled';
import type { SandpackPreviewRef, PreviewProps } from '@codesandbox/sandpack-react/unstyled';
import type { TStartupConfig } from 'librechat-data-provider';
import type { ArtifactFiles } from '~/common';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
@@ -22,7 +21,7 @@ export const ArtifactPreview = memo(function ({
fileKey: string;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
previewRef: MutableRefObject<SandpackPreviewRef>;
currentCode?: string;
startupConfig?: TStartupConfig;
}) {
@@ -36,9 +35,7 @@ export const ArtifactPreview = memo(function ({
}
return {
...files,
[fileKey]: {
code,
},
[fileKey]: { code },
};
}, [currentCode, files, fileKey]);
@@ -46,12 +43,10 @@ export const ArtifactPreview = memo(function ({
if (!startupConfig) {
return sharedOptions;
}
const _options: typeof sharedOptions = {
return {
...sharedOptions,
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
};
return _options;
}, [startupConfig, template]);
if (Object.keys(artifactFiles).length === 0) {
@@ -60,10 +55,7 @@ export const ArtifactPreview = memo(function ({
return (
<SandpackProvider
files={{
...artifactFiles,
...sharedFiles,
}}
files={{ ...artifactFiles, ...sharedFiles }}
options={options}
{...sharedProps}
template={template}

View File

@@ -1,28 +1,32 @@
import { useRef, useEffect } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { Artifact } from '~/common';
import { useEditorContext, useArtifactsContext } from '~/Providers';
import { useCodeState } from '~/Providers/EditorContext';
import { useArtifactsContext } from '~/Providers';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider';
import { ArtifactPreview } from './ArtifactPreview';
import { cn } from '~/utils';
export default function ArtifactTabs({
artifact,
editorRef,
previewRef,
isSharedConvo,
}: {
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
isSharedConvo?: boolean;
}) {
const { isSubmitting } = useArtifactsContext();
const { currentCode, setCurrentCode } = useEditorContext();
const { currentCode, setCurrentCode } = useCodeState();
const { data: startupConfig } = useGetStartupConfig();
const lastIdRef = useRef<string | null>(null);
useEffect(() => {
if (artifact.id !== lastIdRef.current) {
setCurrentCode(undefined);
@@ -33,14 +37,16 @@ export default function ArtifactTabs({
const content = artifact.content ?? '';
const contentRef = useRef<HTMLDivElement>(null);
useAutoScroll({ ref: contentRef, content, isSubmitting });
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
return (
<>
<div className="flex h-full w-full flex-col">
<Tabs.Content
ref={contentRef}
value="code"
id="artifacts-code"
className={cn('flex-grow overflow-auto')}
className="h-full w-full flex-grow overflow-auto"
tabIndex={-1}
>
<ArtifactCodeEditor
@@ -50,9 +56,11 @@ export default function ArtifactTabs({
artifact={artifact}
editorRef={editorRef}
sharedProps={sharedProps}
readOnly={isSharedConvo}
/>
</Tabs.Content>
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
<Tabs.Content value="preview" className="h-full w-full flex-grow overflow-auto" tabIndex={-1}>
<ArtifactPreview
files={files}
fileKey={fileKey}
@@ -63,6 +71,6 @@ export default function ArtifactTabs({
startupConfig={startupConfig}
/>
</Tabs.Content>
</>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import React, { useState } from 'react';
import { MenuButton } from '@ariakit/react';
import { History, Check } from 'lucide-react';
import { DropdownPopup, TooltipAnchor, Button, useMediaQuery } from '@librechat/client';
import { useLocalize } from '~/hooks';
interface ArtifactVersionProps {
currentIndex: number;
totalVersions: number;
onVersionChange: (index: number) => void;
}
export default function ArtifactVersion({
currentIndex,
totalVersions,
onVersionChange,
}: ArtifactVersionProps) {
const localize = useLocalize();
const [isPopoverActive, setIsPopoverActive] = useState(false);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const menuId = 'version-dropdown-menu';
const handleValueChange = (value: string) => {
const index = parseInt(value, 10);
onVersionChange(index);
setIsPopoverActive(false);
};
if (totalVersions <= 1) {
return null;
}
const options = Array.from({ length: totalVersions }, (_, index) => ({
value: index.toString(),
label: localize('com_ui_version_var', { 0: String(index + 1) }),
}));
const dropdownItems = options.map((option) => {
const isSelected = option.value === String(currentIndex);
return {
label: option.label,
onClick: () => handleValueChange(option.value),
value: option.value,
icon: isSelected ? (
<Check size={16} className="text-text-primary" aria-hidden="true" />
) : undefined,
};
});
return (
<DropdownPopup
menuId={menuId}
portal
focusLoop
unmountOnHide
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
<TooltipAnchor
description={localize('com_ui_change_version')}
render={
<Button
size="icon"
variant="ghost"
asChild
aria-label={localize('com_ui_change_version')}
>
<MenuButton>
<History
size={18}
className="text-text-secondary"
aria-hidden="true"
focusable="false"
/>
</MenuButton>
</Button>
}
/>
}
items={dropdownItems}
className={isSmallScreen ? '' : 'absolute right-0 top-0 mt-2'}
/>
);
}

View File

@@ -1,147 +1,334 @@
import { useRef, useState, useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, X } from 'lucide-react';
import { Code, Play, RefreshCw, X } from 'lucide-react';
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import { useShareContext, useMutationState } from '~/Providers';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact';
import { useEditorContext } from '~/Providers';
import ArtifactVersion from './ArtifactVersion';
import ArtifactTabs from './ArtifactTabs';
import { CopyCodeButton } from './Code';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
const MAX_BLUR_AMOUNT = 32;
const MAX_BACKDROP_OPACITY = 0.3;
export default function Artifacts() {
const localize = useLocalize();
const { isMutating } = useEditorContext();
const { isMutating } = useMutationState();
const { isSharedConvo } = useShareContext();
const isMobile = useMediaQuery('(max-width: 868px)');
const editorRef = useRef<CodeEditorRef>();
const previewRef = useRef<SandpackPreviewRef>();
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const [height, setHeight] = useState(90);
const [isDragging, setIsDragging] = useState(false);
const [blurAmount, setBlurAmount] = useState(0);
const dragStartY = useRef(0);
const dragStartHeight = useRef(90);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const tabOptions = [
{
value: 'code',
label: localize('com_ui_code'),
icon: <Code className="size-4" />,
},
{
value: 'preview',
label: localize('com_ui_preview'),
icon: <Play className="size-4" />,
},
];
useEffect(() => {
setIsVisible(true);
}, []);
setIsMounted(true);
const delay = isMobile ? 50 : 30;
const timer = setTimeout(() => setIsVisible(true), delay);
return () => {
clearTimeout(timer);
setIsMounted(false);
};
}, [isMobile]);
useEffect(() => {
if (!isMobile) {
setBlurAmount(0);
return;
}
const minHeightForBlur = 50;
const maxHeightForBlur = 100;
if (height <= minHeightForBlur) {
setBlurAmount(0);
} else if (height >= maxHeightForBlur) {
setBlurAmount(MAX_BLUR_AMOUNT);
} else {
const progress = (height - minHeightForBlur) / (maxHeightForBlur - minHeightForBlur);
setBlurAmount(Math.round(progress * MAX_BLUR_AMOUNT));
}
}, [height, isMobile]);
const {
activeTab,
setActiveTab,
currentIndex,
cycleArtifact,
currentArtifact,
orderedArtifactIds,
setCurrentArtifactId,
} = useArtifacts();
if (currentArtifact === null || currentArtifact === undefined) {
const handleDragStart = (e: React.PointerEvent) => {
setIsDragging(true);
dragStartY.current = e.clientY;
dragStartHeight.current = height;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const handleDragMove = (e: React.PointerEvent) => {
if (!isDragging) {
return;
}
const deltaY = dragStartY.current - e.clientY;
const viewportHeight = window.innerHeight;
const deltaPercentage = (deltaY / viewportHeight) * 100;
const newHeight = Math.max(10, Math.min(100, dragStartHeight.current + deltaPercentage));
setHeight(newHeight);
};
const handleDragEnd = (e: React.PointerEvent) => {
if (!isDragging) {
return;
}
setIsDragging(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
// Snap to positions based on final height
if (height < 30) {
closeArtifacts();
} else if (height > 95) {
setHeight(100);
} else if (height < 60) {
setHeight(50);
} else {
setHeight(90);
}
};
if (!currentArtifact || !isMounted) {
return null;
}
const handleRefresh = () => {
setIsRefreshing(true);
const client = previewRef.current?.getClient();
if (client != null) {
if (client) {
client.dispatch({ type: 'refresh' });
}
setTimeout(() => setIsRefreshing(false), 750);
};
const closeArtifacts = () => {
setIsVisible(false);
setTimeout(() => setArtifactsVisible(false), 300);
if (isMobile) {
setIsClosing(true);
setIsVisible(false);
setTimeout(() => {
setArtifactsVisible(false);
setIsClosing(false);
setHeight(90);
}, 250);
} else {
resetCurrentArtifactId();
setArtifactsVisible(false);
}
};
const backdropOpacity =
blurAmount > 0
? (Math.min(blurAmount, MAX_BLUR_AMOUNT) / MAX_BLUR_AMOUNT) * MAX_BACKDROP_OPACITY
: 0;
return (
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
{/* Main Parent */}
<div className="flex h-full w-full items-center justify-center">
{/* Main Container */}
<div className="flex h-full w-full flex-col">
{/* Mobile backdrop with dynamic blur */}
{isMobile && (
<div
className={cn(
'fixed inset-0 z-[99] bg-black will-change-[opacity,backdrop-filter]',
isVisible && !isClosing
? 'transition-all duration-300'
: 'pointer-events-none opacity-0 backdrop-blur-none transition-opacity duration-150',
blurAmount < 8 && isVisible && !isClosing ? 'pointer-events-none' : '',
)}
style={{
opacity: isVisible && !isClosing ? backdropOpacity : 0,
backdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
WebkitBackdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
}}
onClick={blurAmount >= 8 ? closeArtifacts : undefined}
aria-hidden="true"
/>
)}
<div
className={cn(
`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-500 ease-in-out`,
isVisible ? 'scale-100 opacity-100 blur-0' : 'scale-105 opacity-0 blur-sm',
'flex w-full flex-col bg-surface-primary text-xl text-text-primary',
isMobile
? cn(
'fixed inset-x-0 bottom-0 z-[100] rounded-t-[20px] shadow-[0_-10px_60px_rgba(0,0,0,0.35)]',
isVisible && !isClosing
? 'translate-y-0 opacity-100'
: 'duration-250 translate-y-full opacity-0 transition-all',
isDragging ? '' : 'transition-all duration-300',
)
: cn(
'h-full shadow-2xl',
isVisible && !isClosing
? 'duration-350 translate-x-0 opacity-100 transition-all'
: 'translate-x-5 opacity-0 transition-all duration-300',
),
)}
style={isMobile ? { height: `${height}vh` } : { overflow: 'hidden' }}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border-medium bg-surface-primary-alt p-2">
<div className="flex items-center">
<button className="mr-2 text-text-secondary" onClick={closeArtifacts}>
<ArrowLeft className="h-4 w-4" />
</button>
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
{isMobile && (
<div
className="flex flex-shrink-0 cursor-grab items-center justify-center bg-surface-primary-alt pb-1.5 pt-2.5 active:cursor-grabbing"
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
>
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60" />
</div>
<div className="flex items-center">
{/* Refresh button */}
)}
{/* Header */}
<div
className={cn(
'flex flex-shrink-0 items-center justify-between gap-2 border-b border-border-light bg-surface-primary-alt px-3 py-2 transition-all duration-300',
isMobile ? 'justify-center' : 'overflow-hidden',
)}
>
{!isMobile && (
<div
className={cn(
'flex items-center transition-all duration-500',
isVisible && !isClosing
? 'translate-x-0 opacity-100'
: '-translate-x-2 opacity-0',
)}
>
<Radio
options={tabOptions}
value={activeTab}
onChange={setActiveTab}
disabled={isMutating && activeTab !== 'code'}
/>
</div>
)}
<div
className={cn(
'flex items-center gap-2 transition-all duration-500',
isMobile ? 'min-w-max' : '',
isVisible && !isClosing ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0',
)}
>
{activeTab === 'preview' && (
<button
className={cn(
'mr-2 text-text-secondary transition-transform duration-500 ease-in-out',
isRefreshing ? 'rotate-180' : '',
)}
<Button
size="icon"
variant="ghost"
onClick={handleRefresh}
disabled={isRefreshing}
aria-label="Refresh"
aria-label={localize('com_ui_refresh')}
>
<RefreshCw
size={16}
className={cn('transform', isRefreshing ? 'animate-spin' : '')}
/>
</button>
{isRefreshing ? (
<Spinner size={16} />
) : (
<RefreshCw size={16} className="transition-transform duration-200" />
)}
</Button>
)}
{activeTab !== 'preview' && isMutating && (
<RefreshCw size={16} className="mr-2 animate-spin text-text-secondary" />
<RefreshCw size={16} className="animate-spin text-text-secondary" />
)}
{orderedArtifactIds.length > 1 && (
<ArtifactVersion
currentIndex={currentIndex}
totalVersions={orderedArtifactIds.length}
onVersionChange={(index) => {
const target = orderedArtifactIds[index];
if (target) {
setCurrentArtifactId(target);
}
}}
/>
)}
{/* Tabs */}
<Tabs.List className="mr-2 inline-flex h-7 rounded-full border border-border-medium bg-surface-tertiary">
<Tabs.Trigger
value="preview"
disabled={isMutating}
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
>
{localize('com_ui_preview')}
</Tabs.Trigger>
<Tabs.Trigger
value="code"
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
>
{localize('com_ui_code')}
</Tabs.Trigger>
</Tabs.List>
<button className="text-text-secondary" onClick={closeArtifacts}>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Content */}
<ArtifactTabs
artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
/>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
<div className="flex items-center">
<button onClick={() => cycleArtifact('prev')} className="mr-2 text-text-secondary">
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-xs">{`${currentIndex + 1} / ${
orderedArtifactIds.length
}`}</span>
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2">
<CopyCodeButton content={currentArtifact.content ?? ''} />
{/* Download Button */}
<DownloadArtifact artifact={currentArtifact} />
{/* Publish button */}
{/* <button className="border-0.5 min-w-[4rem] whitespace-nowrap rounded-md border-border-medium bg-[radial-gradient(ellipse,_var(--tw-gradient-stops))] from-surface-active from-50% to-surface-active px-3 py-1 text-xs font-medium text-text-primary transition-colors hover:bg-surface-active hover:text-text-primary active:scale-[0.985] active:bg-surface-active">
Publish
</button> */}
<Button
size="icon"
variant="ghost"
onClick={closeArtifacts}
aria-label={localize('com_ui_close')}
>
<X size={16} />
</Button>
</div>
</div>
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-surface-primary">
<div className="absolute inset-0 flex flex-col">
<ArtifactTabs
artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
isSharedConvo={isSharedConvo}
/>
</div>
<div
className={cn(
'absolute inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300 ease-in-out',
isRefreshing ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
)}
aria-hidden={!isRefreshing}
role="status"
>
<div
className={cn(
'transition-transform duration-300 ease-in-out',
isRefreshing ? 'scale-100' : 'scale-95',
)}
>
<Spinner size={24} />
</div>
</div>
</div>
{isMobile && (
<div className="flex-shrink-0 border-t border-border-light bg-surface-primary-alt p-2">
<Radio
fullWidth
options={tabOptions}
value={activeTab}
onChange={setActiveTab}
disabled={isMutating && activeTab !== 'code'}
/>
</div>
)}
</div>
</div>
</Tabs.Root>

View File

@@ -2,8 +2,9 @@ import React, { memo, useEffect, useRef, useState } from 'react';
import copy from 'copy-to-clipboard';
import rehypeKatex from 'rehype-katex';
import ReactMarkdown from 'react-markdown';
import { Button } from '@librechat/client';
import rehypeHighlight from 'rehype-highlight';
import { Clipboard, CheckMark } from '@librechat/client';
import { Copy, CircleCheckBig } from 'lucide-react';
import { handleDoubleClick, langSubset } from '~/utils';
import { useLocalize } from '~/hooks';
@@ -107,12 +108,13 @@ export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
};
return (
<button
className="mr-2 text-text-secondary"
<Button
size="icon"
variant="ghost"
onClick={handleCopy}
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
</button>
{isCopied ? <CircleCheckBig size={16} /> : <Copy size={16} />}
</Button>
);
};

View File

@@ -1,20 +1,14 @@
import React, { useState } from 'react';
import { Download } from 'lucide-react';
import { Download, CircleCheckBig } from 'lucide-react';
import type { Artifact } from '~/common';
import { CheckMark } from '@librechat/client';
import { Button } from '@librechat/client';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useEditorContext } from '~/Providers';
import { useCodeState } from '~/Providers/EditorContext';
import { useLocalize } from '~/hooks';
const DownloadArtifact = ({
artifact,
className = '',
}: {
artifact: Artifact;
className?: string;
}) => {
const DownloadArtifact = ({ artifact }: { artifact: Artifact }) => {
const localize = useLocalize();
const { currentCode } = useEditorContext();
const { currentCode } = useCodeState();
const [isDownloaded, setIsDownloaded] = useState(false);
const { fileKey: fileName } = useArtifactProps({ artifact });
@@ -41,13 +35,14 @@ const DownloadArtifact = ({
};
return (
<button
className={`mr-2 text-text-secondary ${className}`}
<Button
size="icon"
variant="ghost"
onClick={handleDownload}
aria-label={localize('com_ui_download_artifact')}
>
{isDownloaded ? <CheckMark className="h-4 w-4" /> : <Download className="h-4 w-4" />}
</button>
{isDownloaded ? <CircleCheckBig size={16} /> : <Download size={16} />}
</Button>
);
};

View File

@@ -75,7 +75,7 @@ function AuthLayout({
<div className="flex flex-grow items-center justify-center">
<div className="w-authPageWidth overflow-hidden bg-white px-6 py-4 dark:bg-gray-900 sm:max-w-md sm:rounded-lg">
{!hasStartupConfigError && !isFetching && (
{!hasStartupConfigError && !isFetching && header && (
<h1
className="mb-4 text-center text-3xl font-semibold text-black dark:text-white"
style={{ userSelect: 'none' }}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { ErrorTypes } from 'librechat-data-provider';
import { ErrorTypes, registerPage } from 'librechat-data-provider';
import { OpenIDIcon, useToastContext } from '@librechat/client';
import { useOutletContext, useSearchParams } from 'react-router-dom';
import type { TLoginLayoutContext } from '~/common';
@@ -104,7 +104,7 @@ function Login() {
{' '}
{localize('com_auth_no_account')}{' '}
<a
href="/register"
href={registerPage()}
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_sign_up')}

View File

@@ -4,6 +4,7 @@ import { Turnstile } from '@marsidev/react-turnstile';
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
import type { TRegisterUser, TError } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { useLocalize, TranslationKeys } from '~/hooks';
@@ -213,7 +214,7 @@ const Registration: React.FC = () => {
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
{localize('com_auth_already_have_account')}{' '}
<a
href="/login"
href={loginPage()}
aria-label="Login"
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>

View File

@@ -3,6 +3,7 @@ import { useState, ReactNode } from 'react';
import { Spinner, Button } from '@librechat/client';
import { useOutletContext } from 'react-router-dom';
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import type { FC } from 'react';
@@ -26,7 +27,7 @@ const ResetPasswordBodyText = () => {
<p>{localize('com_auth_reset_password_if_email_exists')}</p>
<a
className="inline-flex text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
href="/login"
href={loginPage()}
>
{localize('com_auth_back_to_login')}
</a>
@@ -134,7 +135,7 @@ function RequestPasswordReset() {
{isLoading ? <Spinner /> : localize('com_auth_continue')}
</Button>
<a
href="/login"
href={loginPage()}
className="block text-center text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_back_to_login')}

View File

@@ -72,7 +72,7 @@ const BookmarkForm = ({
}
const allTags =
queryClient.getQueryData<TConversationTag[]>([QueryKeys.conversationTags]) ?? [];
if (allTags.some((tag) => tag.tag === data.tag)) {
if (allTags.some((tag) => tag.tag === data.tag && tag.tag !== bookmark?.tag)) {
showToast({
message: localize('com_ui_bookmarks_create_exists'),
status: 'warning',

View File

@@ -0,0 +1,499 @@
import React, { createRef } from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import BookmarkForm from '../BookmarkForm';
import type { TConversationTag } from 'librechat-data-provider';
const mockMutate = jest.fn();
const mockShowToast = jest.fn();
const mockGetQueryData = jest.fn();
const mockSetOpen = jest.fn();
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, params?: Record<string, unknown>) => {
const translations: Record<string, string> = {
com_ui_bookmarks_title: 'Title',
com_ui_bookmarks_description: 'Description',
com_ui_bookmarks_edit: 'Edit Bookmark',
com_ui_bookmarks_new: 'New Bookmark',
com_ui_bookmarks_create_exists: 'This bookmark already exists',
com_ui_bookmarks_add_to_conversation: 'Add to current conversation',
com_ui_bookmarks_tag_exists: 'A bookmark with this title already exists',
com_ui_field_required: 'This field is required',
com_ui_field_max_length: `${params?.field || 'Field'} must be less than ${params?.length || 0} characters`,
};
return translations[key] || key;
},
}));
jest.mock('@librechat/client', () => {
const ActualReact = jest.requireActual<typeof import('react')>('react');
return {
Checkbox: ({
checked,
onCheckedChange,
value,
...props
}: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
value: string;
}) =>
ActualReact.createElement('input', {
type: 'checkbox',
checked,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onCheckedChange(e.target.checked),
value,
...props,
}),
Label: ({ children, ...props }: { children: React.ReactNode }) =>
ActualReact.createElement('label', props, children),
TextareaAutosize: ActualReact.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>((props, ref) => ActualReact.createElement('textarea', { ref, ...props })),
Input: ActualReact.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => ActualReact.createElement('input', { ref, ...props }),
),
useToastContext: () => ({
showToast: mockShowToast,
}),
};
});
jest.mock('~/Providers/BookmarkContext', () => ({
useBookmarkContext: () => ({
bookmarks: [],
}),
}));
jest.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
getQueryData: mockGetQueryData,
}),
}));
jest.mock('~/utils', () => ({
cn: (...classes: (string | undefined | null | boolean)[]) => classes.filter(Boolean).join(' '),
logger: {
log: jest.fn(),
},
}));
const createMockBookmark = (overrides?: Partial<TConversationTag>): TConversationTag => ({
_id: 'bookmark-1',
user: 'user-1',
tag: 'Test Bookmark',
description: 'Test description',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
count: 1,
position: 0,
...overrides,
});
const createMockMutation = (isLoading = false) => ({
mutate: mockMutate,
isLoading,
isError: false,
isSuccess: false,
data: undefined,
error: null,
reset: jest.fn(),
mutateAsync: jest.fn(),
status: 'idle' as const,
variables: undefined,
context: undefined,
failureCount: 0,
failureReason: null,
isPaused: false,
isIdle: true,
submittedAt: 0,
});
describe('BookmarkForm - Bookmark Editing', () => {
const formRef = createRef<HTMLFormElement>();
beforeEach(() => {
jest.clearAllMocks();
mockGetQueryData.mockReturnValue([]);
});
describe('Editing only the description (tag unchanged)', () => {
it('should allow submitting when only the description is changed', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Original description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
await act(async () => {
fireEvent.change(descriptionInput, { target: { value: 'Updated description' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'My Bookmark',
description: 'Updated description',
}),
);
});
expect(mockShowToast).not.toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
it('should not submit when both tag and description are unchanged', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Same description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).not.toHaveBeenCalled();
});
expect(mockSetOpen).not.toHaveBeenCalled();
});
});
describe('Renaming a tag to an existing tag (should show error)', () => {
it('should show error toast when renaming to an existing tag name (via allTags)', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
const otherBookmark = createMockBookmark({
_id: 'bookmark-2',
tag: 'Existing Tag',
description: 'Other description',
});
mockGetQueryData.mockReturnValue([existingBookmark, otherBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'This bookmark already exists',
status: 'warning',
});
});
expect(mockMutate).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
it('should show error toast when renaming to an existing tag name (via tags prop)', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
tags={['Existing Tag', 'Another Tag']}
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'This bookmark already exists',
status: 'warning',
});
});
expect(mockMutate).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
});
describe('Renaming a tag to a new tag (should succeed)', () => {
it('should allow renaming to a completely new tag name', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Brand New Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'Brand New Tag',
description: 'Description',
}),
);
});
expect(mockShowToast).not.toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
it('should allow keeping the same tag name when editing (not trigger duplicate error)', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Original description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
await act(async () => {
fireEvent.change(descriptionInput, { target: { value: 'New description' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'My Bookmark',
description: 'New description',
}),
);
});
expect(mockShowToast).not.toHaveBeenCalled();
});
});
describe('Validation interaction between different data sources', () => {
it('should check both tags prop and allTags query data for duplicates', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
const queryDataBookmark = createMockBookmark({
_id: 'bookmark-query',
tag: 'Query Data Tag',
});
mockGetQueryData.mockReturnValue([existingBookmark, queryDataBookmark]);
render(
<BookmarkForm
tags={['Props Tag']}
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Props Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'This bookmark already exists',
status: 'warning',
});
});
expect(mockMutate).not.toHaveBeenCalled();
});
it('should not trigger mutation when mutation is loading', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation(true) as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
await act(async () => {
fireEvent.change(descriptionInput, { target: { value: 'Updated description' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).not.toHaveBeenCalled();
});
});
it('should handle empty allTags gracefully', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Description',
});
mockGetQueryData.mockReturnValue(null);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'New Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'New Tag',
}),
);
});
});
});
});

View File

@@ -11,6 +11,7 @@ import BookmarkMenu from './Menus/BookmarkMenu';
import { TemporaryChat } from './TemporaryChat';
import AddMultiConvo from './AddMultiConvo';
import { useHasAccess } from '~/hooks';
import { AnimatePresence, motion } from 'framer-motion';
const defaultInterface = getConfigDefaults().interface;
@@ -38,24 +39,24 @@ export default function Header() {
return (
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="mx-1 flex items-center gap-2">
<div
className={`flex items-center gap-2 ${
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
} ${
!navVisible
? 'translate-x-0 opacity-100'
: 'pointer-events-none translate-x-[-100px] opacity-0'
}`}
>
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
<HeaderNewChat />
</div>
<div
className={`flex items-center gap-2 ${
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
} ${!navVisible ? 'translate-x-0' : 'translate-x-[-100px]'}`}
>
<div className="mx-1 flex items-center">
<AnimatePresence initial={false}>
{!navVisible && (
<motion.div
className={`flex items-center gap-2`}
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
key="header-buttons"
>
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
<HeaderNewChat />
</motion.div>
)}
</AnimatePresence>
<div className={navVisible ? 'flex items-center gap-2' : 'ml-2 flex items-center gap-2'}>
<ModelSelector startupConfig={startupConfig} />
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
{hasAccessToBookmarks === true && <BookmarkMenu />}

View File

@@ -260,37 +260,50 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<FileFormChat conversation={conversation} />
{endpoint && (
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
}}
disabled={disableInputs || isNotAppendable}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => {
handleFocusOrClick();
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
aria-label={localize('com_ui_message_input')}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
removeFocusRings,
'transition-[max-height] duration-200 disabled:cursor-not-allowed',
<div className="relative flex-1">
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current =
e;
}}
disabled={disableInputs || isNotAppendable}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => {
handleFocusOrClick();
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
aria-label={localize('com_ui_message_input')}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
removeFocusRings,
'scrollbar-hover transition-[max-height] duration-200 disabled:cursor-not-allowed',
)}
/>
{isCollapsed && (
<div
className="pointer-events-none absolute bottom-0 left-0 right-0 h-10 transition-all duration-200"
style={{
backdropFilter: 'blur(2px)',
WebkitMaskImage: 'linear-gradient(to top, black 15%, transparent 75%)',
maskImage: 'linear-gradient(to top, black 15%, transparent 75%)',
}}
/>
)}
/>
<div className="flex flex-col items-start justify-start pt-1.5">
</div>
<div className="flex flex-col items-start justify-start pr-2.5 pt-1.5">
<CollapseChat
isCollapsed={isCollapsed}
isScrollable={isMoreThanThreeRows}
@@ -301,7 +314,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
)}
<div
className={cn(
'items-between flex gap-2 pb-2',
'@container items-between flex gap-2 pb-2',
isRTL ? 'flex-row-reverse' : 'flex-row',
)}
>

View File

@@ -5,12 +5,12 @@ import {
EModelEndpoint,
mergeFileConfig,
isAgentsEndpoint,
getEndpointField,
isAssistantsEndpoint,
fileConfig as defaultFileConfig,
getEndpointFileConfig,
} from 'librechat-data-provider';
import type { EndpointFileConfig, TConversation } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import { useGetFileConfig, useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField } from '~/utils/endpoints';
import AttachFileMenu from './AttachFileMenu';
import AttachFile from './AttachFile';
@@ -26,7 +26,7 @@ function AttachFileChat({
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
const isAssistants = useMemo(() => isAssistantsEndpoint(endpoint), [endpoint]);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
@@ -39,9 +39,23 @@ function AttachFileChat({
);
}, [endpoint, endpointsConfig]);
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''] as EndpointFileConfig | undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
const endpointFileConfig = useMemo(
() =>
getEndpointFileConfig({
endpoint,
fileConfig,
endpointType,
}),
[endpoint, fileConfig, endpointType],
);
const endpointSupportsFiles: boolean = useMemo(
() => supportsFiles[endpointType ?? endpoint ?? ''] ?? false,
[endpointType, endpoint],
);
const isUploadDisabled = useMemo(
() => (disableInputs || endpointFileConfig?.disabled) ?? false,
[disableInputs, endpointFileConfig?.disabled],
);
if (isAssistants && endpointSupportsFiles && !isUploadDisabled) {
return <AttachFile disabled={disableInputs} />;

View File

@@ -61,13 +61,8 @@ const AttachFileMenu = ({
ephemeralAgentByConvoId(conversationId),
);
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig,
});
const { handleFileChange } = useFileHandling();
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
overrideEndpoint: EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig,
toolResource,
});

View File

@@ -1,10 +1,8 @@
import { useRecoilState } from 'recoil';
import { useState } from 'react';
import { Settings2 } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { TooltipAnchor } from '@librechat/client';
import { Root, Anchor } from '@radix-ui/react-popover';
import { PluginStoreDialog, TooltipAnchor } from '@librechat/client';
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
import { EModelEndpoint, isParamEndpoint, tConvoUpdateSchema } from 'librechat-data-provider';
import { isParamEndpoint, getEndpointField, tConvoUpdateSchema } from 'librechat-data-provider';
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
import { useSetIndexOptions, useLocalize } from '~/hooks';
@@ -12,8 +10,6 @@ import { useGetEndpointsQuery } from '~/data-provider';
import OptionsPopover from './OptionsPopover';
import PopoverButtons from './PopoverButtons';
import { useChatContext } from '~/Providers';
import { getEndpointField } from '~/utils';
import store from '~/store';
export default function HeaderOptions({
interfaceConfig,
@@ -23,36 +19,11 @@ export default function HeaderOptions({
const { data: endpointsConfig } = useGetEndpointsQuery();
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
store.showPluginStoreDialog,
);
const localize = useLocalize();
const { showPopover, conversation, setShowPopover } = useChatContext();
const { setOption } = useSetIndexOptions();
const { endpoint, conversationId } = conversation ?? {};
const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(endpoint ?? '');
const userProvidesKey = useMemo(
() => !!(endpointsConfig?.[endpoint ?? '']?.userProvide ?? false),
[endpointsConfig, endpoint],
);
const keyProvided = useMemo(
() => (userProvidesKey ? !!(keyExpiry.expiresAt ?? '') : true),
[keyExpiry.expiresAt, userProvidesKey],
);
const noSettings = useMemo<{ [key: string]: boolean }>(
() => ({
[EModelEndpoint.chatGPTBrowser]: true,
}),
[conversationId],
);
useEffect(() => {
if (endpoint && noSettings[endpoint]) {
setShowPopover(false);
}
}, [endpoint, noSettings]);
const { endpoint } = conversation ?? {};
const saveAsPreset = () => {
setSaveAsDialogShow(true);
@@ -76,22 +47,20 @@ export default function HeaderOptions({
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
<div className="z-[61] flex w-full items-center justify-center gap-2">
{!noSettings[endpoint] &&
interfaceConfig?.parameters === true &&
paramEndpoint === false && (
<TooltipAnchor
id="parameters-button"
aria-label={localize('com_ui_model_parameters')}
description={localize('com_ui_model_parameters')}
tabIndex={0}
role="button"
onClick={triggerAdvancedMode}
data-testid="parameters-button"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
</TooltipAnchor>
)}
{interfaceConfig?.parameters === true && paramEndpoint === false && (
<TooltipAnchor
id="parameters-button"
aria-label={localize('com_ui_model_parameters')}
description={localize('com_ui_model_parameters')}
tabIndex={0}
role="button"
onClick={triggerAdvancedMode}
data-testid="parameters-button"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
</TooltipAnchor>
)}
</div>
{interfaceConfig?.parameters === true && paramEndpoint === false && (
<OptionsPopover
@@ -122,12 +91,6 @@ export default function HeaderOptions({
}
/>
)}
{interfaceConfig?.parameters === true && (
<PluginStoreDialog
isOpen={showPluginStoreDialog}
setIsOpen={setShowPluginStoreDialog}
/>
)}
</span>
</div>
</Anchor>

View File

@@ -166,6 +166,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
<TooltipAnchor
className="absolute bottom-[27px] right-2"
description={localize('com_ui_happy_birthday')}
aria-label={localize('com_ui_happy_birthday')}
>
<BirthdayIcon />
</TooltipAnchor>

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
import { getEndpointField } from '~/utils';
interface DialogManagerProps {
keyDialogOpen: boolean;

View File

@@ -1,7 +1,8 @@
import React, { memo } from 'react';
import { getEndpointField } from 'librechat-data-provider';
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
import type { IconMapProps } from '~/common';
import { getModelSpecIconURL, getIconKey, getEndpointField } from '~/utils';
import { getModelSpecIconURL, getIconKey } from '~/utils';
import { URLIcon } from '~/components/Endpoints/URLIcon';
import { icons } from '~/hooks/Endpoint/Icons';

View File

@@ -1,20 +1,21 @@
import { useRecoilValue } from 'recoil';
import { Close } from '@radix-ui/react-popover';
import { Flipper, Flipped } from 'react-flip-toolkit';
import { getEndpointField } from 'librechat-data-provider';
import {
Dialog,
DialogTrigger,
Label,
DialogTemplate,
PinIcon,
EditIcon,
TrashIcon,
DialogTrigger,
DialogTemplate,
} from '@librechat/client';
import type { TPreset } from 'librechat-data-provider';
import type { FC } from 'react';
import { getPresetTitle, getEndpointField, getIconKey } from '~/utils';
import FileUpload from '~/components/Chat/Input/Files/FileUpload';
import { useGetEndpointsQuery } from '~/data-provider';
import { getPresetTitle, getIconKey } from '~/utils';
import { MenuSeparator, MenuItem } from '../UI';
import { icons } from '~/hooks/Endpoint/Icons';
import { useLocalize } from '~/hooks';

View File

@@ -180,6 +180,10 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
}
}, [isPromptOpen, zoom]);
const imageDetailsLabel = isPromptOpen
? localize('com_ui_hide_image_details')
: localize('com_ui_show_image_details');
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogContent
@@ -198,6 +202,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
onClick={() => onOpenChange(false)}
variant="ghost"
className="h-10 w-10 p-0 hover:bg-surface-hover"
aria-label={localize('com_ui_close')}
>
<X className="size-7 sm:size-6" />
</Button>
@@ -208,7 +213,12 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
<TooltipAnchor
description={localize('com_ui_reset_zoom')}
render={
<Button onClick={resetZoom} variant="ghost" className="h-10 w-10 p-0">
<Button
onClick={resetZoom}
variant="ghost"
className="h-10 w-10 p-0"
aria-label={localize('com_ui_reset_zoom')}
>
<RotateCcw className="size-6" />
</Button>
}
@@ -217,22 +227,24 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
<TooltipAnchor
description={localize('com_ui_download')}
render={
<Button onClick={() => downloadImage()} variant="ghost" className="h-10 w-10 p-0">
<Button
onClick={() => downloadImage()}
variant="ghost"
className="h-10 w-10 p-0"
aria-label={localize('com_ui_download')}
>
<ArrowDownToLine className="size-6" />
</Button>
}
/>
<TooltipAnchor
description={
isPromptOpen
? localize('com_ui_hide_image_details')
: localize('com_ui_show_image_details')
}
description={imageDetailsLabel}
render={
<Button
onClick={() => setIsPromptOpen(!isPromptOpen)}
variant="ghost"
className="h-10 w-10 p-0"
aria-label={imageDetailsLabel}
>
{isPromptOpen ? (
<PanelLeftOpen className="size-7 sm:size-6" />

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useMemo } from 'react';
import { Skeleton } from '@librechat/client';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import { apiBaseUrl } from 'librechat-data-provider';
import { cn, scaleImage } from '~/utils';
import DialogImage from './DialogImage';
@@ -36,6 +37,24 @@ const Image = ({
const handleImageLoad = () => setIsLoaded(true);
// Fix image path to include base path for subdirectory deployments
const absoluteImageUrl = useMemo(() => {
if (!imagePath) return imagePath;
// If it's already an absolute URL or doesn't start with /images/, return as is
if (
imagePath.startsWith('http') ||
imagePath.startsWith('data:') ||
!imagePath.startsWith('/images/')
) {
return imagePath;
}
// Get the base URL and prepend it to the image path
const baseURL = apiBaseUrl();
return `${baseURL}${imagePath}`;
}, [imagePath]);
const { width: scaledWidth, height: scaledHeight } = useMemo(
() =>
scaleImage({
@@ -48,7 +67,7 @@ const Image = ({
const downloadImage = async () => {
try {
const response = await fetch(imagePath);
const response = await fetch(absoluteImageUrl);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
@@ -67,7 +86,7 @@ const Image = ({
} catch (error) {
console.error('Download failed:', error);
const link = document.createElement('a');
link.href = imagePath;
link.href = absoluteImageUrl;
link.download = altText || 'image.png';
document.body.appendChild(link);
link.click();
@@ -97,7 +116,7 @@ const Image = ({
'opacity-100 transition-opacity duration-100',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
src={imagePath}
src={absoluteImageUrl}
style={{
width: `${scaledWidth}`,
height: 'auto',
@@ -117,7 +136,7 @@ const Image = ({
<DialogImage
isOpen={isOpen}
onOpenChange={setIsOpen}
src={imagePath}
src={absoluteImageUrl}
downloadImage={downloadImage}
args={args}
/>

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