Compare commits

..

41 Commits

Author SHA1 Message Date
Atef Bellaaj
526b1980bc 🔧 fix: Merge and Rebase Conflicts
- Move AgentCategory from api/models to @packages/data-schemas structure
  - Add schema, types, methods, and model following codebase conventions
  - Implement auto-seeding of default categories during AppService startup
  - Update marketplace controller to use new data-schemas methods
  - Remove old model file and standalone seed script

refactor: unify agent marketplace to single endpoint with cursor pagination

  - Replace multiple marketplace routes with unified /marketplace endpoint
  - Add query string controls: category, search, limit, cursor, promoted, requiredPermission
  - Implement cursor-based pagination replacing page-based system
  - Integrate ACL permissions for proper access control
  - Fix ObjectId constructor error in Agent model
  - Update React components to use unified useGetMarketplaceAgentsQuery hook
  - Enhance type safety and remove deprecated useDynamicAgentQuery
  - Update tests for new marketplace architecture
  -Known issues:
  see more button after category switching + Unit tests

feat: add icon property to ProcessedAgentCategory interface

- Add useMarketplaceAgentsInfiniteQuery and useGetAgentCategoriesQuery to client/src/data-provider/Agents/
  - Replace manual pagination in AgentGrid with infinite query pattern
  - Update imports to use local data provider instead of librechat-data-provider
  - Add proper permission handling with PERMISSION_BITS.VIEW/EDIT constants
  - Improve agent access control by adding requiredPermission validation in backend
  - Remove manual cursor/state management in favor of infinite query built-ins
  - Maintain existing search and category filtering functionality

refactor: consolidate agent marketplace endpoints into main agents API and improve data management consistency

  - Remove dedicated marketplace controller and routes, merging functionality into main agents v1 API
  - Add countPromotedAgents function to Agent model for promoted agents count
  - Enhance getListAgents handler with marketplace filtering (category, search, promoted status)
  - Move getAgentCategories from marketplace to v1 controller with same functionality
  - Update agent mutations to invalidate marketplace queries and handle multiple permission levels
  - Improve cache management by updating all agent query variants (VIEW/EDIT permissions)
  - Consolidate agent data access patterns for better maintainability and consistency
  - Remove duplicate marketplace route definitions and middleware

selected view only agents injected in the drop down

fix: remove minlength validation for support contact name in agent schema

feat: add validation and error messages for agent name in AgentConfig and AgentPanel

fix: update agent permission check logic in AgentPanel to simplify condition

Fix linting WIP

Fix Unit tests WIP

ESLint fixes

eslint fix

refactor: enhance isDuplicateVersion function in Agent model for improved comparison logic

- Introduced handling for undefined/null values in array and object comparisons.
- Normalized array comparisons to treat undefined/null as empty arrays.
- Added deep comparison for objects and improved handling of primitive values.
- Enhanced projectIds comparison to ensure consistent MongoDB ObjectId handling.

refactor: remove redundant properties from IAgent interface in agent schema

chore: update localization for agent detail component and clean up imports

ci: update access middleware tests

chore: remove unused PermissionTypes import from Role model

ci: update AclEntry model tests

ci: update button accessibility labels in AgentDetail tests

refactor: update exhaustive dep. lint warning
2025-06-28 12:37:59 -04:00
“Praneeth
f59ef0ecb0 🏪 feat: Agent Marketplace
bugfix: Enhance Agent and AgentCategory schemas with new fields for category, support contact, and promotion status

refactored and moved agent category methods and schema to data-schema package
2025-06-28 12:37:59 -04:00
Danny Avila
658480d7cd 🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
WIP: pre-granular-permissions commit

feat: Add category and support contact fields to Agent schema and UI components

Revert "feat: Add category and support contact fields to Agent schema and UI components"

This reverts commit c43a52b4c9.

Fix: Update import for renderHook in useAgentCategories.spec.tsx

fix: Update icon rendering in AgentCategoryDisplay tests to use empty spans

refactor: Improve category synchronization logic and clean up AgentConfig component

refactor: Remove unused UI flow translations from translation.json

feat: agent marketplace features

🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
2025-06-28 12:37:58 -04:00
matt burnett
3e1591d404 🤖 fix: Remove versions and __v when Duplicating an Agent (#8115)
Revert "Add tests for agent duplication controller"

This reverts commit 3e7beb1cc336bcfe1c57411e9c151f5e6aa927e4.
2025-06-28 12:35:41 -04:00
Danny Avila
1060ae8040 🐛 fix: Assistants Endpoint Handling in createPayload Function (#8123)
* 📦 chore: bump librechat-data-provider version to 0.7.89

* 🐛 fix: Assistants endpoint handling in createPayload function
2025-06-28 12:33:43 -04:00
Danny Avila
dd67e463e4 📦 chore: bump pbkdf2 to v3.1.3 (#8091) 2025-06-26 19:19:04 -04:00
github-actions[bot]
d60ad61325 🌍 i18n: Update translation.json with latest translations (#8058)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-26 19:12:46 -04:00
Danny Avila
452151e408 🐛 fix: RAG API failing with OPENID_REUSE_TOKENS Enabled (#8090)
* feat: Implement Short-Lived JWT Token Generation for RAG API

* fix: Update import paths

* fix: Correct environment variable names for OpenID on behalf flow

* fix: Remove unnecessary spaces in OpenID on behalf flow userinfo scope

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
2025-06-26 19:10:21 -04:00
Danny Avila
33b4a97b42 🔒 fix: Agents Config/Permission Checks after Streamline Change (#8089)
* refactor: access control logic to TypeScript

* chore: Change EndpointURLs to a constant object for improved type safety

* 🐛 fix: Enhance agent access control by adding skipAgentCheck functionality

* 🐛 fix: Add endpointFileConfig prop to AttachFileMenu and update file handling logic

* 🐛 fix: Update tool handling logic to support optional groupedTools and improve null checks, add dedicated tool dialog for Assistants

* chore: Export Accordion component from UI index for improved modularity

* feat: Add ActivePanelContext for managing active panel state across components

* chore: Replace string IDs with EModelEndpoint constants for assistants and agents in useSideNavLinks

* fix: Integrate access checks for agent creation and deletion routes in actions.js
2025-06-26 18:53:05 -04:00
Sebastien Bruel
9cdc62b655 📂 fix: Prevent Null Reference Errors in File Process (#8084) 2025-06-26 18:51:35 -04:00
Danny Avila
799f0e5810 🐛 fix: Move MemoryEntry and PluginAuth model retrieval inside methods for Runtime Usage 2025-06-25 20:58:34 -04:00
Danny Avila
cbda3cb529 🕐 feat: Configurable Retention Period for Temporary Chats (#8056)
* feat: Add configurable retention period for temporary chats

* Addressing eslint errors

* Fix: failing test due to missing registration

* Update: variable name and use hours instead of days for chat retention

* Addressing comments

* chore: fix import order in Conversation.js

* chore: import order in Message.js

* chore: fix import order in config.ts

* chore: move common methods to packages/api to reduce potential for circular dependencies

* refactor: update temp chat retention config type to Partial<TCustomConfig>

* refactor: remove unused config variable from AppService and update loadCustomConfig tests with logger mock

* refactor: handle model undefined edge case by moving Session model initialization inside methods

---------

Co-authored-by: Rakshit Tiwari <rak1729e@gmail.com>
2025-06-25 17:16:26 -04:00
Karol Potocki
3ab1bd65e5 🐛 fix: Support Bedrock Provider for MCP Image Content Rendering (#8047) 2025-06-25 15:38:24 -04:00
Marlon
c551ba21f5 📜 chore: Update .env.example (#8043)
Update recent Gemini model names and remove deprecated Gemini models from env.example
2025-06-25 15:31:24 -04:00
Danny Avila
c87422a1e0 🧠 feat: Thinking Budget, Include Thoughts, and Dynamic Thinking for Gemini 2.5 (#8055)
* feat: support thinking budget parameter for Gemini 2.5 series (#6949, #7542)

https://ai.google.dev/gemini-api/docs/thinking#set-budget

* refactor: update thinking budget minimum value to -1 for dynamic thinking

- see: https://ai.google.dev/gemini-api/docs/thinking#set-budget

* chore: bump @librechat/agents to v2.4.43

* refactor: rename LLMConfigOptions to OpenAIConfigOptions for clarity and consistency

- Updated type definitions and references in initialize.ts, llm.ts, and openai.ts to reflect the new naming convention.
- Ensured that the OpenAI configuration options are consistently used across the relevant files.

* refactor: port Google LLM methods to TypeScript Package

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

* refactor: update thinking budget description for clarity and adjust placeholder in parameter settings

* refactor: enhance googleSettings default value for thinking budget to support dynamic adjustment

* chore: update @librechat/agents to v2.4.44 for Vertex Dynamic Thinking workaround

* refactor: rename google config function, update `createRun` types, use `reasoning` as `reasoningKey` for Google

* refactor: simplify placeholder handling in DynamicInput component

* refactor: enhance thinking budget description for clarity and allow automatic decision by setting to "-1"

* refactor: update text styling in OptionHover component for improved readability

* chore: update @librechat/agents dependency to v2.4.46 in package.json and package-lock.json

* chore: update @librechat/api version to 1.2.5 in package.json and package-lock.json

* refactor: enhance `clientOptions` handling by filtering `omitTitleOptions`, add `json` field for Google models

---------

Co-authored-by: ciffelia <15273128+ciffelia@users.noreply.github.com>
2025-06-25 15:14:33 -04:00
Dustin Healy
b169306096 🧪 ci: Add Tests for Custom Endpoint Header Resolution (#8045)
* Enhanced existing tests for the `resolveHeaders` function to cover all user field placeholders and messy scenarios.
* Added basic integration tests for custom endpoints initialization file
2025-06-24 21:11:06 -04:00
Rakshit Tiwari
42977ac0d0 🖼️ feat: Add Optional Client-Side Image Resizing to Prevent Upload Errors (#7909)
* feat: Add optional client-side image resizing to prevent upload errors

* Addressing comments from author

* Addressing eslint errors

* Fixing the naming to clientresize from clientsideresize
2025-06-24 10:43:29 -04:00
Dustin Healy
d9a0fe03ed 🔧 fix: User Placeholders in Headers for Custom Endpoints (#8030)
* hotfix(custom-endpoints): fix user placeholder resolution in headers

* fix: import

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-06-24 08:21:14 -04:00
Danny Avila
d39b99971f 🧠 fix: Agent Title Config & Resource Handling (#8028)
* 🔧 fix: enhance client options handling in AgentClient and set default recursion limit

- Updated the recursion limit to default to 25 if not specified in agentsEConfig.
- Enhanced client options in AgentClient to include model parameters such as apiKey and anthropicApiUrl from agentModelParams.
- Updated requestOptions in the anthropic endpoint to use reverseProxyUrl as anthropicApiUrl.

* Enhance LLM configuration tests with edge case handling

* chore add return type annotation for getCustomEndpointConfig function

* fix: update modelOptions handling to use optional chaining and default to empty object in multiple endpoint initializations

* chore: update @librechat/agents to version 2.4.42

* refactor: streamline agent endpoint configuration and enhance client options handling for title generations

- Introduced a new `getProviderConfig` function to centralize provider configuration logic.
- Updated `AgentClient` to utilize the new provider configuration, improving clarity and maintainability.
- Removed redundant code related to endpoint initialization and model parameter handling.
- Enhanced error logging for missing endpoint configurations.

* fix: add abort handling for image generation and editing in OpenAIImageTools

* ci: enhance getLLMConfig tests to verify fetchOptions and dispatcher properties

* fix: use optional chaining for endpointOption properties in getOptions

* fix: increase title generation timeout from 25s to 45s, pass `endpointOption` to `getOptions`

* fix: update file filtering logic in getToolFilesByIds to ensure text field is properly checked

* fix: add error handling for empty OCR results in uploadMistralOCR and uploadAzureMistralOCR

* fix: enhance error handling in file upload to include 'No OCR result' message

* chore: update error messages in uploadMistralOCR and uploadAzureMistralOCR

* fix: enhance filtering logic in getToolFilesByIds to include context checks for OCR resources to only include files directly attached to agent

---------

Co-authored-by: Matt Burnett <matt.burnett@shopify.com>
2025-06-23 19:44:24 -04:00
Marco Beretta
1b7e044bf5 🤩 style: DialogImage, Update Stylesheet, and Improve Accessibility (#8014)
* 🔧 fix: Adjust typography and border styles for improved readability in markdown components

* 🔧 fix: Enhance code block styling in markdown for better visibility and consistency

* 🔧 fix: Adjust margins and line heights for improved readability in markdown elements

* 🔧 fix: Adjust spacing for horizontal rules in markdown for improved consistency

* 🔧 fix: Refactor DialogImage component for improved quality styling and layout consistency

* 🔧 fix: Enhance zoom and pan functionality in DialogImage component with improved controls and user experience

* 🔧 fix: Improve zoom and pan functionality in DialogImage component with enhanced controls and reset zoom feature
2025-06-23 14:30:15 -04:00
Danny Avila
5c947be455 fix: Minor Menu Issues (#8026)
* fix: Enable portal support in ExportAndShareMenu component

* fix: MCPSubMenu with focus loop and improved button click handling

* chore: remove "tools" header in toolsdropdown
2025-06-23 14:29:21 -04:00
Dustin Healy
2b2f7fe289 feat: Configurable MCP Dropdown Placeholder (#7988)
* new env  variable for mcp label

* 🔄 refactor: Update MCPSelect placeholderText to draw from interface section of librechat.yaml rather than .env

* 🧹 chore: extract mcpServers schema for better maintainability

* 🔄 refactor: Update MCPSelect and useMCPSelect to utilize TPlugin type for better type consistency

* 🔄 refactor: Pass placeholder from startupConfig to MCPSubMenu for improved localization

* 🔄 refactor: Integrate startupConfig into BadgeRowContext and related components for enhanced configuration management

---------

Co-authored-by: mwbrandao <mariana.brandao@nos.pt>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-06-23 13:21:01 -04:00
Danny Avila
a058963a9f 👤 feat: User Placeholder Variables for Custom Endpoint Headers (#7993)
* 🔧 refactor: move `processMCPEnv` from `librechat-data-provider` and move to `@librechat/api`

* 🔧 refactor: Update resolveHeaders import paths

* 🔧 refactor: Enhance resolveHeaders to support user and custom variables

- Updated resolveHeaders function to accept user and custom user variables for placeholder replacement.
- Modified header resolution in multiple client and controller files to utilize the enhanced resolveHeaders functionality.
- Added comprehensive tests for resolveHeaders to ensure correct processing of user and custom variables.

* 🔧 fix: Update user ID placeholder processing in env.ts

* 🔧 fix: Remove arguments passing this.user rather than req.user

- Updated multiple client and controller files to call resolveHeaders without the user parameter

* 🔧 refactor: Enhance processUserPlaceholders to be more readable / less nested

* 🔧 refactor: Update processUserPlaceholders to pass all tests in mpc.spec.ts and env.spec.ts

* chore: remove legacy ChatGPTClient

* chore: remove LLM initialization code

* chore: initial deprecation removal of `gptPlugins`

* chore: remove cohere-ai dependency from package.json and package-lock.json

* chore: update brace-expansion to version 2.0.2 and add license information

* chore: remove PluginsClient test file

* chore: remove legacy

* ci: remove deprecated sendMessage/getCompletion/chatCompletion tests

---------

Co-authored-by: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
2025-06-23 12:39:27 -04:00
Danny Avila
01e9b196bc 🤖 feat: Streamline Endpoints to Agent Framework (#8013)
* refactor(buildEndpointOption): Improve error logging in middleware, consolidate `isAgents` builder logic, remove adding `modelsConfig` to `endpointOption`

* refactor: parameter extraction and organization in agent services, minimize redundancy of shared fields across objects, make clear distinction of parameters processed uniquely by LibreChat vs LLM Provider Configs

* refactor(createPayload): streamline all endpoints to agent route

* fix: add `modelLabel` to response sender options for agent initialization

* chore: correct log message context in EditController abort controller cleanup

* chore: remove unused abortRequest hook

* chore: remove unused addToCache module and its dependencies

* refactor: remove AskController and related routes, update endpoint URLs (now all streamlined to agents route)

* chore: remove unused bedrock route and its related imports

* refactor: simplify response sender logic for Google endpoint

* chore: add `modelDisplayLabel` handling for agents endpoint

* feat: add file search capability to ephemeral agents, update code interpreter selection based of file upload, consolidate main upload menu for all endpoints

* feat: implement useToolToggle hook for managing tool toggle state, refactor CodeInterpreter and WebSearch components to utilize new hook

* feat: add ToolsDropdown component to BadgeRow for enhanced tool options

* feat: introduce BadgeRowContext and BadgeRowProvider for managing conversation state, refactor related components to utilize context

* feat: implement useMCPSelect hook for managing MCP selection state, refactor MCPSelect component to utilize new hook

* feat: enhance BadgeRowContext with MCPSelect and tool toggle functionality, refactor related components to utilize updated context and hooks

* refactor: streamline useToolToggle hook by integrating setEphemeralAgent directly into toggle logic and removing redundant setValue function

* refactor: consolidate codeApiKeyForm and searchApiKeyForm from CodeInterpreter and WebSearch to utilize new context properties

* refactor: update CheckboxButton to support controlled state and enhance ToolsDropdown with permission-based toggles for web search and code interpreter

* refactor: conditionally render CheckboxButton in CodeInterpreter and WebSearch components for improved UI responsiveness

* chore: add jotai dependency to package.json and package-lock.json

* chore: update brace-expansion package to version 2.0.2 in package-lock.json due to CVE-2025-5889

* Revert "chore: add jotai dependency to package.json and package-lock.json"

This reverts commit 69b6997396.

* refactor: add pinning functionality to CodeInterpreter and WebSearch components, and enhance ToolsDropdown with pin toggle for web search and code interpreter

* chore: move MCPIcon to correct location, remove duplicate

* fix: update MCP import to use type-only import from librechat-data-provider

* feat: implement MCPSubMenu component and integrate pinning functionality into ToolsDropdown

* fix: cycling to submenu by using parent menu context

* feat: add FileSearch component and integrate it into BadgeRow and ToolsDropdown

* chore: import order

* chore: remove agent specific logic that would block functionality for streamlined endpoints

* chore: linting for `createContextHandlers`

* chore: ensure ToolsDropdown doesn't show up for agents

* chore: ensure tool resource is selected when dragged to UI

* chore: update file search behavior to simulate legacy functionality

* feat: ToolDialogs with multiple trigger references, add settings to tool dropdown

* refactor: simplify web search and code interpreter settings checks

* chore: simplify local storage key for pinned state in useToolToggle

* refactor: reinstate agent check in AttachFileChat component, as individual providers will ahve different file configurations

* ci: increase timeout for MongoDB connection in Agent tests
2025-06-23 09:59:05 -04:00
Rakshit Tiwari
d835f48307 🗃️ feat: Support .h Files (#7978) 2025-06-21 10:50:30 -04:00
Danny Avila
0587a1cc7c 🔃 refactor: Index Sync in Background 2025-06-20 20:44:13 -04:00
Danny Avila
72cd159a37 🔃 refactor: Optimize MeiliSearch Sync Processing and Tracking (#7994) 2025-06-20 18:05:19 -04:00
Danny Avila
fa54c9ae90 📦 chore: Bump Agents Packages (#7992)
* chore: update peer dependency for @librechat/agents to version 2.4.41

* 🔧 chore: proxy handling in OpenAI endpoint to use undici

* 🔧 chore: update @anthropic-ai/sdk to version 0.52.0 and refactor proxy handling to use undici

* 🔧 chore: update globIgnores in vite.config.ts to exclude index.html from caching

* 🔧 ci: update proxy handling in getLLMConfig to use fetchOptions and ProxyAgent

* 🔧 chore: refactor proxy handling in Anthropic and OpenAI clients to use fetchOptions

* refactor: agent initialization to streamline model parameters and resendFiles handling

* chore: update @google/generative-ai to version 0.24.0
2025-06-20 15:49:24 -04:00
Danny Avila
97085073d2 🔧 fix: Potential Null Values for Custom Config 2025-06-19 22:54:49 -04:00
github-actions[bot]
299c484c7a 🌍 i18n: Update translation.json with latest translations (#7893)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-19 20:45:05 -04:00
Danny Avila
d53cd1f391 🔧 fix: Immutability Issue in useChatFunctions (#7983)
* fix: Use mutable version of conversation in useChatFunctions

* fix: Use cloneDeep for conversation in useChatFunctions to ensure immutability

---------

Co-authored-by: lucioperca <lucioperca@users.noreply.github.com>
2025-06-19 19:21:38 -04:00
Danny Avila
a5e8d009a1 🔧 fix: Logger Paths and Exclude index.html from Service Worker Caching (#7982) 2025-06-19 19:00:45 -04:00
Danny Avila
3e4b01de82 🗝️ feat: User Provided Credentials for MCP Servers (#7980)
* 🗝️ feat: Per-User Credentials for MCP Servers

chore: add aider to gitignore

feat: fill custom variables to MCP server

feat: replace placeholders with custom user MCP variables

feat: handle MCP install/uninstall (uses pluginauths)

feat: add MCP custom variables dialog to MCPSelect

feat: add MCP custom variables dialog to the side panel

feat: do not require to fill MCP credentials for in tools dialog

feat: add translations keys (en+cs) for custom MCP variables

fix: handle LIBRECHAT_USER_ID correctly during MCP var replacement

style: remove unused MCP translation keys

style: fix eslint for MCP custom vars

chore: move aider gitignore to AI section

* feat: Add Plugin Authentication Methods to data-schemas

* refactor: Replace PluginAuth model methods with new utility functions for improved code organization and maintainability

* refactor: Move IPluginAuth interface to types directory for better organization and update pluginAuth schema to use the new import

* refactor: Remove unused getUsersPluginsAuthValuesMap function and streamline PluginService.js; add new getPluginAuthMap function for improved plugin authentication handling

* chore: fix typing for optional tools property with GenericTool[] type

* chore: update librechat-data-provider version to 0.7.88

* refactor: optimize getUserMCPAuthMap function by reducing variable usage and improving server key collection logic

* refactor: streamline MCP tool creation by removing customUserVars parameter and enhancing user-specific authentication handling to avoid closure encapsulation

* refactor: extract processSingleValue function to streamline MCP environment variable processing and enhance readability

* refactor: enhance MCP tool processing logic by simplifying conditions and improving authentication handling for custom user variables

* ci: fix action tests

* chore: fix imports, remove comments

* chore: remove non-english translations

* fix: remove newline at end of translation.json file

---------

Co-authored-by: Aleš Kůtek <kutekales@gmail.com>
2025-06-19 18:27:55 -04:00
Dustin Healy
8b15bb2ed6 feat: Enhance Agent Panel with Tool Grouping (#7951)
*  feat: Enhance Agent Panel with Tool Grouping

* 🧰 feat: Added support for grouping tools in the Agent Panel, allowing for better organization and management of related tools.
* 💡 feat: Added hovercards for tools belonging to a group which display their tool descriptions when their help icon is hovered over.
* 🧹 chore: Updated the AgentPanelContext to include grouped tools and their metadata.
* 🔨 refactor: Refactored AgentConfig and AgentTool components to utilize the new tool structure and enhance rendering logic.
* 🔍 feat: Improved the ToolSelectDialog to filter and display tools based on user input, including searching for tools within a group, and limits viewport height to prevent overflowing vertically on smaller screens.

This update enhances the overall functionality and usability of the Agent Panel, making it easier for users to interact with tools.

* Potential fix for code scanning alert no. 6217: Disallow unused variables

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: Agent tool type mismatches

* fix: accessibility issues and mcp tool overflow issue

* fix: enhance keyboard accessibility and prevent event propagation in AgentTool

* chore: WIP types

* chore: address comments and fix accordian collapse bug

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-06-19 10:01:50 -04:00
Danny Avila
c7e4523d7c 🎯 refactor: LaTeX and Math Rendering (#7952)
* refactor: Markdown LaTeX processing

- Added micromark-extension-llm-math as a dependency in package.json and package-lock.json.
- Updated Vite configuration to alias micromark-extension-math.
- Modified Markdown components to use singleDollarTextMath: false for improved LaTeX rendering.
- Refactored latex utility functions to enhance LaTeX processing and escaping mechanisms.

* chore: linting of `EditTextPart`

* fix: handle key up to initiate edit of latest user message by adding id prop to Edit Message HoverButton

* chore: linting in Artifact component

* refactor: enhance LaTeX preprocessing functionality

- Updated `preprocessLaTeX` to improve handling of currency and LaTeX expressions.
- Introduced optimized regex patterns for better performance.
- Added support for escaping mhchem commands and handling code blocks.
- Enhanced tests for various LaTeX scenarios, including currency and special characters.
- Refactored existing tests to align with new preprocessing logic.

* chore: filter out false positives in unused packages workflow

- Added a grep command to exclude the micromark-extension-llm-math package from the list of unused dependencies in the GitHub Actions workflow.
2025-06-18 00:58:51 -04:00
Robin Anderson
dba0ec4320 🔧 chore: update pricing for OpenAI o3 (#7948)
`o3` is now 80% cheaper, at $2/Mt input and $8/Mt output.
https://openai.com/api/pricing/
2025-06-17 21:27:31 -04:00
Rakshit Tiwari
3c9357580e 🖼️ feat: Add support for HEIC image format (#7914)
* feat: Add HEIC image format support with client-side conversion - Add HEIC/HEIF mime types to supported image formats - Install heic-to library for client-side HEIC to JPEG conversion - Create heicConverter utility with detection and conversion functions - Integrate HEIC processing into file upload flow - Add error handling and localization for HEIC conversion failures - Maintain backward compatibility with existing image formats - Resolves #5570

* feat: Add UI feedback during HEIC conversion - Show file thumbnail

* Addressing eslint errors

* Addressing the vite bundler issue
2025-06-17 21:12:15 -04:00
Danny Avila
10c0d7d474 ⬇️ fix: Image Download Browser Compatibility (#7950)
* fix: Add null check for SelectedTTS in MessageAudio component to prevent rendering issues

* fix: image download browser compatibility with error handling and fallback mechanism

- Updated the downloadImage function to use fetch for improved reliability and added error handling.
- Implemented a fallback to the original download method in case of fetch failure.
- Ensured the download link uses a blob URL for better compatibility with various image types.
2025-06-17 21:11:39 -04:00
Danny Avila
ec7370dfe9 🪐 feat: MCP OAuth 2.0 Discovery Support (#7924)
* chore: Update @modelcontextprotocol/sdk to version 1.12.3 in package.json and package-lock.json

- Bump version of @modelcontextprotocol/sdk to 1.12.3 to incorporate recent updates.
- Update dependencies for ajv and cross-spawn to their latest versions.
- Add ajv as a new dependency in the sdk module.
- Include json-schema-traverse as a new dependency in the sdk module.

* feat: @librechat/auth

* feat: Add crypto module exports to auth package

- Introduced a new crypto module by creating index.ts in the crypto directory.
- Updated the main index.ts of the auth package to export from the new crypto module.

* feat: Update package dependencies and build scripts for auth package

- Added @librechat/auth as a dependency in package.json and package-lock.json.
- Updated build scripts to include the auth package in both frontend and bun build processes.
- Removed unused mongoose and openid-client dependencies from package-lock.json for cleaner dependency management.

* refactor: Migrate crypto utility functions to @librechat/auth

- Replaced local crypto utility imports with the new @librechat/auth package across multiple files.
- Removed the obsolete crypto.js file and its exports.
- Updated relevant services and models to utilize the new encryption and decryption methods from @librechat/auth.

* feat: Enhance OAuth token handling and update dependencies in auth package

* chore: Remove Token model and TokenService due to restructuring of OAuth handling

- Deleted the Token.js model and TokenService.js, which were responsible for managing OAuth tokens.
- This change is part of a broader refactor to streamline OAuth token management and improve code organization.

* refactor: imports from '@librechat/auth' to '@librechat/api' and add OAuth token handling functionality

* refactor: Simplify logger usage in MCP and FlowStateManager classes

* chore: fix imports

* feat: Add OAuth configuration schema to MCP with token exchange method support

* feat: FIRST PASS Implement MCP OAuth flow with token management and error handling

- Added a new route for handling OAuth callbacks and token retrieval.
- Integrated OAuth token storage and retrieval mechanisms.
- Enhanced MCP connection to support automatic OAuth flow initiation on 401 errors.
- Implemented dynamic client registration and metadata discovery for OAuth.
- Updated MCPManager to manage OAuth tokens and handle authentication requirements.
- Introduced comprehensive logging for OAuth processes and error handling.

* refactor: Update MCPConnection and MCPManager to utilize new URL handling

- Added a `url` property to MCPConnection for better URL management.
- Refactored MCPManager to use the new `url` property instead of a deprecated method for OAuth handling.
- Changed logging from info to debug level for flow manager and token methods initialization.
- Improved comments for clarity on existing tokens and OAuth event listener setup.

* refactor: Improve connection timeout error messages in MCPConnection and MCPManager and use initTimeout for connection

- Updated the connection timeout error messages to include the duration of the timeout.
- Introduced a configurable `connectTimeout` variable in both MCPConnection and MCPManager for better flexibility.

* chore: cleanup MCP OAuth Token exchange handling; fix: erroneous use of flowsCache and remove verbose logs

* refactor: Update MCPManager and MCPTokenStorage to use TokenMethods for token management

- Removed direct token storage handling in MCPManager and replaced it with TokenMethods for better abstraction.
- Refactored MCPTokenStorage methods to accept parameters for token operations, enhancing flexibility and readability.
- Improved logging messages related to token persistence and retrieval processes.

* refactor: Update MCP OAuth handling to use static methods and improve flow management

- Refactored MCPOAuthHandler to utilize static methods for initiating and completing OAuth flows, enhancing clarity and reducing instance dependencies.
- Updated MCPManager to pass flowManager explicitly to OAuth handling methods, improving flexibility in flow state management.
- Enhanced comments and logging for better understanding of OAuth processes and flow state retrieval.

* refactor: Integrate token methods into createMCPTool for enhanced token management

* refactor: Change logging from info to debug level in MCPOAuthHandler for improved log management

* chore: clean up logging

* feat: first pass, auth URL from MCP OAuth flow

* chore: Improve logging format for OAuth authentication URL display

* chore: cleanup mcp manager comments

* feat: add connection reconnection logic in MCPManager

* refactor: reorganize token storage handling in MCP

- Moved token storage logic from MCPManager to a new MCPTokenStorage class for better separation of concerns.
- Updated imports to reflect the new token storage structure.
- Enhanced methods for storing, retrieving, updating, and deleting OAuth tokens, improving overall token management.

* chore: update comment for SYSTEM_USER_ID in MCPManager for clarity

* feat: implement refresh token functionality in MCP

- Added refresh token handling in MCPManager to support token renewal for both app-level and user-specific connections.
- Introduced a refreshTokens function to facilitate token refresh logic.
- Enhanced MCPTokenStorage to manage client information and refresh token processes.
- Updated logging for better traceability during token operations.

* chore: cleanup @librechat/auth

* feat: implement MCP server initialization in a separate service

- Added a new service to handle the initialization of MCP servers, improving code organization and readability.
- Refactored the server startup logic to utilize the new initializeMCP function.
- Removed redundant MCP initialization code from the main server file.

* fix: don't log auth url for user connections

* feat: enhance OAuth flow with success and error handling components

- Updated OAuth callback routes to redirect to new success and error pages instead of sending status messages.
- Introduced `OAuthSuccess` and `OAuthError` components to provide user feedback during authentication.
- Added localization support for success and error messages in the translation files.
- Implemented countdown functionality in the success component for a better user experience.

* fix: refresh token handling for user connections, add missing URL and methods

- add standard enum for system user id and helper for determining app-lvel vs. user-level connections

* refactor: update token handling in MCPManager and MCPTokenStorage

* fix: improve error logging in OAuth authentication handler

* fix: concurrency issues for both login url emission and concurrency of oauth flows for shared flows (same user, same server, multiple calls for same server)

* fix: properly fail shared flows for concurrent server calls and prevent duplication of tokens

* chore: remove unused auth package directory from update configuration

* ci: fix mocks in samlStrategy tests

* ci: add mcpConfig to AppService test setup

* chore: remove obsolete MCP OAuth implementation documentation

* fix: update build script for API to use correct command

* chore: bump version of @librechat/api to 1.2.4

* fix: update abort signal handling in createMCPTool function

* fix: add optional clientInfo parameter to refreshTokensFunction metadata

* refactor: replace app.locals.availableTools with getCachedTools in multiple services and controllers for improved tool management

* fix: concurrent refresh token handling issue

* refactor: add signal parameter to getUserConnection method for improved abort handling

* chore: JSDoc typing for `loadEphemeralAgent`

* refactor: update isConnectionActive method to use destructured parameters for improved readability

* feat: implement caching for MCP tools to handle app-level disconnects for loading list of tools

* ci: fix agent test
2025-06-17 13:50:33 -04:00
Ruben Talstra
b412455e9d 🚮 feat: Enhance "Delete User" Script (#7899)
* 🔧 fix: Enhance user deletion script to allow deep deletion of related data

* 🔧 fix: Update user deletion script to confirm deep deletion of transaction history

* 🔧 fix: Refactor user deletion script to use graceful exit and ensure deep deletion of related data

* Update config/delete-user.js

is a good idea

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-15 15:08:31 -04:00
Danny Avila
3af2666890 🪐 refactor: Migrate Share Functionality to Type-Safe Methods (#7903)
* chore: Update import for isEnabled utility in convoAccess middleware

* refactor: Migrate Share functionality to new methods structure in `@librechat/data-schemas`

- Deleted the old Share.js model and moved its functionality to a new share.ts file within the data-schemas package.
- Updated imports across the codebase to reflect the new structure.
- Enhanced error handling and logging in shared link operations.
- Introduced TypeScript types for shared links and related operations to improve type safety and maintainability.

* chore: Update promptGroupSchema validation with typing

* fix: error handling and logging in createSharedLink

* fix: don't allow empty shared link or shared link without messages

* ci: add tests for shared link methods

* chore: Bump version of @librechat/data-schemas to 0.0.9 in package.json and package-lock.json

* chore: Add nanoid as peer dependency

- Introduced `nanoid` as a dependency in `package.json` and `package-lock.json`.
- Replaced UUID generation with `nanoid` for creating unique conversation and message IDs in share methods tests.
2025-06-14 11:24:30 -04:00
471 changed files with 40767 additions and 13518 deletions

View File

@@ -58,7 +58,7 @@ DEBUG_CONSOLE=false
# Endpoints #
#===================================================#
# ENDPOINTS=openAI,assistants,azureOpenAI,google,gptPlugins,anthropic
# ENDPOINTS=openAI,assistants,azureOpenAI,google,anthropic
PROXY=
@@ -142,10 +142,10 @@ GOOGLE_KEY=user_provided
# GOOGLE_AUTH_HEADER=true
# Gemini API (AI Studio)
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite-preview-06-17,gemini-2.0-flash,gemini-2.0-flash-lite
# Vertex AI
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite-preview-06-17,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
@@ -453,8 +453,8 @@ OPENID_REUSE_TOKENS=
OPENID_JWKS_URL_CACHE_ENABLED=
OPENID_JWKS_URL_CACHE_TIME= # 600000 ms eq to 10 minutes leave empty to disable caching
#Set to true to trigger token exchange flow to acquire access token for the userinfo endpoint.
OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED=
OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE = "user.read" # example for Scope Needed for Microsoft Graph API
OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED=
OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API
# Set to true to use the OpenID Connect end session endpoint for logout
OPENID_USE_END_SESSION_ENDPOINT=
@@ -485,6 +485,21 @@ SAML_IMAGE_URL=
# SAML_USE_AUTHN_RESPONSE_SIGNED=
#===============================================#
# Microsoft Graph API / Entra ID Integration #
#===============================================#
# Enable Entra ID people search integration in permissions/sharing system
# When enabled, the people picker will search both local database and Entra ID
USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false
# When enabled, entra id groups owners will be considered as members of the group
ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false
# Microsoft Graph API scopes needed for people/group search
# Default scopes provide access to user profiles and group memberships
OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All
# LDAP
LDAP_URL=
LDAP_BIND_DN=
@@ -657,4 +672,4 @@ OPENWEATHER_API_KEY=
# Reranker (Required)
# JINA_API_KEY=your_jina_api_key
# or
# COHERE_API_KEY=your_cohere_api_key
# COHERE_API_KEY=your_cohere_api_key

View File

@@ -98,6 +98,8 @@ jobs:
cd client
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt | sort) || echo "")
# Filter out false positives
UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
echo "CLIENT_UNUSED<<EOF" >> $GITHUB_ENV
echo "$UNUSED" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV

1
.gitignore vendored
View File

@@ -55,6 +55,7 @@ bower_components/
# AI
.clineignore
.cursor
.aider*
# Floobits
.floo

3
.vscode/launch.json vendored
View File

@@ -8,7 +8,8 @@
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/api/server/index.js",
"env": {
"NODE_ENV": "production"
"NODE_ENV": "production",
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
},
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env"

View File

@@ -190,10 +190,11 @@ class AnthropicClient extends BaseClient {
reverseProxyUrl: this.options.reverseProxyUrl,
}),
apiKey: this.apiKey,
fetchOptions: {},
};
if (this.options.proxy) {
options.httpAgent = new HttpsProxyAgent(this.options.proxy);
options.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy);
}
if (this.options.reverseProxyUrl) {

View File

@@ -792,7 +792,8 @@ class BaseClient {
userMessage.tokenCount = userMessageTokenCount;
/*
Note: `AskController` saves the user message, so we update the count of its `userMessage` reference
Note: `AgentController` saves the user message if not saved here
(noted by `savedMessageIds`), so we update the count of its `userMessage` reference
*/
if (typeof opts?.getReqData === 'function') {
opts.getReqData({
@@ -801,7 +802,8 @@ class BaseClient {
}
/*
Note: we update the user message to be sure it gets the calculated token count;
though `AskController` saves the user message, EditController does not
though `AgentController` saves the user message if not saved here
(noted by `savedMessageIds`), EditController does not
*/
await userMessagePromise;
await this.updateMessageInDatabase({

View File

@@ -1,804 +0,0 @@
const { Keyv } = require('keyv');
const crypto = require('crypto');
const { CohereClient } = require('cohere-ai');
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source');
const { constructAzureURL, genAzureChatCompletion } = require('@librechat/api');
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
const {
ImageDetail,
EModelEndpoint,
resolveHeaders,
CohereConstants,
mapModelToAzureConfig,
} = require('librechat-data-provider');
const { createContextHandlers } = require('./prompts');
const { createCoherePayload } = require('./llm');
const { extractBaseURL } = require('~/utils');
const BaseClient = require('./BaseClient');
const { logger } = require('~/config');
const CHATGPT_MODEL = 'gpt-3.5-turbo';
const tokenizersCache = {};
class ChatGPTClient extends BaseClient {
constructor(apiKey, options = {}, cacheOptions = {}) {
super(apiKey, options, cacheOptions);
cacheOptions.namespace = cacheOptions.namespace || 'chatgpt';
this.conversationsCache = new Keyv(cacheOptions);
this.setOptions(options);
}
setOptions(options) {
if (this.options && !this.options.replaceOptions) {
// nested options aren't spread properly, so we need to do this manually
this.options.modelOptions = {
...this.options.modelOptions,
...options.modelOptions,
};
delete options.modelOptions;
// now we can merge options
this.options = {
...this.options,
...options,
};
} else {
this.options = options;
}
if (this.options.openaiApiKey) {
this.apiKey = this.options.openaiApiKey;
}
const modelOptions = this.options.modelOptions || {};
this.modelOptions = {
...modelOptions,
// set some good defaults (check for undefined in some cases because they may be 0)
model: modelOptions.model || CHATGPT_MODEL,
temperature: typeof modelOptions.temperature === 'undefined' ? 0.8 : modelOptions.temperature,
top_p: typeof modelOptions.top_p === 'undefined' ? 1 : modelOptions.top_p,
presence_penalty:
typeof modelOptions.presence_penalty === 'undefined' ? 1 : modelOptions.presence_penalty,
stop: modelOptions.stop,
};
this.isChatGptModel = this.modelOptions.model.includes('gpt-');
const { isChatGptModel } = this;
this.isUnofficialChatGptModel =
this.modelOptions.model.startsWith('text-chat') ||
this.modelOptions.model.startsWith('text-davinci-002-render');
const { isUnofficialChatGptModel } = this;
// Davinci models have a max context length of 4097 tokens.
this.maxContextTokens = this.options.maxContextTokens || (isChatGptModel ? 4095 : 4097);
// I decided to reserve 1024 tokens for the response.
// The max prompt tokens is determined by the max context tokens minus the max response tokens.
// Earlier messages will be dropped until the prompt is within the limit.
this.maxResponseTokens = this.modelOptions.max_tokens || 1024;
this.maxPromptTokens =
this.options.maxPromptTokens || this.maxContextTokens - this.maxResponseTokens;
if (this.maxPromptTokens + this.maxResponseTokens > this.maxContextTokens) {
throw new Error(
`maxPromptTokens + max_tokens (${this.maxPromptTokens} + ${this.maxResponseTokens} = ${
this.maxPromptTokens + this.maxResponseTokens
}) must be less than or equal to maxContextTokens (${this.maxContextTokens})`,
);
}
this.userLabel = this.options.userLabel || 'User';
this.chatGptLabel = this.options.chatGptLabel || 'ChatGPT';
if (isChatGptModel) {
// Use these faux tokens to help the AI understand the context since we are building the chat log ourselves.
// Trying to use "<|im_start|>" causes the AI to still generate "<" or "<|" at the end sometimes for some reason,
// without tripping the stop sequences, so I'm using "||>" instead.
this.startToken = '||>';
this.endToken = '';
this.gptEncoder = this.constructor.getTokenizer('cl100k_base');
} else if (isUnofficialChatGptModel) {
this.startToken = '<|im_start|>';
this.endToken = '<|im_end|>';
this.gptEncoder = this.constructor.getTokenizer('text-davinci-003', true, {
'<|im_start|>': 100264,
'<|im_end|>': 100265,
});
} else {
// Previously I was trying to use "<|endoftext|>" but there seems to be some bug with OpenAI's token counting
// system that causes only the first "<|endoftext|>" to be counted as 1 token, and the rest are not treated
// as a single token. So we're using this instead.
this.startToken = '||>';
this.endToken = '';
try {
this.gptEncoder = this.constructor.getTokenizer(this.modelOptions.model, true);
} catch {
this.gptEncoder = this.constructor.getTokenizer('text-davinci-003', true);
}
}
if (!this.modelOptions.stop) {
const stopTokens = [this.startToken];
if (this.endToken && this.endToken !== this.startToken) {
stopTokens.push(this.endToken);
}
stopTokens.push(`\n${this.userLabel}:`);
stopTokens.push('<|diff_marker|>');
// I chose not to do one for `chatGptLabel` because I've never seen it happen
this.modelOptions.stop = stopTokens;
}
if (this.options.reverseProxyUrl) {
this.completionsUrl = this.options.reverseProxyUrl;
} else if (isChatGptModel) {
this.completionsUrl = 'https://api.openai.com/v1/chat/completions';
} else {
this.completionsUrl = 'https://api.openai.com/v1/completions';
}
return this;
}
static getTokenizer(encoding, isModelName = false, extendSpecialTokens = {}) {
if (tokenizersCache[encoding]) {
return tokenizersCache[encoding];
}
let tokenizer;
if (isModelName) {
tokenizer = encodingForModel(encoding, extendSpecialTokens);
} else {
tokenizer = getEncoding(encoding, extendSpecialTokens);
}
tokenizersCache[encoding] = tokenizer;
return tokenizer;
}
/** @type {getCompletion} */
async getCompletion(input, onProgress, onTokenProgress, abortController = null) {
if (!abortController) {
abortController = new AbortController();
}
let modelOptions = { ...this.modelOptions };
if (typeof onProgress === 'function') {
modelOptions.stream = true;
}
if (this.isChatGptModel) {
modelOptions.messages = input;
} else {
modelOptions.prompt = input;
}
if (this.useOpenRouter && modelOptions.prompt) {
delete modelOptions.stop;
}
const { debug } = this.options;
let baseURL = this.completionsUrl;
if (debug) {
console.debug();
console.debug(baseURL);
console.debug(modelOptions);
console.debug();
}
const opts = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
};
if (this.isVisionModel) {
modelOptions.max_tokens = 4000;
}
/** @type {TAzureConfig | undefined} */
const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI];
const isAzure = this.azure || this.options.azure;
if (
(isAzure && this.isVisionModel && azureConfig) ||
(azureConfig && this.isVisionModel && this.options.endpoint === EModelEndpoint.azureOpenAI)
) {
const { modelGroupMap, groupMap } = azureConfig;
const {
azureOptions,
baseURL,
headers = {},
serverless,
} = mapModelToAzureConfig({
modelName: modelOptions.model,
modelGroupMap,
groupMap,
});
opts.headers = resolveHeaders(headers);
this.langchainProxy = extractBaseURL(baseURL);
this.apiKey = azureOptions.azureOpenAIApiKey;
const groupName = modelGroupMap[modelOptions.model].group;
this.options.addParams = azureConfig.groupMap[groupName].addParams;
this.options.dropParams = azureConfig.groupMap[groupName].dropParams;
// Note: `forcePrompt` not re-assigned as only chat models are vision models
this.azure = !serverless && azureOptions;
this.azureEndpoint =
!serverless && genAzureChatCompletion(this.azure, modelOptions.model, this);
if (serverless === true) {
this.options.defaultQuery = azureOptions.azureOpenAIApiVersion
? { 'api-version': azureOptions.azureOpenAIApiVersion }
: undefined;
this.options.headers['api-key'] = this.apiKey;
}
}
if (this.options.defaultQuery) {
opts.defaultQuery = this.options.defaultQuery;
}
if (this.options.headers) {
opts.headers = { ...opts.headers, ...this.options.headers };
}
if (isAzure) {
// Azure does not accept `model` in the body, so we need to remove it.
delete modelOptions.model;
baseURL = this.langchainProxy
? constructAzureURL({
baseURL: this.langchainProxy,
azureOptions: this.azure,
})
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
if (this.options.forcePrompt) {
baseURL += '/completions';
} else {
baseURL += '/chat/completions';
}
opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion };
opts.headers = { ...opts.headers, 'api-key': this.apiKey };
} else if (this.apiKey) {
opts.headers.Authorization = `Bearer ${this.apiKey}`;
}
if (process.env.OPENAI_ORGANIZATION) {
opts.headers['OpenAI-Organization'] = process.env.OPENAI_ORGANIZATION;
}
if (this.useOpenRouter) {
opts.headers['HTTP-Referer'] = 'https://librechat.ai';
opts.headers['X-Title'] = 'LibreChat';
}
/* hacky fixes for Mistral AI API:
- Re-orders system message to the top of the messages payload, as not allowed anywhere else
- If there is only one message and it's a system message, change the role to user
*/
if (baseURL.includes('https://api.mistral.ai/v1') && modelOptions.messages) {
const { messages } = modelOptions;
const systemMessageIndex = messages.findIndex((msg) => msg.role === 'system');
if (systemMessageIndex > 0) {
const [systemMessage] = messages.splice(systemMessageIndex, 1);
messages.unshift(systemMessage);
}
modelOptions.messages = messages;
if (messages.length === 1 && messages[0].role === 'system') {
modelOptions.messages[0].role = 'user';
}
}
if (this.options.addParams && typeof this.options.addParams === 'object') {
modelOptions = {
...modelOptions,
...this.options.addParams,
};
logger.debug('[ChatGPTClient] chatCompletion: added params', {
addParams: this.options.addParams,
modelOptions,
});
}
if (this.options.dropParams && Array.isArray(this.options.dropParams)) {
this.options.dropParams.forEach((param) => {
delete modelOptions[param];
});
logger.debug('[ChatGPTClient] chatCompletion: dropped params', {
dropParams: this.options.dropParams,
modelOptions,
});
}
if (baseURL.startsWith(CohereConstants.API_URL)) {
const payload = createCoherePayload({ modelOptions });
return await this.cohereChatCompletion({ payload, onTokenProgress });
}
if (baseURL.includes('v1') && !baseURL.includes('/completions') && !this.isChatCompletion) {
baseURL = baseURL.split('v1')[0] + 'v1/completions';
} else if (
baseURL.includes('v1') &&
!baseURL.includes('/chat/completions') &&
this.isChatCompletion
) {
baseURL = baseURL.split('v1')[0] + 'v1/chat/completions';
}
const BASE_URL = new URL(baseURL);
if (opts.defaultQuery) {
Object.entries(opts.defaultQuery).forEach(([key, value]) => {
BASE_URL.searchParams.append(key, value);
});
delete opts.defaultQuery;
}
const completionsURL = BASE_URL.toString();
opts.body = JSON.stringify(modelOptions);
if (modelOptions.stream) {
return new Promise(async (resolve, reject) => {
try {
let done = false;
await fetchEventSource(completionsURL, {
...opts,
signal: abortController.signal,
async onopen(response) {
if (response.status === 200) {
return;
}
if (debug) {
console.debug(response);
}
let error;
try {
const body = await response.text();
error = new Error(`Failed to send message. HTTP ${response.status} - ${body}`);
error.status = response.status;
error.json = JSON.parse(body);
} catch {
error = error || new Error(`Failed to send message. HTTP ${response.status}`);
}
throw error;
},
onclose() {
if (debug) {
console.debug('Server closed the connection unexpectedly, returning...');
}
// workaround for private API not sending [DONE] event
if (!done) {
onProgress('[DONE]');
resolve();
}
},
onerror(err) {
if (debug) {
console.debug(err);
}
// rethrow to stop the operation
throw err;
},
onmessage(message) {
if (debug) {
console.debug(message);
}
if (!message.data || message.event === 'ping') {
return;
}
if (message.data === '[DONE]') {
onProgress('[DONE]');
resolve();
done = true;
return;
}
onProgress(JSON.parse(message.data));
},
});
} catch (err) {
reject(err);
}
});
}
const response = await fetch(completionsURL, {
...opts,
signal: abortController.signal,
});
if (response.status !== 200) {
const body = await response.text();
const error = new Error(`Failed to send message. HTTP ${response.status} - ${body}`);
error.status = response.status;
try {
error.json = JSON.parse(body);
} catch {
error.body = body;
}
throw error;
}
return response.json();
}
/** @type {cohereChatCompletion} */
async cohereChatCompletion({ payload, onTokenProgress }) {
const cohere = new CohereClient({
token: this.apiKey,
environment: this.completionsUrl,
});
if (!payload.stream) {
const chatResponse = await cohere.chat(payload);
return chatResponse.text;
}
const chatStream = await cohere.chatStream(payload);
let reply = '';
for await (const message of chatStream) {
if (!message) {
continue;
}
if (message.eventType === 'text-generation' && message.text) {
onTokenProgress(message.text);
reply += message.text;
}
/*
Cohere API Chinese Unicode character replacement hotfix.
Should be un-commented when the following issue is resolved:
https://github.com/cohere-ai/cohere-typescript/issues/151
else if (message.eventType === 'stream-end' && message.response) {
reply = message.response.text;
}
*/
}
return reply;
}
async generateTitle(userMessage, botMessage) {
const instructionsPayload = {
role: 'system',
content: `Write an extremely concise subtitle for this conversation with no more than a few words. All words should be capitalized. Exclude punctuation.
||>Message:
${userMessage.message}
||>Response:
${botMessage.message}
||>Title:`,
};
const titleGenClientOptions = JSON.parse(JSON.stringify(this.options));
titleGenClientOptions.modelOptions = {
model: 'gpt-3.5-turbo',
temperature: 0,
presence_penalty: 0,
frequency_penalty: 0,
};
const titleGenClient = new ChatGPTClient(this.apiKey, titleGenClientOptions);
const result = await titleGenClient.getCompletion([instructionsPayload], null);
// remove any non-alphanumeric characters, replace multiple spaces with 1, and then trim
return result.choices[0].message.content
.replace(/[^a-zA-Z0-9' ]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
async sendMessage(message, opts = {}) {
if (opts.clientOptions && typeof opts.clientOptions === 'object') {
this.setOptions(opts.clientOptions);
}
const conversationId = opts.conversationId || crypto.randomUUID();
const parentMessageId = opts.parentMessageId || crypto.randomUUID();
let conversation =
typeof opts.conversation === 'object'
? opts.conversation
: await this.conversationsCache.get(conversationId);
let isNewConversation = false;
if (!conversation) {
conversation = {
messages: [],
createdAt: Date.now(),
};
isNewConversation = true;
}
const shouldGenerateTitle = opts.shouldGenerateTitle && isNewConversation;
const userMessage = {
id: crypto.randomUUID(),
parentMessageId,
role: 'User',
message,
};
conversation.messages.push(userMessage);
// Doing it this way instead of having each message be a separate element in the array seems to be more reliable,
// especially when it comes to keeping the AI in character. It also seems to improve coherency and context retention.
const { prompt: payload, context } = await this.buildPrompt(
conversation.messages,
userMessage.id,
{
isChatGptModel: this.isChatGptModel,
promptPrefix: opts.promptPrefix,
},
);
if (this.options.keepNecessaryMessagesOnly) {
conversation.messages = context;
}
let reply = '';
let result = null;
if (typeof opts.onProgress === 'function') {
await this.getCompletion(
payload,
(progressMessage) => {
if (progressMessage === '[DONE]') {
return;
}
const token = this.isChatGptModel
? progressMessage.choices[0].delta.content
: progressMessage.choices[0].text;
// first event's delta content is always undefined
if (!token) {
return;
}
if (this.options.debug) {
console.debug(token);
}
if (token === this.endToken) {
return;
}
opts.onProgress(token);
reply += token;
},
opts.abortController || new AbortController(),
);
} else {
result = await this.getCompletion(
payload,
null,
opts.abortController || new AbortController(),
);
if (this.options.debug) {
console.debug(JSON.stringify(result));
}
if (this.isChatGptModel) {
reply = result.choices[0].message.content;
} else {
reply = result.choices[0].text.replace(this.endToken, '');
}
}
// avoids some rendering issues when using the CLI app
if (this.options.debug) {
console.debug();
}
reply = reply.trim();
const replyMessage = {
id: crypto.randomUUID(),
parentMessageId: userMessage.id,
role: 'ChatGPT',
message: reply,
};
conversation.messages.push(replyMessage);
const returnData = {
response: replyMessage.message,
conversationId,
parentMessageId: replyMessage.parentMessageId,
messageId: replyMessage.id,
details: result || {},
};
if (shouldGenerateTitle) {
conversation.title = await this.generateTitle(userMessage, replyMessage);
returnData.title = conversation.title;
}
await this.conversationsCache.set(conversationId, conversation);
if (this.options.returnConversation) {
returnData.conversation = conversation;
}
return returnData;
}
async buildPrompt(messages, { isChatGptModel = false, promptPrefix = null }) {
promptPrefix = (promptPrefix || this.options.promptPrefix || '').trim();
// Handle attachments and create augmentedPrompt
if (this.options.attachments) {
const attachments = await this.options.attachments;
const lastMessage = messages[messages.length - 1];
if (this.message_file_map) {
this.message_file_map[lastMessage.messageId] = attachments;
} else {
this.message_file_map = {
[lastMessage.messageId]: attachments,
};
}
const files = await this.addImageURLs(lastMessage, attachments);
this.options.attachments = files;
this.contextHandlers = createContextHandlers(this.options.req, lastMessage.text);
}
if (this.message_file_map) {
this.contextHandlers = createContextHandlers(
this.options.req,
messages[messages.length - 1].text,
);
}
// Calculate image token cost and process embedded files
messages.forEach((message, i) => {
if (this.message_file_map && this.message_file_map[message.messageId]) {
const attachments = this.message_file_map[message.messageId];
for (const file of attachments) {
if (file.embedded) {
this.contextHandlers?.processFile(file);
continue;
}
messages[i].tokenCount =
(messages[i].tokenCount || 0) +
this.calculateImageTokenCost({
width: file.width,
height: file.height,
detail: this.options.imageDetail ?? ImageDetail.auto,
});
}
}
});
if (this.contextHandlers) {
this.augmentedPrompt = await this.contextHandlers.createContext();
promptPrefix = this.augmentedPrompt + promptPrefix;
}
if (promptPrefix) {
// If the prompt prefix doesn't end with the end token, add it.
if (!promptPrefix.endsWith(`${this.endToken}`)) {
promptPrefix = `${promptPrefix.trim()}${this.endToken}\n\n`;
}
promptPrefix = `${this.startToken}Instructions:\n${promptPrefix}`;
}
const promptSuffix = `${this.startToken}${this.chatGptLabel}:\n`; // Prompt ChatGPT to respond.
const instructionsPayload = {
role: 'system',
content: promptPrefix,
};
const messagePayload = {
role: 'system',
content: promptSuffix,
};
let currentTokenCount;
if (isChatGptModel) {
currentTokenCount =
this.getTokenCountForMessage(instructionsPayload) +
this.getTokenCountForMessage(messagePayload);
} else {
currentTokenCount = this.getTokenCount(`${promptPrefix}${promptSuffix}`);
}
let promptBody = '';
const maxTokenCount = this.maxPromptTokens;
const context = [];
// Iterate backwards through the messages, adding them to the prompt until we reach the max token count.
// Do this within a recursive async function so that it doesn't block the event loop for too long.
const buildPromptBody = async () => {
if (currentTokenCount < maxTokenCount && messages.length > 0) {
const message = messages.pop();
const roleLabel =
message?.isCreatedByUser || message?.role?.toLowerCase() === 'user'
? this.userLabel
: this.chatGptLabel;
const messageString = `${this.startToken}${roleLabel}:\n${
message?.text ?? message?.message
}${this.endToken}\n`;
let newPromptBody;
if (promptBody || isChatGptModel) {
newPromptBody = `${messageString}${promptBody}`;
} else {
// Always insert prompt prefix before the last user message, if not gpt-3.5-turbo.
// This makes the AI obey the prompt instructions better, which is important for custom instructions.
// After a bunch of testing, it doesn't seem to cause the AI any confusion, even if you ask it things
// like "what's the last thing I wrote?".
newPromptBody = `${promptPrefix}${messageString}${promptBody}`;
}
context.unshift(message);
const tokenCountForMessage = this.getTokenCount(messageString);
const newTokenCount = currentTokenCount + tokenCountForMessage;
if (newTokenCount > maxTokenCount) {
if (promptBody) {
// This message would put us over the token limit, so don't add it.
return false;
}
// This is the first message, so we can't add it. Just throw an error.
throw new Error(
`Prompt is too long. Max token count is ${maxTokenCount}, but prompt is ${newTokenCount} tokens long.`,
);
}
promptBody = newPromptBody;
currentTokenCount = newTokenCount;
// wait for next tick to avoid blocking the event loop
await new Promise((resolve) => setImmediate(resolve));
return buildPromptBody();
}
return true;
};
await buildPromptBody();
const prompt = `${promptBody}${promptSuffix}`;
if (isChatGptModel) {
messagePayload.content = prompt;
// Add 3 tokens for Assistant Label priming after all messages have been counted.
currentTokenCount += 3;
}
// Use up to `this.maxContextTokens` tokens (prompt + response), but try to leave `this.maxTokens` tokens for the response.
this.modelOptions.max_tokens = Math.min(
this.maxContextTokens - currentTokenCount,
this.maxResponseTokens,
);
if (isChatGptModel) {
return { prompt: [instructionsPayload, messagePayload], context };
}
return { prompt, context, promptTokens: currentTokenCount };
}
getTokenCount(text) {
return this.gptEncoder.encode(text, 'all').length;
}
/**
* Algorithm adapted from "6. Counting tokens for chat API calls" of
* https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
*
* An additional 3 tokens need to be added for assistant label priming after all messages have been counted.
*
* @param {Object} message
*/
getTokenCountForMessage(message) {
// Note: gpt-3.5-turbo and gpt-4 may update over time. Use default for these as well as for unknown models
let tokensPerMessage = 3;
let tokensPerName = 1;
if (this.modelOptions.model === 'gpt-3.5-turbo-0301') {
tokensPerMessage = 4;
tokensPerName = -1;
}
let numTokens = tokensPerMessage;
for (let [key, value] of Object.entries(message)) {
numTokens += this.getTokenCount(value);
if (key === 'name') {
numTokens += tokensPerName;
}
}
return numTokens;
}
}
module.exports = ChatGPTClient;

View File

@@ -1,7 +1,7 @@
const { google } = require('googleapis');
const { Tokenizer } = require('@librechat/api');
const { concat } = require('@langchain/core/utils/stream');
const { ChatVertexAI } = require('@langchain/google-vertexai');
const { Tokenizer, getSafetySettings } = require('@librechat/api');
const { ChatGoogleGenerativeAI } = require('@langchain/google-genai');
const { GoogleGenerativeAI: GenAI } = require('@google/generative-ai');
const { HumanMessage, SystemMessage } = require('@langchain/core/messages');
@@ -12,13 +12,13 @@ const {
endpointSettings,
parseTextParts,
EModelEndpoint,
googleSettings,
ContentTypes,
VisionModes,
ErrorTypes,
Constants,
AuthKeys,
} = require('librechat-data-provider');
const { getSafetySettings } = require('~/server/services/Endpoints/google/llm');
const { encodeAndFormat } = require('~/server/services/Files/images');
const { spendTokens } = require('~/models/spendTokens');
const { getModelMaxTokens } = require('~/utils');
@@ -166,6 +166,16 @@ class GoogleClient extends BaseClient {
);
}
// Add thinking configuration
this.modelOptions.thinkingConfig = {
thinkingBudget:
(this.modelOptions.thinking ?? googleSettings.thinking.default)
? this.modelOptions.thinkingBudget
: 0,
};
delete this.modelOptions.thinking;
delete this.modelOptions.thinkingBudget;
this.sender =
this.options.sender ??
getResponseSender({

View File

@@ -5,6 +5,7 @@ const {
isEnabled,
Tokenizer,
createFetch,
resolveHeaders,
constructAzureURL,
genAzureChatCompletion,
createStreamEventHandlers,
@@ -15,7 +16,6 @@ const {
ContentTypes,
parseTextParts,
EModelEndpoint,
resolveHeaders,
KnownEndpoints,
openAISettings,
ImageDetailCost,
@@ -37,7 +37,6 @@ const { addSpaceIfNeeded, sleep } = require('~/server/utils');
const { spendTokens } = require('~/models/spendTokens');
const { handleOpenAIErrors } = require('./tools/util');
const { createLLM, RunManager } = require('./llm');
const ChatGPTClient = require('./ChatGPTClient');
const { summaryBuffer } = require('./memory');
const { runTitleChain } = require('./chains');
const { tokenSplit } = require('./document');
@@ -47,12 +46,6 @@ const { logger } = require('~/config');
class OpenAIClient extends BaseClient {
constructor(apiKey, options = {}) {
super(apiKey, options);
this.ChatGPTClient = new ChatGPTClient();
this.buildPrompt = this.ChatGPTClient.buildPrompt.bind(this);
/** @type {getCompletion} */
this.getCompletion = this.ChatGPTClient.getCompletion.bind(this);
/** @type {cohereChatCompletion} */
this.cohereChatCompletion = this.ChatGPTClient.cohereChatCompletion.bind(this);
this.contextStrategy = options.contextStrategy
? options.contextStrategy.toLowerCase()
: 'discard';
@@ -379,23 +372,12 @@ class OpenAIClient extends BaseClient {
return files;
}
async buildMessages(
messages,
parentMessageId,
{ isChatCompletion = false, promptPrefix = null },
opts,
) {
async buildMessages(messages, parentMessageId, { promptPrefix = null }, opts) {
let orderedMessages = this.constructor.getMessagesForConversation({
messages,
parentMessageId,
summary: this.shouldSummarize,
});
if (!isChatCompletion) {
return await this.buildPrompt(orderedMessages, {
isChatGptModel: isChatCompletion,
promptPrefix,
});
}
let payload;
let instructions;
@@ -1159,6 +1141,7 @@ ${convo}
logger.debug('[OpenAIClient] chatCompletion', { baseURL, modelOptions });
const opts = {
baseURL,
fetchOptions: {},
};
if (this.useOpenRouter) {
@@ -1177,7 +1160,7 @@ ${convo}
}
if (this.options.proxy) {
opts.httpAgent = new HttpsProxyAgent(this.options.proxy);
opts.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy);
}
/** @type {TAzureConfig | undefined} */
@@ -1395,7 +1378,7 @@ ${convo}
...modelOptions,
stream: true,
};
const stream = await openai.beta.chat.completions
const stream = await openai.chat.completions
.stream(params)
.on('abort', () => {
/* Do nothing here */

View File

@@ -1,542 +0,0 @@
const OpenAIClient = require('./OpenAIClient');
const { CallbackManager } = require('@langchain/core/callbacks/manager');
const { BufferMemory, ChatMessageHistory } = require('langchain/memory');
const { addImages, buildErrorInput, buildPromptPrefix } = require('./output_parsers');
const { initializeCustomAgent, initializeFunctionsAgent } = require('./agents');
const { processFileURL } = require('~/server/services/Files/process');
const { EModelEndpoint } = require('librechat-data-provider');
const { checkBalance } = require('~/models/balanceMethods');
const { formatLangChainMessages } = require('./prompts');
const { extractBaseURL } = require('~/utils');
const { loadTools } = require('./tools/util');
const { logger } = require('~/config');
class PluginsClient extends OpenAIClient {
constructor(apiKey, options = {}) {
super(apiKey, options);
this.sender = options.sender ?? 'Assistant';
this.tools = [];
this.actions = [];
this.setOptions(options);
this.openAIApiKey = this.apiKey;
this.executor = null;
}
setOptions(options) {
this.agentOptions = { ...options.agentOptions };
this.functionsAgent = this.agentOptions?.agent === 'functions';
this.agentIsGpt3 = this.agentOptions?.model?.includes('gpt-3');
super.setOptions(options);
this.isGpt3 = this.modelOptions?.model?.includes('gpt-3');
if (this.options.reverseProxyUrl) {
this.langchainProxy = extractBaseURL(this.options.reverseProxyUrl);
}
}
getSaveOptions() {
return {
artifacts: this.options.artifacts,
chatGptLabel: this.options.chatGptLabel,
modelLabel: this.options.modelLabel,
promptPrefix: this.options.promptPrefix,
tools: this.options.tools,
...this.modelOptions,
agentOptions: this.agentOptions,
iconURL: this.options.iconURL,
greeting: this.options.greeting,
spec: this.options.spec,
};
}
saveLatestAction(action) {
this.actions.push(action);
}
getFunctionModelName(input) {
if (/-(?!0314)\d{4}/.test(input)) {
return input;
} else if (input.includes('gpt-3.5-turbo')) {
return 'gpt-3.5-turbo';
} else if (input.includes('gpt-4')) {
return 'gpt-4';
} else {
return 'gpt-3.5-turbo';
}
}
getBuildMessagesOptions(opts) {
return {
isChatCompletion: true,
promptPrefix: opts.promptPrefix,
abortController: opts.abortController,
};
}
async initialize({ user, message, onAgentAction, onChainEnd, signal }) {
const modelOptions = {
modelName: this.agentOptions.model,
temperature: this.agentOptions.temperature,
};
const model = this.initializeLLM({
...modelOptions,
context: 'plugins',
initialMessageCount: this.currentMessages.length + 1,
});
logger.debug(
`[PluginsClient] Agent Model: ${model.modelName} | Temp: ${model.temperature} | Functions: ${this.functionsAgent}`,
);
// Map Messages to Langchain format
const pastMessages = formatLangChainMessages(this.currentMessages.slice(0, -1), {
userName: this.options?.name,
});
logger.debug('[PluginsClient] pastMessages: ' + pastMessages.length);
// TODO: use readOnly memory, TokenBufferMemory? (both unavailable in LangChainJS)
const memory = new BufferMemory({
llm: model,
chatHistory: new ChatMessageHistory(pastMessages),
});
const { loadedTools } = await loadTools({
user,
model,
tools: this.options.tools,
functions: this.functionsAgent,
options: {
memory,
signal: this.abortController.signal,
openAIApiKey: this.openAIApiKey,
conversationId: this.conversationId,
fileStrategy: this.options.req.app.locals.fileStrategy,
processFileURL,
message,
},
useSpecs: true,
});
if (loadedTools.length === 0) {
return;
}
this.tools = loadedTools;
logger.debug('[PluginsClient] Requested Tools', this.options.tools);
logger.debug(
'[PluginsClient] Loaded Tools',
this.tools.map((tool) => tool.name),
);
const handleAction = (action, runId, callback = null) => {
this.saveLatestAction(action);
logger.debug('[PluginsClient] Latest Agent Action ', this.actions[this.actions.length - 1]);
if (typeof callback === 'function') {
callback(action, runId);
}
};
// initialize agent
const initializer = this.functionsAgent ? initializeFunctionsAgent : initializeCustomAgent;
let customInstructions = (this.options.promptPrefix ?? '').trim();
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
customInstructions = `${customInstructions ?? ''}\n${this.options.artifactsPrompt}`.trim();
}
this.executor = await initializer({
model,
signal,
pastMessages,
tools: this.tools,
customInstructions,
verbose: this.options.debug,
returnIntermediateSteps: true,
customName: this.options.chatGptLabel,
currentDateString: this.currentDateString,
callbackManager: CallbackManager.fromHandlers({
async handleAgentAction(action, runId) {
handleAction(action, runId, onAgentAction);
},
async handleChainEnd(action) {
if (typeof onChainEnd === 'function') {
onChainEnd(action);
}
},
}),
});
logger.debug('[PluginsClient] Loaded agent.');
}
async executorCall(message, { signal, stream, onToolStart, onToolEnd }) {
let errorMessage = '';
const maxAttempts = 1;
for (let attempts = 1; attempts <= maxAttempts; attempts++) {
const errorInput = buildErrorInput({
message,
errorMessage,
actions: this.actions,
functionsAgent: this.functionsAgent,
});
const input = attempts > 1 ? errorInput : message;
logger.debug(`[PluginsClient] Attempt ${attempts} of ${maxAttempts}`);
if (errorMessage.length > 0) {
logger.debug('[PluginsClient] Caught error, input: ' + JSON.stringify(input));
}
try {
this.result = await this.executor.call({ input, signal }, [
{
async handleToolStart(...args) {
await onToolStart(...args);
},
async handleToolEnd(...args) {
await onToolEnd(...args);
},
async handleLLMEnd(output) {
const { generations } = output;
const { text } = generations[0][0];
if (text && typeof stream === 'function') {
await stream(text);
}
},
},
]);
break; // Exit the loop if the function call is successful
} catch (err) {
logger.error('[PluginsClient] executorCall error:', err);
if (attempts === maxAttempts) {
const { run } = this.runManager.getRunByConversationId(this.conversationId);
const defaultOutput = `Encountered an error while attempting to respond: ${err.message}`;
this.result.output = run && run.error ? run.error : defaultOutput;
this.result.errorMessage = run && run.error ? run.error : err.message;
this.result.intermediateSteps = this.actions;
break;
}
}
}
}
/**
*
* @param {TMessage} responseMessage
* @param {Partial<TMessage>} saveOptions
* @param {string} user
* @returns
*/
async handleResponseMessage(responseMessage, saveOptions, user) {
const { output, errorMessage, ...result } = this.result;
logger.debug('[PluginsClient][handleResponseMessage] Output:', {
output,
errorMessage,
...result,
});
const { error } = responseMessage;
if (!error) {
responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
responseMessage.completionTokens = this.getTokenCount(responseMessage.text);
}
// Record usage only when completion is skipped as it is already recorded in the agent phase.
if (!this.agentOptions.skipCompletion && !error) {
await this.recordTokenUsage(responseMessage);
}
const databasePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
delete responseMessage.tokenCount;
return { ...responseMessage, ...result, databasePromise };
}
async sendMessage(message, opts = {}) {
/** @type {Promise<TMessage>} */
let userMessagePromise;
/** @type {{ filteredTools: string[], includedTools: string[] }} */
const { filteredTools = [], includedTools = [] } = this.options.req.app.locals;
if (includedTools.length > 0) {
const tools = this.options.tools.filter((plugin) => includedTools.includes(plugin));
this.options.tools = tools;
} else {
const tools = this.options.tools.filter((plugin) => !filteredTools.includes(plugin));
this.options.tools = tools;
}
// If a message is edited, no tools can be used.
const completionMode = this.options.tools.length === 0 || opts.isEdited;
if (completionMode) {
this.setOptions(opts);
return super.sendMessage(message, opts);
}
logger.debug('[PluginsClient] sendMessage', { userMessageText: message, opts });
const {
user,
conversationId,
responseMessageId,
saveOptions,
userMessage,
onAgentAction,
onChainEnd,
onToolStart,
onToolEnd,
} = await this.handleStartMethods(message, opts);
if (opts.progressCallback) {
opts.onProgress = opts.progressCallback.call(null, {
...(opts.progressOptions ?? {}),
parentMessageId: userMessage.messageId,
messageId: responseMessageId,
});
}
this.currentMessages.push(userMessage);
let {
prompt: payload,
tokenCountMap,
promptTokens,
} = await this.buildMessages(
this.currentMessages,
userMessage.messageId,
this.getBuildMessagesOptions({
promptPrefix: null,
abortController: this.abortController,
}),
);
if (tokenCountMap) {
logger.debug('[PluginsClient] tokenCountMap', { tokenCountMap });
if (tokenCountMap[userMessage.messageId]) {
userMessage.tokenCount = tokenCountMap[userMessage.messageId];
logger.debug('[PluginsClient] userMessage.tokenCount', userMessage.tokenCount);
}
this.handleTokenCountMap(tokenCountMap);
}
this.result = {};
if (payload) {
this.currentMessages = payload;
}
if (!this.skipSaveUserMessage) {
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
if (typeof opts?.getReqData === 'function') {
opts.getReqData({
userMessagePromise,
});
}
}
const balance = this.options.req?.app?.locals?.balance;
if (balance?.enabled) {
await checkBalance({
req: this.options.req,
res: this.options.res,
txData: {
user: this.user,
tokenType: 'prompt',
amount: promptTokens,
debug: this.options.debug,
model: this.modelOptions.model,
endpoint: EModelEndpoint.openAI,
},
});
}
const responseMessage = {
endpoint: EModelEndpoint.gptPlugins,
iconURL: this.options.iconURL,
messageId: responseMessageId,
conversationId,
parentMessageId: userMessage.messageId,
isCreatedByUser: false,
model: this.modelOptions.model,
sender: this.sender,
promptTokens,
};
await this.initialize({
user,
message,
onAgentAction,
onChainEnd,
signal: this.abortController.signal,
onProgress: opts.onProgress,
});
// const stream = async (text) => {
// await this.generateTextStream.call(this, text, opts.onProgress, { delay: 1 });
// };
await this.executorCall(message, {
signal: this.abortController.signal,
// stream,
onToolStart,
onToolEnd,
});
// If message was aborted mid-generation
if (this.result?.errorMessage?.length > 0 && this.result?.errorMessage?.includes('cancel')) {
responseMessage.text = 'Cancelled.';
return await this.handleResponseMessage(responseMessage, saveOptions, user);
}
// If error occurred during generation (likely token_balance)
if (this.result?.errorMessage?.length > 0) {
responseMessage.error = true;
responseMessage.text = this.result.output;
return await this.handleResponseMessage(responseMessage, saveOptions, user);
}
if (this.agentOptions.skipCompletion && this.result.output && this.functionsAgent) {
const partialText = opts.getPartialText();
const trimmedPartial = opts.getPartialText().replaceAll(':::plugin:::\n', '');
responseMessage.text =
trimmedPartial.length === 0 ? `${partialText}${this.result.output}` : partialText;
addImages(this.result.intermediateSteps, responseMessage);
await this.generateTextStream(this.result.output, opts.onProgress, { delay: 5 });
return await this.handleResponseMessage(responseMessage, saveOptions, user);
}
if (this.agentOptions.skipCompletion && this.result.output) {
responseMessage.text = this.result.output;
addImages(this.result.intermediateSteps, responseMessage);
await this.generateTextStream(this.result.output, opts.onProgress, { delay: 5 });
return await this.handleResponseMessage(responseMessage, saveOptions, user);
}
logger.debug('[PluginsClient] Completion phase: this.result', this.result);
const promptPrefix = buildPromptPrefix({
result: this.result,
message,
functionsAgent: this.functionsAgent,
});
logger.debug('[PluginsClient]', { promptPrefix });
payload = await this.buildCompletionPrompt({
messages: this.currentMessages,
promptPrefix,
});
logger.debug('[PluginsClient] buildCompletionPrompt Payload', payload);
responseMessage.text = await this.sendCompletion(payload, opts);
return await this.handleResponseMessage(responseMessage, saveOptions, user);
}
async buildCompletionPrompt({ messages, promptPrefix: _promptPrefix }) {
logger.debug('[PluginsClient] buildCompletionPrompt messages', messages);
const orderedMessages = messages;
let promptPrefix = _promptPrefix.trim();
// If the prompt prefix doesn't end with the end token, add it.
if (!promptPrefix.endsWith(`${this.endToken}`)) {
promptPrefix = `${promptPrefix.trim()}${this.endToken}\n\n`;
}
promptPrefix = `${this.startToken}Instructions:\n${promptPrefix}`;
const promptSuffix = `${this.startToken}${this.chatGptLabel ?? 'Assistant'}:\n`;
const instructionsPayload = {
role: 'system',
content: promptPrefix,
};
const messagePayload = {
role: 'system',
content: promptSuffix,
};
if (this.isGpt3) {
instructionsPayload.role = 'user';
messagePayload.role = 'user';
instructionsPayload.content += `\n${promptSuffix}`;
}
// testing if this works with browser endpoint
if (!this.isGpt3 && this.options.reverseProxyUrl) {
instructionsPayload.role = 'user';
}
let currentTokenCount =
this.getTokenCountForMessage(instructionsPayload) +
this.getTokenCountForMessage(messagePayload);
let promptBody = '';
const maxTokenCount = this.maxPromptTokens;
// Iterate backwards through the messages, adding them to the prompt until we reach the max token count.
// Do this within a recursive async function so that it doesn't block the event loop for too long.
const buildPromptBody = async () => {
if (currentTokenCount < maxTokenCount && orderedMessages.length > 0) {
const message = orderedMessages.pop();
const isCreatedByUser = message.isCreatedByUser || message.role?.toLowerCase() === 'user';
const roleLabel = isCreatedByUser ? this.userLabel : this.chatGptLabel;
let messageString = `${this.startToken}${roleLabel}:\n${
message.text ?? message.content ?? ''
}${this.endToken}\n`;
let newPromptBody = `${messageString}${promptBody}`;
const tokenCountForMessage = this.getTokenCount(messageString);
const newTokenCount = currentTokenCount + tokenCountForMessage;
if (newTokenCount > maxTokenCount) {
if (promptBody) {
// This message would put us over the token limit, so don't add it.
return false;
}
// This is the first message, so we can't add it. Just throw an error.
throw new Error(
`Prompt is too long. Max token count is ${maxTokenCount}, but prompt is ${newTokenCount} tokens long.`,
);
}
promptBody = newPromptBody;
currentTokenCount = newTokenCount;
// wait for next tick to avoid blocking the event loop
await new Promise((resolve) => setTimeout(resolve, 0));
return buildPromptBody();
}
return true;
};
await buildPromptBody();
const prompt = promptBody;
messagePayload.content = prompt;
// Add 2 tokens for metadata after all messages have been counted.
currentTokenCount += 2;
if (this.isGpt3 && messagePayload.content.length > 0) {
const context = 'Chat History:\n';
messagePayload.content = `${context}${prompt}`;
currentTokenCount += this.getTokenCount(context);
}
// Use up to `this.maxContextTokens` tokens (prompt + response), but try to leave `this.maxTokens` tokens for the response.
this.modelOptions.max_tokens = Math.min(
this.maxContextTokens - currentTokenCount,
this.maxResponseTokens,
);
if (this.isGpt3) {
messagePayload.content += promptSuffix;
return [instructionsPayload, messagePayload];
}
const result = [messagePayload, instructionsPayload];
if (this.functionsAgent && !this.isGpt3) {
result[1].content = `${result[1].content}\n${this.startToken}${this.chatGptLabel}:\nSure thing! Here is the output you requested:\n`;
}
return result.filter((message) => message.content.length > 0);
}
}
module.exports = PluginsClient;

View File

@@ -1,15 +1,11 @@
const ChatGPTClient = require('./ChatGPTClient');
const OpenAIClient = require('./OpenAIClient');
const PluginsClient = require('./PluginsClient');
const GoogleClient = require('./GoogleClient');
const TextStream = require('./TextStream');
const AnthropicClient = require('./AnthropicClient');
const toolUtils = require('./tools/util');
module.exports = {
ChatGPTClient,
OpenAIClient,
PluginsClient,
GoogleClient,
TextStream,
AnthropicClient,

View File

@@ -1,6 +1,7 @@
const axios = require('axios');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const footer = `Use the context as your learned knowledge to better answer the user.
@@ -18,7 +19,7 @@ function createContextHandlers(req, userMessageContent) {
const queryPromises = [];
const processedFiles = [];
const processedIds = new Set();
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
const useFullContext = isEnabled(process.env.RAG_USE_FULL_CONTEXT);
const query = async (file) => {
@@ -96,35 +97,35 @@ function createContextHandlers(req, userMessageContent) {
resolvedQueries.length === 0
? '\n\tThe semantic search did not return any results.'
: resolvedQueries
.map((queryResult, index) => {
const file = processedFiles[index];
let contextItems = queryResult.data;
.map((queryResult, index) => {
const file = processedFiles[index];
let contextItems = queryResult.data;
const generateContext = (currentContext) =>
`
const generateContext = (currentContext) =>
`
<file>
<filename>${file.filename}</filename>
<context>${currentContext}
</context>
</file>`;
if (useFullContext) {
return generateContext(`\n${contextItems}`);
}
if (useFullContext) {
return generateContext(`\n${contextItems}`);
}
contextItems = queryResult.data
.map((item) => {
const pageContent = item[0].page_content;
return `
contextItems = queryResult.data
.map((item) => {
const pageContent = item[0].page_content;
return `
<contextItem>
<![CDATA[${pageContent?.trim()}]]>
</contextItem>`;
})
.join('');
})
.join('');
return generateContext(contextItems);
})
.join('');
return generateContext(contextItems);
})
.join('');
if (useFullContext) {
const prompt = `${header}

View File

@@ -309,7 +309,7 @@ describe('AnthropicClient', () => {
};
client.setOptions({ modelOptions, promptCache: true });
const anthropicClient = client.getClient(modelOptions);
expect(anthropicClient.defaultHeaders).not.toHaveProperty('anthropic-beta');
expect(anthropicClient._options.defaultHeaders).toBeUndefined();
});
it('should not add beta header for other models', () => {
@@ -320,7 +320,7 @@ describe('AnthropicClient', () => {
},
});
const anthropicClient = client.getClient();
expect(anthropicClient.defaultHeaders).not.toHaveProperty('anthropic-beta');
expect(anthropicClient._options.defaultHeaders).toBeUndefined();
});
});

View File

@@ -531,44 +531,6 @@ describe('OpenAIClient', () => {
});
});
describe('sendMessage/getCompletion/chatCompletion', () => {
afterEach(() => {
delete process.env.AZURE_OPENAI_DEFAULT_MODEL;
delete process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME;
});
it('should call getCompletion and fetchEventSource when using a text/instruct model', async () => {
const model = 'text-davinci-003';
const onProgress = jest.fn().mockImplementation(() => ({}));
const testClient = new OpenAIClient('test-api-key', {
...defaultOptions,
modelOptions: { model },
});
const getCompletion = jest.spyOn(testClient, 'getCompletion');
await testClient.sendMessage('Hi mom!', { onProgress });
expect(getCompletion).toHaveBeenCalled();
expect(getCompletion.mock.calls.length).toBe(1);
expect(getCompletion.mock.calls[0][0]).toBe('||>User:\nHi mom!\n||>Assistant:\n');
expect(fetchEventSource).toHaveBeenCalled();
expect(fetchEventSource.mock.calls.length).toBe(1);
// Check if the first argument (url) is correct
const firstCallArgs = fetchEventSource.mock.calls[0];
const expectedURL = 'https://api.openai.com/v1/completions';
expect(firstCallArgs[0]).toBe(expectedURL);
const requestBody = JSON.parse(firstCallArgs[1].body);
expect(requestBody).toHaveProperty('model');
expect(requestBody.model).toBe(model);
});
});
describe('checkVisionRequest functionality', () => {
let client;
const attachments = [{ type: 'image/png' }];

View File

@@ -1,314 +0,0 @@
const crypto = require('crypto');
const { Constants } = require('librechat-data-provider');
const { HumanMessage, AIMessage } = require('@langchain/core/messages');
const PluginsClient = require('../PluginsClient');
jest.mock('~/db/connect');
jest.mock('~/models/Conversation', () => {
return function () {
return {
save: jest.fn(),
deleteConvos: jest.fn(),
};
};
});
const defaultAzureOptions = {
azureOpenAIApiInstanceName: 'your-instance-name',
azureOpenAIApiDeploymentName: 'your-deployment-name',
azureOpenAIApiVersion: '2020-07-01-preview',
};
describe('PluginsClient', () => {
let TestAgent;
let options = {
tools: [],
modelOptions: {
model: 'gpt-3.5-turbo',
temperature: 0,
max_tokens: 2,
},
agentOptions: {
model: 'gpt-3.5-turbo',
},
};
let parentMessageId;
let conversationId;
const fakeMessages = [];
const userMessage = 'Hello, ChatGPT!';
const apiKey = 'fake-api-key';
beforeEach(() => {
TestAgent = new PluginsClient(apiKey, options);
TestAgent.loadHistory = jest
.fn()
.mockImplementation((conversationId, parentMessageId = null) => {
if (!conversationId) {
TestAgent.currentMessages = [];
return Promise.resolve([]);
}
const orderedMessages = TestAgent.constructor.getMessagesForConversation({
messages: fakeMessages,
parentMessageId,
});
const chatMessages = orderedMessages.map((msg) =>
msg?.isCreatedByUser || msg?.role?.toLowerCase() === 'user'
? new HumanMessage(msg.text)
: new AIMessage(msg.text),
);
TestAgent.currentMessages = orderedMessages;
return Promise.resolve(chatMessages);
});
TestAgent.sendMessage = jest.fn().mockImplementation(async (message, opts = {}) => {
if (opts && typeof opts === 'object') {
TestAgent.setOptions(opts);
}
const conversationId = opts.conversationId || crypto.randomUUID();
const parentMessageId = opts.parentMessageId || Constants.NO_PARENT;
const userMessageId = opts.overrideParentMessageId || crypto.randomUUID();
this.pastMessages = await TestAgent.loadHistory(
conversationId,
TestAgent.options?.parentMessageId,
);
const userMessage = {
text: message,
sender: 'ChatGPT',
isCreatedByUser: true,
messageId: userMessageId,
parentMessageId,
conversationId,
};
const response = {
sender: 'ChatGPT',
text: 'Hello, User!',
isCreatedByUser: false,
messageId: crypto.randomUUID(),
parentMessageId: userMessage.messageId,
conversationId,
};
fakeMessages.push(userMessage);
fakeMessages.push(response);
return response;
});
});
test('initializes PluginsClient without crashing', () => {
expect(TestAgent).toBeInstanceOf(PluginsClient);
});
test('check setOptions function', () => {
expect(TestAgent.agentIsGpt3).toBe(true);
});
describe('sendMessage', () => {
test('sendMessage should return a response message', async () => {
const expectedResult = expect.objectContaining({
sender: 'ChatGPT',
text: expect.any(String),
isCreatedByUser: false,
messageId: expect.any(String),
parentMessageId: expect.any(String),
conversationId: expect.any(String),
});
const response = await TestAgent.sendMessage(userMessage);
parentMessageId = response.messageId;
conversationId = response.conversationId;
expect(response).toEqual(expectedResult);
});
test('sendMessage should work with provided conversationId and parentMessageId', async () => {
const userMessage = 'Second message in the conversation';
const opts = {
conversationId,
parentMessageId,
};
const expectedResult = expect.objectContaining({
sender: 'ChatGPT',
text: expect.any(String),
isCreatedByUser: false,
messageId: expect.any(String),
parentMessageId: expect.any(String),
conversationId: opts.conversationId,
});
const response = await TestAgent.sendMessage(userMessage, opts);
parentMessageId = response.messageId;
expect(response.conversationId).toEqual(conversationId);
expect(response).toEqual(expectedResult);
});
test('should return chat history', async () => {
const chatMessages = await TestAgent.loadHistory(conversationId, parentMessageId);
expect(TestAgent.currentMessages).toHaveLength(4);
expect(chatMessages[0].text).toEqual(userMessage);
});
});
describe('getFunctionModelName', () => {
let client;
beforeEach(() => {
client = new PluginsClient('dummy_api_key');
});
test('should return the input when it includes a dash followed by four digits', () => {
expect(client.getFunctionModelName('-1234')).toBe('-1234');
expect(client.getFunctionModelName('gpt-4-5678-preview')).toBe('gpt-4-5678-preview');
});
test('should return the input for all function-capable models (`0613` models and above)', () => {
expect(client.getFunctionModelName('gpt-4-0613')).toBe('gpt-4-0613');
expect(client.getFunctionModelName('gpt-4-32k-0613')).toBe('gpt-4-32k-0613');
expect(client.getFunctionModelName('gpt-3.5-turbo-0613')).toBe('gpt-3.5-turbo-0613');
expect(client.getFunctionModelName('gpt-3.5-turbo-16k-0613')).toBe('gpt-3.5-turbo-16k-0613');
expect(client.getFunctionModelName('gpt-3.5-turbo-1106')).toBe('gpt-3.5-turbo-1106');
expect(client.getFunctionModelName('gpt-4-1106-preview')).toBe('gpt-4-1106-preview');
expect(client.getFunctionModelName('gpt-4-1106')).toBe('gpt-4-1106');
});
test('should return the corresponding model if input is non-function capable (`0314` models)', () => {
expect(client.getFunctionModelName('gpt-4-0314')).toBe('gpt-4');
expect(client.getFunctionModelName('gpt-4-32k-0314')).toBe('gpt-4');
expect(client.getFunctionModelName('gpt-3.5-turbo-0314')).toBe('gpt-3.5-turbo');
expect(client.getFunctionModelName('gpt-3.5-turbo-16k-0314')).toBe('gpt-3.5-turbo');
});
test('should return "gpt-3.5-turbo" when the input includes "gpt-3.5-turbo"', () => {
expect(client.getFunctionModelName('test gpt-3.5-turbo model')).toBe('gpt-3.5-turbo');
});
test('should return "gpt-4" when the input includes "gpt-4"', () => {
expect(client.getFunctionModelName('testing gpt-4')).toBe('gpt-4');
});
test('should return "gpt-3.5-turbo" for input that does not meet any specific condition', () => {
expect(client.getFunctionModelName('random string')).toBe('gpt-3.5-turbo');
expect(client.getFunctionModelName('')).toBe('gpt-3.5-turbo');
});
});
describe('Azure OpenAI tests specific to Plugins', () => {
// TODO: add more tests for Azure OpenAI integration with Plugins
// let client;
// beforeEach(() => {
// client = new PluginsClient('dummy_api_key');
// });
test('should not call getFunctionModelName when azure options are set', () => {
const spy = jest.spyOn(PluginsClient.prototype, 'getFunctionModelName');
const model = 'gpt-4-turbo';
// note, without the azure change in PR #1766, `getFunctionModelName` is called twice
const testClient = new PluginsClient('dummy_api_key', {
agentOptions: {
model,
agent: 'functions',
},
azure: defaultAzureOptions,
});
expect(spy).not.toHaveBeenCalled();
expect(testClient.agentOptions.model).toBe(model);
spy.mockRestore();
});
});
describe('sendMessage with filtered tools', () => {
let TestAgent;
const apiKey = 'fake-api-key';
const mockTools = [{ name: 'tool1' }, { name: 'tool2' }, { name: 'tool3' }, { name: 'tool4' }];
beforeEach(() => {
TestAgent = new PluginsClient(apiKey, {
tools: mockTools,
modelOptions: {
model: 'gpt-3.5-turbo',
temperature: 0,
max_tokens: 2,
},
agentOptions: {
model: 'gpt-3.5-turbo',
},
});
TestAgent.options.req = {
app: {
locals: {},
},
};
TestAgent.sendMessage = jest.fn().mockImplementation(async () => {
const { filteredTools = [], includedTools = [] } = TestAgent.options.req.app.locals;
if (includedTools.length > 0) {
const tools = TestAgent.options.tools.filter((plugin) =>
includedTools.includes(plugin.name),
);
TestAgent.options.tools = tools;
} else {
const tools = TestAgent.options.tools.filter(
(plugin) => !filteredTools.includes(plugin.name),
);
TestAgent.options.tools = tools;
}
return {
text: 'Mocked response',
tools: TestAgent.options.tools,
};
});
});
test('should filter out tools when filteredTools is provided', async () => {
TestAgent.options.req.app.locals.filteredTools = ['tool1', 'tool3'];
const response = await TestAgent.sendMessage('Test message');
expect(response.tools).toHaveLength(2);
expect(response.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'tool2' }),
expect.objectContaining({ name: 'tool4' }),
]),
);
});
test('should only include specified tools when includedTools is provided', async () => {
TestAgent.options.req.app.locals.includedTools = ['tool2', 'tool4'];
const response = await TestAgent.sendMessage('Test message');
expect(response.tools).toHaveLength(2);
expect(response.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'tool2' }),
expect.objectContaining({ name: 'tool4' }),
]),
);
});
test('should prioritize includedTools over filteredTools', async () => {
TestAgent.options.req.app.locals.filteredTools = ['tool1', 'tool3'];
TestAgent.options.req.app.locals.includedTools = ['tool1', 'tool2'];
const response = await TestAgent.sendMessage('Test message');
expect(response.tools).toHaveLength(2);
expect(response.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'tool1' }),
expect.objectContaining({ name: 'tool2' }),
]),
);
});
test('should not modify tools when no filters are provided', async () => {
const response = await TestAgent.sendMessage('Test message');
expect(response.tools).toHaveLength(4);
expect(response.tools).toEqual(expect.arrayContaining(mockTools));
});
});
});

View File

@@ -107,6 +107,12 @@ const getImageEditPromptDescription = () => {
return process.env.IMAGE_EDIT_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION;
};
function createAbortHandler() {
return function () {
logger.debug('[ImageGenOAI] Image generation aborted');
};
}
/**
* Creates OpenAI Image tools (generation and editing)
* @param {Object} fields - Configuration fields
@@ -201,10 +207,18 @@ function createOpenAIImageTools(fields = {}) {
}
let resp;
/** @type {AbortSignal} */
let derivedSignal = null;
/** @type {() => void} */
let abortHandler = null;
try {
const derivedSignal = runnableConfig?.signal
? AbortSignal.any([runnableConfig.signal])
: undefined;
if (runnableConfig?.signal) {
derivedSignal = AbortSignal.any([runnableConfig.signal]);
abortHandler = createAbortHandler();
derivedSignal.addEventListener('abort', abortHandler, { once: true });
}
resp = await openai.images.generate(
{
model: 'gpt-image-1',
@@ -228,6 +242,10 @@ function createOpenAIImageTools(fields = {}) {
logAxiosError({ error, message });
return returnValue(`Something went wrong when trying to generate the image. The OpenAI API may be unavailable:
Error Message: ${error.message}`);
} finally {
if (abortHandler && derivedSignal) {
derivedSignal.removeEventListener('abort', abortHandler);
}
}
if (!resp) {
@@ -409,10 +427,17 @@ Error Message: ${error.message}`);
headers['Authorization'] = `Bearer ${apiKey}`;
}
/** @type {AbortSignal} */
let derivedSignal = null;
/** @type {() => void} */
let abortHandler = null;
try {
const derivedSignal = runnableConfig?.signal
? AbortSignal.any([runnableConfig.signal])
: undefined;
if (runnableConfig?.signal) {
derivedSignal = AbortSignal.any([runnableConfig.signal]);
abortHandler = createAbortHandler();
derivedSignal.addEventListener('abort', abortHandler, { once: true });
}
/** @type {import('axios').AxiosRequestConfig} */
const axiosConfig = {
@@ -467,6 +492,10 @@ Error Message: ${error.message}`);
logAxiosError({ error, message });
return returnValue(`Something went wrong when trying to edit the image. The OpenAI API may be unavailable:
Error Message: ${error.message || 'Unknown error'}`);
} finally {
if (abortHandler && derivedSignal) {
derivedSignal.removeEventListener('abort', abortHandler);
}
}
},
{

View File

@@ -1,9 +1,10 @@
const { z } = require('zod');
const axios = require('axios');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { Tools, EToolResources } = require('librechat-data-provider');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const { getFiles } = require('~/models/File');
const { logger } = require('~/config');
/**
*
@@ -59,7 +60,7 @@ const createFileSearchTool = async ({ req, files, entity_id }) => {
if (files.length === 0) {
return 'No files to search. Instruct the user to add files for the search.';
}
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
if (!jwtToken) {
return 'There was an error authenticating the file search request.';
}

View File

@@ -1,14 +1,14 @@
const { mcpToolPattern } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { SerpAPI } = require('@langchain/community/tools/serpapi');
const { Calculator } = require('@langchain/community/tools/calculator');
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
const {
Tools,
Constants,
EToolResources,
loadWebSearchAuth,
replaceSpecialVars,
} = require('librechat-data-provider');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const {
availableTools,
manifestToolMap,
@@ -28,11 +28,10 @@ const {
} = require('../');
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getCachedTools } = require('~/server/services/Config');
const { createMCPTool } = require('~/server/services/MCP');
const { logger } = require('~/config');
const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
/**
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
@@ -93,7 +92,7 @@ const validateTools = async (user, tools = []) => {
return Array.from(validToolsSet.values());
} catch (err) {
logger.error('[validateTools] There was a problem validating tools', err);
throw new Error('There was a problem validating tools');
throw new Error(err);
}
};
@@ -236,7 +235,7 @@ const loadTools = async ({
/** @type {Record<string, string>} */
const toolContextMap = {};
const appTools = options.req?.app?.locals?.availableTools ?? {};
const appTools = (await getCachedTools({ includeGlobal: true })) ?? {};
for (const tool of tools) {
if (tool === Tools.execute_code) {
@@ -299,6 +298,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
requestedTools[tool] = async () =>
createMCPTool({
req: options.req,
res: options.res,
toolKey: tool,
model: agent?.model ?? model,
provider: agent?.provider ?? endpoint,

View File

@@ -1,7 +1,8 @@
const { logger } = require('@librechat/data-schemas');
const { isEnabled, math } = require('@librechat/api');
const { ViolationTypes } = require('librechat-data-provider');
const { isEnabled, math, removePorts } = require('~/server/utils');
const { deleteAllUserSessions } = require('~/models');
const { removePorts } = require('~/server/utils');
const getLogStores = require('./getLogStores');
const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {};

View File

@@ -1,7 +1,7 @@
const { Keyv } = require('keyv');
const { isEnabled, math } = require('@librechat/api');
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
const { logFile, violationFile } = require('./keyvFiles');
const { isEnabled, math } = require('~/server/utils');
const keyvRedis = require('./keyvRedis');
const keyvMongo = require('./keyvMongo');
@@ -29,6 +29,10 @@ const roles = isRedisEnabled
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: CacheKeys.ROLES });
const mcpTools = isRedisEnabled
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: CacheKeys.MCP_TOOLS });
const audioRuns = isRedisEnabled
? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES })
: new Keyv({ namespace: CacheKeys.AUDIO_RUNS, ttl: Time.TEN_MINUTES });
@@ -67,6 +71,7 @@ const openIdExchangedTokensCache = isRedisEnabled
const namespaces = {
[CacheKeys.ROLES]: roles,
[CacheKeys.MCP_TOOLS]: mcpTools,
[CacheKeys.CONFIG_STORE]: config,
[CacheKeys.PENDING_REQ]: pending_req,
[ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }),

View File

@@ -15,7 +15,7 @@ let flowManager = null;
*/
function getMCPManager(userId) {
if (!mcpManager) {
mcpManager = MCPManager.getInstance(logger);
mcpManager = MCPManager.getInstance();
} else {
mcpManager.checkIdleConnections(userId);
}
@@ -30,7 +30,6 @@ function getFlowStateManager(flowsCache) {
if (!flowManager) {
flowManager = new FlowStateManager(flowsCache, {
ttl: Time.ONE_MINUTE * 3,
logger,
});
}
return flowManager;

View File

@@ -1,8 +1,11 @@
const mongoose = require('mongoose');
const { MeiliSearch } = require('meilisearch');
const { logger } = require('@librechat/data-schemas');
const { FlowStateManager } = require('@librechat/api');
const { CacheKeys } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const { getLogStores } = require('~/cache');
const Conversation = mongoose.models.Conversation;
const Message = mongoose.models.Message;
@@ -28,43 +31,123 @@ class MeiliSearchClient {
}
}
/**
* Performs the actual sync operations for messages and conversations
*/
async function performSync() {
const client = MeiliSearchClient.getInstance();
const { status } = await client.health();
if (status !== 'available') {
throw new Error('Meilisearch not available');
}
if (indexingDisabled === true) {
logger.info('[indexSync] Indexing is disabled, skipping...');
return { messagesSync: false, convosSync: false };
}
let messagesSync = false;
let convosSync = false;
// Check if we need to sync messages
const messageProgress = await Message.getSyncProgress();
if (!messageProgress.isComplete) {
logger.info(
`[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
);
// Check if we should do a full sync or incremental
const messageCount = await Message.countDocuments();
const messagesIndexed = messageProgress.totalProcessed;
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
if (messageCount - messagesIndexed > syncThreshold) {
logger.info('[indexSync] Starting full message sync due to large difference');
await Message.syncWithMeili();
messagesSync = true;
} else if (messageCount !== messagesIndexed) {
logger.warn('[indexSync] Messages out of sync, performing incremental sync');
await Message.syncWithMeili();
messagesSync = true;
}
} else {
logger.info(
`[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`,
);
}
// Check if we need to sync conversations
const convoProgress = await Conversation.getSyncProgress();
if (!convoProgress.isComplete) {
logger.info(
`[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
);
const convoCount = await Conversation.countDocuments();
const convosIndexed = convoProgress.totalProcessed;
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
if (convoCount - convosIndexed > syncThreshold) {
logger.info('[indexSync] Starting full conversation sync due to large difference');
await Conversation.syncWithMeili();
convosSync = true;
} else if (convoCount !== convosIndexed) {
logger.warn('[indexSync] Convos out of sync, performing incremental sync');
await Conversation.syncWithMeili();
convosSync = true;
}
} else {
logger.info(
`[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`,
);
}
return { messagesSync, convosSync };
}
/**
* Main index sync function that uses FlowStateManager to prevent concurrent execution
*/
async function indexSync() {
if (!searchEnabled) {
return;
}
try {
const client = MeiliSearchClient.getInstance();
const { status } = await client.health();
if (status !== 'available') {
throw new Error('Meilisearch not available');
logger.info('[indexSync] Starting index synchronization check...');
try {
// Get or create FlowStateManager instance
const flowsCache = getLogStores(CacheKeys.FLOWS);
if (!flowsCache) {
logger.warn('[indexSync] Flows cache not available, falling back to direct sync');
return await performSync();
}
if (indexingDisabled === true) {
logger.info('[indexSync] Indexing is disabled, skipping...');
const flowManager = new FlowStateManager(flowsCache, {
ttl: 60000 * 10, // 10 minutes TTL for sync operations
});
// Use a unique flow ID for the sync operation
const flowId = 'meili-index-sync';
const flowType = 'MEILI_SYNC';
// This will only execute the handler if no other instance is running the sync
const result = await flowManager.createFlowWithHandler(flowId, flowType, performSync);
if (result.messagesSync || result.convosSync) {
logger.info('[indexSync] Sync completed successfully');
} else {
logger.debug('[indexSync] No sync was needed');
}
return result;
} catch (err) {
if (err.message.includes('flow already exists')) {
logger.info('[indexSync] Sync already running on another instance');
return;
}
const messageCount = await Message.countDocuments();
const convoCount = await Conversation.countDocuments();
const messages = await client.index('messages').getStats();
const convos = await client.index('convos').getStats();
const messagesIndexed = messages.numberOfDocuments;
const convosIndexed = convos.numberOfDocuments;
logger.debug(`[indexSync] There are ${messageCount} messages and ${messagesIndexed} indexed`);
logger.debug(`[indexSync] There are ${convoCount} convos and ${convosIndexed} indexed`);
if (messageCount !== messagesIndexed) {
logger.debug('[indexSync] Messages out of sync, indexing');
Message.syncWithMeili();
}
if (convoCount !== convosIndexed) {
logger.debug('[indexSync] Convos out of sync, indexing');
Conversation.syncWithMeili();
}
} catch (err) {
if (err.message.includes('not found')) {
logger.debug('[indexSync] Creating indices...');
currentTimeout = setTimeout(async () => {

View File

@@ -1,6 +1,4 @@
/** @type {import('jest').Config} */
module.exports = {
displayName: 'default',
testEnvironment: 'node',
clearMocks: true,
roots: ['<rootDir>'],
@@ -13,7 +11,7 @@ module.exports = {
moduleNameMapper: {
'~/(.*)': '<rootDir>/$1',
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js',
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js', // Mock for the passport strategy part
'^openid-client$': '<rootDir>/test/__mocks__/openid-client.js',
},
transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'],

View File

@@ -4,14 +4,17 @@ const { logger } = require('@librechat/data-schemas');
const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
require('librechat-data-provider').Constants;
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
// Default category value for new agents
const {
getProjectByName,
addAgentIdsToProject,
removeAgentIdsFromProject,
removeAgentFromAllProjects,
} = require('./Project');
const getLogStores = require('~/cache/getLogStores');
const { getCachedTools } = require('~/server/services/Config');
// Category values are now imported from shared constants
// Schema fields (category, support_contact, is_promoted) are defined in @librechat/data-schemas
const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
@@ -22,7 +25,7 @@ const { Agent } = require('~/db/models');
* @throws {Error} If the agent creation fails.
*/
const createAgent = async (agentData) => {
const { author, ...versionData } = agentData;
const { author: _author, ...versionData } = agentData;
const timestamp = new Date();
const initialAgentData = {
...agentData,
@@ -33,6 +36,7 @@ const createAgent = async (agentData) => {
updatedAt: timestamp,
},
],
category: agentData.category || 'general',
};
return (await Agent.create(initialAgentData)).toObject();
};
@@ -55,12 +59,12 @@ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter)
* @param {string} params.agent_id
* @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Agent|null} The agent document as a plain object, or null if not found.
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/
const loadEphemeralAgent = ({ req, agent_id, endpoint, model_parameters: _m }) => {
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
const { model, ...model_parameters } = _m;
/** @type {Record<string, FunctionTool>} */
const availableTools = req.app.locals.availableTools;
const availableTools = await getCachedTools({ includeGlobal: true });
/** @type {TEphemeralAgent | null} */
const ephemeralAgent = req.body.ephemeralAgent;
const mcpServers = new Set(ephemeralAgent?.mcp);
@@ -69,6 +73,9 @@ const loadEphemeralAgent = ({ req, agent_id, endpoint, model_parameters: _m }) =
if (ephemeralAgent?.execute_code === true) {
tools.push(Tools.execute_code);
}
if (ephemeralAgent?.file_search === true) {
tools.push(Tools.file_search);
}
if (ephemeralAgent?.web_search === true) {
tools.push(Tools.web_search);
}
@@ -111,7 +118,7 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
return null;
}
if (agent_id === EPHEMERAL_AGENT_ID) {
return loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
}
const agent = await getAgent({
id: agent_id,
@@ -122,29 +129,7 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
}
agent.version = agent.versions ? agent.versions.length : 0;
if (agent.author.toString() === req.user.id) {
return agent;
}
if (!agent.projectIds) {
return null;
}
const cache = getLogStores(CONFIG_STORE);
/** @type {TStartupConfig} */
const cachedStartupConfig = await cache.get(STARTUP_CONFIG);
let { instanceProjectId } = cachedStartupConfig ?? {};
if (!instanceProjectId) {
instanceProjectId = (await getProjectByName(GLOBAL_PROJECT_NAME, '_id'))._id.toString();
}
for (const projectObjectId of agent.projectIds) {
const projectId = projectObjectId.toString();
if (projectId === instanceProjectId) {
return agent;
}
}
return agent;
};
/**
@@ -174,7 +159,7 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
'actionsHash', // Exclude actionsHash from direct comparison
];
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
const { $push: _$push, $pull: _$pull, $addToSet: _$addToSet, ...directUpdates } = updateData;
if (Object.keys(directUpdates).length === 0 && !actionsHash) {
return null;
@@ -193,54 +178,116 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
let isMatch = true;
for (const field of importantFields) {
if (!wouldBeVersion[field] && !lastVersion[field]) {
const wouldBeValue = wouldBeVersion[field];
const lastVersionValue = lastVersion[field];
// Skip if both are undefined/null
if (!wouldBeValue && !lastVersionValue) {
continue;
}
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
if (wouldBeVersion[field].length !== lastVersion[field].length) {
// Handle arrays
if (Array.isArray(wouldBeValue) || Array.isArray(lastVersionValue)) {
// Normalize: treat undefined/null as empty array for comparison
let wouldBeArr;
if (Array.isArray(wouldBeValue)) {
wouldBeArr = wouldBeValue;
} else if (wouldBeValue == null) {
wouldBeArr = [];
} else {
wouldBeArr = [wouldBeValue];
}
let lastVersionArr;
if (Array.isArray(lastVersionValue)) {
lastVersionArr = lastVersionValue;
} else if (lastVersionValue == null) {
lastVersionArr = [];
} else {
lastVersionArr = [lastVersionValue];
}
if (wouldBeArr.length !== lastVersionArr.length) {
isMatch = false;
break;
}
// Special handling for projectIds (MongoDB ObjectIds)
if (field === 'projectIds') {
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
const wouldBeIds = wouldBeArr.map((id) => id.toString()).sort();
const versionIds = lastVersionArr.map((id) => id.toString()).sort();
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
isMatch = false;
break;
}
}
// Handle arrays of objects like tool_kwargs
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
// Handle arrays of objects
else if (
wouldBeArr.length > 0 &&
typeof wouldBeArr[0] === 'object' &&
wouldBeArr[0] !== null
) {
const sortedWouldBe = [...wouldBeArr].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersionArr].map((item) => JSON.stringify(item)).sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
} else {
const sortedWouldBe = [...wouldBeVersion[field]].sort();
const sortedVersion = [...lastVersion[field]].sort();
const sortedWouldBe = [...wouldBeArr].sort();
const sortedVersion = [...lastVersionArr].sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
}
} else if (field === 'model_parameters') {
const wouldBeParams = wouldBeVersion[field] || {};
const lastVersionParams = lastVersion[field] || {};
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
}
// Handle objects
else if (typeof wouldBeValue === 'object' && wouldBeValue !== null) {
const lastVersionObj =
typeof lastVersionValue === 'object' && lastVersionValue !== null ? lastVersionValue : {};
// For empty objects, normalize the comparison
const wouldBeKeys = Object.keys(wouldBeValue);
const lastVersionKeys = Object.keys(lastVersionObj);
// If both are empty objects, they're equal
if (wouldBeKeys.length === 0 && lastVersionKeys.length === 0) {
continue;
}
// Otherwise do a deep comparison
if (JSON.stringify(wouldBeValue) !== JSON.stringify(lastVersionObj)) {
isMatch = false;
break;
}
}
// Handle primitive values
else {
// For primitives, handle the case where one is undefined and the other is a default value
if (wouldBeValue !== lastVersionValue) {
// Special handling for boolean false vs undefined
if (
typeof wouldBeValue === 'boolean' &&
wouldBeValue === false &&
lastVersionValue === undefined
) {
continue;
}
// Special handling for empty string vs undefined
if (
typeof wouldBeValue === 'string' &&
wouldBeValue === '' &&
lastVersionValue === undefined
) {
continue;
}
isMatch = false;
break;
}
} else if (wouldBeVersion[field] !== lastVersion[field]) {
isMatch = false;
break;
}
}
@@ -269,7 +316,14 @@ const updateAgent = async (searchParameter, updateData, options = {}) => {
const currentAgent = await Agent.findOne(searchParameter);
if (currentAgent) {
const { __v, _id, id, versions, author, ...versionData } = currentAgent.toObject();
const {
__v,
_id,
id: __id,
versions,
author: _author,
...versionData
} = currentAgent.toObject();
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
let actionsHash = null;
@@ -460,8 +514,113 @@ const deleteAgent = async (searchParameter) => {
return agent;
};
/**
* Get agents by accessible IDs with optional cursor-based pagination.
* @param {Object} params - The parameters for getting accessible agents.
* @param {Array} [params.accessibleIds] - Array of agent ObjectIds the user has ACL access to.
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
* @param {number} [params.limit] - Number of agents to return (max 100). If not provided, returns all agents.
* @param {string} [params.after] - Cursor for pagination - get agents after this cursor. // base64 encoded JSON string with updatedAt and _id.
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
*/
const getListAgentsByAccess = async ({
accessibleIds = [],
otherParams = {},
limit = null,
after = null,
}) => {
const isPaginated = limit !== null && limit !== undefined;
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
// Build base query combining ACL accessible agents with other filters
const baseQuery = { ...otherParams };
if (accessibleIds.length > 0) {
baseQuery._id = { $in: accessibleIds };
}
// Add cursor condition
if (after) {
try {
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
const { updatedAt, _id } = cursor;
const cursorCondition = {
$or: [
{ updatedAt: { $lt: new Date(updatedAt) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } },
],
};
// Merge cursor condition with base query
if (Object.keys(baseQuery).length > 0) {
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
// Remove the original conditions from baseQuery to avoid duplication
Object.keys(baseQuery).forEach((key) => {
if (key !== '$and') delete baseQuery[key];
});
} else {
Object.assign(baseQuery, cursorCondition);
}
} catch (error) {
logger.warn('Invalid cursor:', error.message);
}
}
let query = Agent.find(baseQuery, {
id: 1,
_id: 1,
name: 1,
avatar: 1,
author: 1,
projectIds: 1,
description: 1,
updatedAt: 1,
category: 1,
support_contact: 1,
is_promoted: 1,
}).sort({ updatedAt: -1, _id: 1 });
// Only apply limit if pagination is requested
if (isPaginated) {
query = query.limit(normalizedLimit + 1);
}
const agents = await query.lean();
const hasMore = isPaginated ? agents.length > normalizedLimit : false;
const data = (isPaginated ? agents.slice(0, normalizedLimit) : agents).map((agent) => {
if (agent.author) {
agent.author = agent.author.toString();
}
return agent;
});
// Generate next cursor only if paginated
let nextCursor = null;
if (isPaginated && hasMore && data.length > 0) {
const lastAgent = agents[normalizedLimit - 1];
nextCursor = Buffer.from(
JSON.stringify({
updatedAt: lastAgent.updatedAt.toISOString(),
_id: lastAgent._id.toString(),
}),
).toString('base64');
}
return {
object: 'list',
data,
first_id: data.length > 0 ? data[0].id : null,
last_id: data.length > 0 ? data[data.length - 1].id : null,
has_more: hasMore,
after: nextCursor,
};
};
/**
* Get all agents.
* @deprecated Use getListAgentsByAccess for ACL-aware agent listing
* @param {Object} searchParameter - The search parameters to find matching agents.
* @param {string} searchParameter.author - The user ID of the agent's author.
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
@@ -480,13 +639,15 @@ const getListAgents = async (searchParameter) => {
const agents = (
await Agent.find(query, {
id: 1,
_id: 0,
_id: 1,
name: 1,
avatar: 1,
author: 1,
projectIds: 1,
description: 1,
// @deprecated - isCollaborative replaced by ACL permissions
isCollaborative: 1,
category: 1,
}).lean()
).map((agent) => {
if (agent.author?.toString() !== author) {
@@ -652,6 +813,14 @@ const generateActionMetadataHash = async (actionIds, actions) => {
return hashHex;
};
/**
* Counts the number of promoted agents.
* @returns {Promise<number>} - The count of promoted agents
*/
const countPromotedAgents = async () => {
const count = await Agent.countDocuments({ is_promoted: true });
return count;
};
/**
* Load a default agent based on the endpoint
@@ -669,6 +838,8 @@ module.exports = {
revertAgentVersion,
updateAgentProjects,
addAgentResourceFile,
getListAgentsByAccess,
removeAgentResourceFiles,
generateActionMetadataHash,
countPromotedAgents,
};

View File

@@ -6,6 +6,10 @@ const originalEnv = {
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
process.env.CREDS_IV = '0123456789abcdef';
jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn(),
}));
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { agentSchema } = require('@librechat/data-schemas');
@@ -23,6 +27,7 @@ const {
generateActionMetadataHash,
revertAgentVersion,
} = require('./Agent');
const { getCachedTools } = require('~/server/services/Config');
/**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
@@ -38,7 +43,7 @@ describe('models/Agent', () => {
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
});
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
@@ -406,8 +411,9 @@ describe('models/Agent', () => {
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
});
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
@@ -664,7 +670,7 @@ describe('models/Agent', () => {
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
});
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
@@ -1326,7 +1332,7 @@ describe('models/Agent', () => {
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
});
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
@@ -1508,7 +1514,7 @@ describe('models/Agent', () => {
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
});
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
@@ -1546,6 +1552,12 @@ describe('models/Agent', () => {
test('should test ephemeral agent loading logic', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({
tool1_mcp_server1: {},
tool2_mcp_server2: {},
another_tool: {},
});
const mockReq = {
user: { id: 'user123' },
body: {
@@ -1556,15 +1568,6 @@ describe('models/Agent', () => {
mcp: ['server1', 'server2'],
},
},
app: {
locals: {
availableTools: {
tool1_mcp_server1: {},
tool2_mcp_server2: {},
another_tool: {},
},
},
},
};
const result = await loadAgent({
@@ -1630,7 +1633,7 @@ describe('models/Agent', () => {
expect(result.version).toBe(1);
});
test('should return null when user is not author and agent has no projectIds', async () => {
test('should return agent even when user is not author (permissions checked at route level)', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
@@ -1651,12 +1654,18 @@ describe('models/Agent', () => {
model_parameters: { model: 'gpt-4' },
});
expect(result).toBeFalsy();
// With the new permission system, loadAgent returns the agent regardless of permissions
// Permission checks are handled at the route level via middleware
expect(result).toBeTruthy();
expect(result.id).toBe(agentId);
expect(result.name).toBe('Test Agent');
});
test('should handle ephemeral agent with no MCP servers', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({});
const mockReq = {
user: { id: 'user123' },
body: {
@@ -1667,11 +1676,6 @@ describe('models/Agent', () => {
mcp: [],
},
},
app: {
locals: {
availableTools: {},
},
},
};
const result = await loadAgent({
@@ -1692,16 +1696,13 @@ describe('models/Agent', () => {
test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({});
const mockReq = {
user: { id: 'user123' },
body: {
promptPrefix: 'Basic instructions',
},
app: {
locals: {
availableTools: {},
},
},
};
const result = await loadAgent({
@@ -1734,6 +1735,13 @@ describe('models/Agent', () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
const largeToolList = Array.from({ length: 100 }, (_, i) => `tool_${i}_mcp_server1`);
const availableTools = largeToolList.reduce((acc, tool) => {
acc[tool] = {};
return acc;
}, {});
getCachedTools.mockResolvedValue(availableTools);
const mockReq = {
user: { id: 'user123' },
body: {
@@ -1744,14 +1752,6 @@ describe('models/Agent', () => {
mcp: ['server1'],
},
},
app: {
locals: {
availableTools: largeToolList.reduce((acc, tool) => {
acc[tool] = {};
return acc;
}, {}),
},
},
};
const result = await loadAgent({
@@ -1766,7 +1766,7 @@ describe('models/Agent', () => {
}
});
test('should handle loadAgent with agent from different project', async () => {
test('should return agent from different project (permissions checked at route level)', async () => {
const authorId = new mongoose.Types.ObjectId();
const userId = new mongoose.Types.ObjectId();
const agentId = `agent_${uuidv4()}`;
@@ -1789,7 +1789,11 @@ describe('models/Agent', () => {
model_parameters: { model: 'gpt-4' },
});
expect(result).toBeFalsy();
// With the new permission system, loadAgent returns the agent regardless of permissions
// Permission checks are handled at the route level via middleware
expect(result).toBeTruthy();
expect(result.id).toBe(agentId);
expect(result.name).toBe('Project Agent');
});
});
});
@@ -1802,7 +1806,7 @@ describe('models/Agent', () => {
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
});
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
@@ -2272,6 +2276,13 @@ describe('models/Agent', () => {
test('should handle loadEphemeralAgent with malformed MCP tool names', async () => {
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
getCachedTools.mockResolvedValue({
malformed_tool_name: {}, // No mcp delimiter
tool__server1: {}, // Wrong delimiter
tool_mcp_server1: {}, // Correct format
tool_mcp_server2: {}, // Different server
});
const mockReq = {
user: { id: 'user123' },
body: {
@@ -2282,16 +2293,6 @@ describe('models/Agent', () => {
mcp: ['server1'],
},
},
app: {
locals: {
availableTools: {
malformed_tool_name: {}, // No mcp delimiter
tool__server1: {}, // Wrong delimiter
tool_mcp_server1: {}, // Correct format
tool_mcp_server2: {}, // Different server
},
},
},
};
const result = await loadAgent({
@@ -2357,7 +2358,7 @@ describe('models/Agent', () => {
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
});
}, 20000);
afterAll(async () => {
await mongoose.disconnect();

View File

@@ -1,4 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models');
@@ -98,10 +100,15 @@ module.exports = {
update.conversationId = newConversationId;
}
if (req.body.isTemporary) {
const expiredAt = new Date();
expiredAt.setDate(expiredAt.getDate() + 30);
update.expiredAt = expiredAt;
if (req?.body?.isTemporary) {
try {
const customConfig = await getCustomConfig();
update.expiredAt = createTempChatExpirationDate(customConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
update.expiredAt = null;
}
} else {
update.expiredAt = null;
}

View File

@@ -1,5 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { EToolResources } = require('librechat-data-provider');
const { EToolResources, FileContext } = require('librechat-data-provider');
const { File } = require('~/db/models');
/**
@@ -32,19 +32,19 @@ const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
* @returns {Promise<Array<MongoFile>>} Files that match the criteria
*/
const getToolFilesByIds = async (fileIds, toolResourceSet) => {
if (!fileIds || !fileIds.length) {
if (!fileIds || !fileIds.length || !toolResourceSet?.size) {
return [];
}
try {
const filter = {
file_id: { $in: fileIds },
$or: [],
};
if (toolResourceSet.size) {
filter.$or = [];
if (toolResourceSet.has(EToolResources.ocr)) {
filter.$or.push({ text: { $exists: true, $ne: null }, context: FileContext.agents });
}
if (toolResourceSet.has(EToolResources.file_search)) {
filter.$or.push({ embedded: true });
}

View File

@@ -1,5 +1,7 @@
const { z } = require('zod');
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
const { Message } = require('~/db/models');
const idSchema = z.string().uuid();
@@ -54,9 +56,14 @@ async function saveMessage(req, params, metadata) {
};
if (req?.body?.isTemporary) {
const expiredAt = new Date();
expiredAt.setDate(expiredAt.getDate() + 30);
update.expiredAt = expiredAt;
try {
const customConfig = await getCustomConfig();
update.expiredAt = createTempChatExpirationDate(customConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
update.expiredAt = null;
}
} else {
update.expiredAt = null;
}

View File

@@ -2,7 +2,6 @@ const {
CacheKeys,
SystemRoles,
roleDefaults,
PermissionTypes,
permissionsSchema,
removeNullishValues,
} = require('librechat-data-provider');

View File

@@ -1,346 +0,0 @@
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const { Conversation, SharedLink } = require('~/db/models');
const { getMessages } = require('./Message');
class ShareServiceError extends Error {
constructor(message, code) {
super(message);
this.name = 'ShareServiceError';
this.code = code;
}
}
const memoizedAnonymizeId = (prefix) => {
const memo = new Map();
return (id) => {
if (!memo.has(id)) {
memo.set(id, `${prefix}_${nanoid()}`);
}
return memo.get(id);
};
};
const anonymizeConvoId = memoizedAnonymizeId('convo');
const anonymizeAssistantId = memoizedAnonymizeId('a');
const anonymizeMessageId = (id) =>
id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id);
function anonymizeConvo(conversation) {
if (!conversation) {
return null;
}
const newConvo = { ...conversation };
if (newConvo.assistant_id) {
newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id);
}
return newConvo;
}
function anonymizeMessages(messages, newConvoId) {
if (!Array.isArray(messages)) {
return [];
}
const idMap = new Map();
return messages.map((message) => {
const newMessageId = anonymizeMessageId(message.messageId);
idMap.set(message.messageId, newMessageId);
const anonymizedAttachments = message.attachments?.map((attachment) => {
return {
...attachment,
messageId: newMessageId,
conversationId: newConvoId,
};
});
return {
...message,
messageId: newMessageId,
parentMessageId:
idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId),
conversationId: newConvoId,
model: message.model?.startsWith('asst_')
? anonymizeAssistantId(message.model)
: message.model,
attachments: anonymizedAttachments,
};
});
}
async function getSharedMessages(shareId) {
try {
const share = await SharedLink.findOne({ shareId, isPublic: true })
.populate({
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();
if (!share?.conversationId || !share.isPublic) {
return null;
}
const newConvoId = anonymizeConvoId(share.conversationId);
const result = {
...share,
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
};
return result;
} catch (error) {
logger.error('[getShare] Error getting share link', {
error: error.message,
shareId,
});
throw new ShareServiceError('Error getting share link', 'SHARE_FETCH_ERROR');
}
}
async function getSharedLinks(user, pageParam, pageSize, isPublic, sortBy, sortDirection, search) {
try {
const query = { user, isPublic };
if (pageParam) {
if (sortDirection === 'desc') {
query[sortBy] = { $lt: pageParam };
} else {
query[sortBy] = { $gt: pageParam };
}
}
if (search && search.trim()) {
try {
const searchResults = await Conversation.meiliSearch(search);
if (!searchResults?.hits?.length) {
return {
links: [],
nextCursor: undefined,
hasNextPage: false,
};
}
const conversationIds = searchResults.hits.map((hit) => hit.conversationId);
query['conversationId'] = { $in: conversationIds };
} catch (searchError) {
logger.error('[getSharedLinks] Meilisearch error', {
error: searchError.message,
user,
});
return {
links: [],
nextCursor: undefined,
hasNextPage: false,
};
}
}
const sort = {};
sort[sortBy] = sortDirection === 'desc' ? -1 : 1;
if (Array.isArray(query.conversationId)) {
query.conversationId = { $in: query.conversationId };
}
const sharedLinks = await SharedLink.find(query)
.sort(sort)
.limit(pageSize + 1)
.select('-__v -user')
.lean();
const hasNextPage = sharedLinks.length > pageSize;
const links = sharedLinks.slice(0, pageSize);
const nextCursor = hasNextPage ? links[links.length - 1][sortBy] : undefined;
return {
links: links.map((link) => ({
shareId: link.shareId,
title: link?.title || 'Untitled',
isPublic: link.isPublic,
createdAt: link.createdAt,
conversationId: link.conversationId,
})),
nextCursor,
hasNextPage,
};
} catch (error) {
logger.error('[getSharedLinks] Error getting shares', {
error: error.message,
user,
});
throw new ShareServiceError('Error getting shares', 'SHARES_FETCH_ERROR');
}
}
async function deleteAllSharedLinks(user) {
try {
const result = await SharedLink.deleteMany({ user });
return {
message: 'All shared links deleted successfully',
deletedCount: result.deletedCount,
};
} catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', {
error: error.message,
user,
});
throw new ShareServiceError('Error deleting shared links', 'BULK_DELETE_ERROR');
}
}
async function createSharedLink(user, conversationId) {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const [existingShare, conversationMessages] = await Promise.all([
SharedLink.findOne({ conversationId, isPublic: true }).select('-_id -__v -user').lean(),
getMessages({ conversationId }),
]);
if (existingShare && existingShare.isPublic) {
throw new ShareServiceError('Share already exists', 'SHARE_EXISTS');
} else if (existingShare) {
await SharedLink.deleteOne({ conversationId });
}
const conversation = await Conversation.findOne({ conversationId }).lean();
const title = conversation?.title || 'Untitled';
const shareId = nanoid();
await SharedLink.create({
shareId,
conversationId,
messages: conversationMessages,
title,
user,
});
return { shareId, conversationId };
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', {
error: error.message,
user,
conversationId,
});
throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR');
}
}
async function getSharedLink(user, conversationId) {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const share = await SharedLink.findOne({ conversationId, user, isPublic: true })
.select('shareId -_id')
.lean();
if (!share) {
return { shareId: null, success: false };
}
return { shareId: share.shareId, success: true };
} catch (error) {
logger.error('[getSharedLink] Error getting shared link', {
error: error.message,
user,
conversationId,
});
throw new ShareServiceError('Error getting shared link', 'SHARE_FETCH_ERROR');
}
}
async function updateSharedLink(user, shareId) {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const share = await SharedLink.findOne({ shareId }).select('-_id -__v -user').lean();
if (!share) {
throw new ShareServiceError('Share not found', 'SHARE_NOT_FOUND');
}
const [updatedMessages] = await Promise.all([
getMessages({ conversationId: share.conversationId }),
]);
const newShareId = nanoid();
const update = {
messages: updatedMessages,
user,
shareId: newShareId,
};
const updatedShare = await SharedLink.findOneAndUpdate({ shareId, user }, update, {
new: true,
upsert: false,
runValidators: true,
}).lean();
if (!updatedShare) {
throw new ShareServiceError('Share update failed', 'SHARE_UPDATE_ERROR');
}
anonymizeConvo(updatedShare);
return { shareId: newShareId, conversationId: updatedShare.conversationId };
} catch (error) {
logger.error('[updateSharedLink] Error updating shared link', {
error: error.message,
user,
shareId,
});
throw new ShareServiceError(
error.code === 'SHARE_UPDATE_ERROR' ? error.message : 'Error updating shared link',
error.code || 'SHARE_UPDATE_ERROR',
);
}
}
async function deleteSharedLink(user, shareId) {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
try {
const result = await SharedLink.findOneAndDelete({ shareId, user }).lean();
if (!result) {
return null;
}
return {
success: true,
shareId,
message: 'Share deleted successfully',
};
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', {
error: error.message,
user,
shareId,
});
throw new ShareServiceError('Error deleting shared link', 'SHARE_DELETE_ERROR');
}
}
module.exports = {
getSharedLink,
getSharedLinks,
createSharedLink,
updateSharedLink,
deleteSharedLink,
getSharedMessages,
deleteAllSharedLinks,
};

View File

@@ -1,42 +0,0 @@
const { findToken, updateToken, createToken } = require('~/models');
const { encryptV2 } = require('~/server/utils/crypto');
/**
* Handles the OAuth token by creating or updating the token.
* @param {object} fields
* @param {string} fields.userId - The user's ID.
* @param {string} fields.token - The full token to store.
* @param {string} fields.identifier - Unique, alternative identifier for the token.
* @param {number} fields.expiresIn - The number of seconds until the token expires.
* @param {object} fields.metadata - Additional metadata to store with the token.
* @param {string} [fields.type="oauth"] - The type of token. Default is 'oauth'.
*/
async function handleOAuthToken({
token,
userId,
identifier,
expiresIn,
metadata,
type = 'oauth',
}) {
const encrypedToken = await encryptV2(token);
const tokenData = {
type,
userId,
metadata,
identifier,
token: encrypedToken,
expiresIn: parseInt(expiresIn, 10) || 3600,
};
const existingToken = await findToken({ userId, identifier });
if (existingToken) {
return await updateToken({ identifier }, tokenData);
} else {
return await createToken(tokenData);
}
}
module.exports = {
handleOAuthToken,
};

View File

@@ -1,6 +1,6 @@
const mongoose = require('mongoose');
const { getRandomValues } = require('@librechat/api');
const { logger, hashToken } = require('@librechat/data-schemas');
const { getRandomValues } = require('~/server/utils/crypto');
const { createToken, findToken } = require('~/models');
/**

View File

@@ -78,7 +78,7 @@ const tokenValues = Object.assign(
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
'o4-mini': { prompt: 1.1, completion: 4.4 },
'o3-mini': { prompt: 1.1, completion: 4.4 },
o3: { prompt: 10, completion: 40 },
o3: { prompt: 2, completion: 8 },
'o1-mini': { prompt: 1.1, completion: 4.4 },
'o1-preview': { prompt: 15, completion: 60 },
o1: { prompt: 15, completion: 60 },

View File

@@ -34,29 +34,28 @@
},
"homepage": "https://librechat.ai",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/sdk": "^0.52.0",
"@aws-sdk/client-s3": "^3.758.0",
"@aws-sdk/s3-request-presigner": "^3.758.0",
"@azure/identity": "^4.7.0",
"@azure/search-documents": "^12.0.0",
"@azure/storage-blob": "^12.27.0",
"@google/generative-ai": "^0.23.0",
"@google/generative-ai": "^0.24.0",
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
"@langchain/community": "^0.3.44",
"@langchain/core": "^0.3.57",
"@langchain/google-genai": "^0.2.9",
"@langchain/google-vertexai": "^0.2.9",
"@langchain/community": "^0.3.47",
"@langchain/core": "^0.3.60",
"@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.38",
"@librechat/auth": "*",
"@librechat/agents": "^2.4.46",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
"bcryptjs": "^2.4.3",
"cohere-ai": "^7.9.1",
"compression": "^1.7.4",
"connect-redis": "^7.1.0",
"cookie": "^0.7.2",
@@ -121,7 +120,6 @@
"jest": "^29.7.0",
"mongodb-memory-server": "^10.1.3",
"nodemon": "^3.0.3",
"supertest": "^7.1.0",
"ts-jest": "^29.4.0"
"supertest": "^7.1.0"
}
}
}

View File

@@ -169,9 +169,6 @@ function disposeClient(client) {
client.isGenerativeModel = null;
}
// Properties specific to OpenAIClient
if (client.ChatGPTClient) {
client.ChatGPTClient = null;
}
if (client.completionsUrl) {
client.completionsUrl = null;
}

View File

@@ -1,282 +0,0 @@
const { getResponseSender, Constants } = require('librechat-data-provider');
const {
handleAbortError,
createAbortController,
cleanupAbortController,
} = require('~/server/middleware');
const {
disposeClient,
processReqData,
clientRegistry,
requestDataMap,
} = require('~/server/cleanup');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { saveMessage } = require('~/models');
const { logger } = require('~/config');
const AskController = async (req, res, next, initializeClient, addTitle) => {
let {
text,
endpointOption,
conversationId,
modelDisplayLabel,
parentMessageId = null,
overrideParentMessageId = null,
} = req.body;
let client = null;
let abortKey = null;
let cleanupHandlers = [];
let clientRef = null;
logger.debug('[AskController]', {
text,
conversationId,
...endpointOption,
modelsConfig: endpointOption?.modelsConfig ? 'exists' : '',
});
let userMessage = null;
let userMessagePromise = null;
let promptTokens = null;
let userMessageId = null;
let responseMessageId = null;
let getAbortData = null;
const sender = getResponseSender({
...endpointOption,
model: endpointOption.modelOptions.model,
modelDisplayLabel,
});
const initialConversationId = conversationId;
const newConvo = !initialConversationId;
const userId = req.user.id;
let reqDataContext = {
userMessage,
userMessagePromise,
responseMessageId,
promptTokens,
conversationId,
userMessageId,
};
const updateReqData = (data = {}) => {
reqDataContext = processReqData(data, reqDataContext);
abortKey = reqDataContext.abortKey;
userMessage = reqDataContext.userMessage;
userMessagePromise = reqDataContext.userMessagePromise;
responseMessageId = reqDataContext.responseMessageId;
promptTokens = reqDataContext.promptTokens;
conversationId = reqDataContext.conversationId;
userMessageId = reqDataContext.userMessageId;
};
let { onProgress: progressCallback, getPartialText } = createOnProgress();
const performCleanup = () => {
logger.debug('[AskController] Performing cleanup');
if (Array.isArray(cleanupHandlers)) {
for (const handler of cleanupHandlers) {
try {
if (typeof handler === 'function') {
handler();
}
} catch (e) {
// Ignore
}
}
}
if (abortKey) {
logger.debug('[AskController] Cleaning up abort controller');
cleanupAbortController(abortKey);
abortKey = null;
}
if (client) {
disposeClient(client);
client = null;
}
reqDataContext = null;
userMessage = null;
userMessagePromise = null;
promptTokens = null;
getAbortData = null;
progressCallback = null;
endpointOption = null;
cleanupHandlers = null;
addTitle = null;
if (requestDataMap.has(req)) {
requestDataMap.delete(req);
}
logger.debug('[AskController] Cleanup completed');
};
try {
({ client } = await initializeClient({ req, res, endpointOption }));
if (clientRegistry && client) {
clientRegistry.register(client, { userId }, client);
}
if (client) {
requestDataMap.set(req, { client });
}
clientRef = new WeakRef(client);
getAbortData = () => {
const currentClient = clientRef?.deref();
const currentText =
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
return {
sender,
conversationId,
messageId: reqDataContext.responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId,
text: currentText,
userMessage: userMessage,
userMessagePromise: userMessagePromise,
promptTokens: reqDataContext.promptTokens,
};
};
const { onStart, abortController } = createAbortController(
req,
res,
getAbortData,
updateReqData,
);
const closeHandler = () => {
logger.debug('[AskController] Request closed');
if (!abortController || abortController.signal.aborted || abortController.requestCompleted) {
return;
}
abortController.abort();
logger.debug('[AskController] Request aborted on close');
};
res.on('close', closeHandler);
cleanupHandlers.push(() => {
try {
res.removeListener('close', closeHandler);
} catch (e) {
// Ignore
}
});
const messageOptions = {
user: userId,
parentMessageId,
conversationId: reqDataContext.conversationId,
overrideParentMessageId,
getReqData: updateReqData,
onStart,
abortController,
progressCallback,
progressOptions: {
res,
},
};
/** @type {TMessage} */
let response = await client.sendMessage(text, messageOptions);
response.endpoint = endpointOption.endpoint;
const databasePromise = response.databasePromise;
delete response.databasePromise;
const { conversation: convoData = {} } = await databasePromise;
const conversation = { ...convoData };
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
const latestUserMessage = reqDataContext.userMessage;
if (client?.options?.attachments && latestUserMessage) {
latestUserMessage.files = client.options.attachments;
if (endpointOption?.modelOptions?.model) {
conversation.model = endpointOption.modelOptions.model;
}
delete latestUserMessage.image_urls;
}
if (!abortController.signal.aborted) {
const finalResponseMessage = { ...response };
sendMessage(res, {
final: true,
conversation,
title: conversation.title,
requestMessage: latestUserMessage,
responseMessage: finalResponseMessage,
});
res.end();
if (client?.savedMessageIds && !client.savedMessageIds.has(response.messageId)) {
await saveMessage(
req,
{ ...finalResponseMessage, user: userId },
{ context: 'api/server/controllers/AskController.js - response end' },
);
}
}
if (!client?.skipSaveUserMessage && latestUserMessage) {
await saveMessage(req, latestUserMessage, {
context: "api/server/controllers/AskController.js - don't skip saving user message",
});
}
if (typeof addTitle === 'function' && parentMessageId === Constants.NO_PARENT && newConvo) {
addTitle(req, {
text,
response: { ...response },
client,
})
.then(() => {
logger.debug('[AskController] Title generation started');
})
.catch((err) => {
logger.error('[AskController] Error in title generation', err);
})
.finally(() => {
logger.debug('[AskController] Title generation completed');
performCleanup();
});
} else {
performCleanup();
}
} catch (error) {
logger.error('[AskController] Error handling request', error);
let partialText = '';
try {
const currentClient = clientRef?.deref();
partialText =
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
} catch (getTextError) {
logger.error('[AskController] Error calling getText() during error handling', getTextError);
}
handleAbortError(res, req, error, {
sender,
partialText,
conversationId: reqDataContext.conversationId,
messageId: reqDataContext.responseMessageId,
parentMessageId: overrideParentMessageId ?? reqDataContext.userMessageId ?? parentMessageId,
userMessageId: reqDataContext.userMessageId,
})
.catch((err) => {
logger.error('[AskController] Error in `handleAbortError` during catch block', err);
})
.finally(() => {
performCleanup();
});
}
};
module.exports = AskController;

View File

@@ -1,24 +1,21 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
registerUser,
requestPasswordReset,
setOpenIDAuthTokens,
resetPassword,
setAuthTokens,
setOpenIDAuthTokens,
} = require('@librechat/auth');
registerUser,
} = require('~/server/services/AuthService');
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
const { getOpenIdConfig } = require('@librechat/auth');
const { isEnabled } = require('~/server/utils');
const { isEmailDomainAllowed } = require('~/server/services/domains');
const { getBalanceConfig } = require('~/server/services/Config');
const { getOpenIdConfig } = require('~/strategies');
const registrationController = async (req, res) => {
try {
const isEmailDomAllowed = await isEmailDomainAllowed(req.body.email);
const balanceConfig = await getBalanceConfig();
const response = await registerUser(req.body, {}, isEmailDomAllowed, balanceConfig);
const response = await registerUser(req.body);
const { status, message } = response;
res.status(status).send({ message });
} catch (err) {
@@ -68,11 +65,9 @@ const refreshController = async (req, res) => {
if (!refreshToken) {
return res.status(200).send('Refresh token not provided');
}
if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
try {
const openIdConfig = getOpenIdConfig();
const openIdClient = await import('openid-client');
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
const claims = tokenset.claims();
const user = await findUser({ email: claims.email });

View File

@@ -1,3 +1,5 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getResponseSender } = require('librechat-data-provider');
const {
handleAbortError,
@@ -10,9 +12,8 @@ const {
clientRegistry,
requestDataMap,
} = require('~/server/cleanup');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { createOnProgress } = require('~/server/utils');
const { saveMessage } = require('~/models');
const { logger } = require('~/config');
const EditController = async (req, res, next, initializeClient) => {
let {
@@ -84,7 +85,7 @@ const EditController = async (req, res, next, initializeClient) => {
}
if (abortKey) {
logger.debug('[AskController] Cleaning up abort controller');
logger.debug('[EditController] Cleaning up abort controller');
cleanupAbortController(abortKey);
abortKey = null;
}
@@ -198,7 +199,7 @@ const EditController = async (req, res, next, initializeClient) => {
const finalUserMessage = reqDataContext.userMessage;
const finalResponseMessage = { ...response };
sendMessage(res, {
sendEvent(res, {
final: true,
conversation,
title: conversation.title,

View File

@@ -0,0 +1,437 @@
/**
* @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
*/
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const {
getAvailableRoles,
ensurePrincipalExists,
getEffectivePermissions,
ensureGroupPrincipalExists,
bulkUpdateResourcePermissions,
} = require('~/server/services/PermissionService');
const { AclEntry } = require('~/db/models');
const {
searchPrincipals: searchLocalPrincipals,
sortPrincipalsByRelevance,
calculateRelevanceScore,
} = require('~/models');
const {
searchEntraIdPrincipals,
entraIdPrincipalFeatureEnabled,
} = require('~/server/services/GraphApiService');
/**
* Generic controller for resource permission endpoints
* Delegates validation and logic to PermissionService
*/
/**
* Bulk update permissions for a resource (grant, update, remove)
* @route PUT /api/{resourceType}/{resourceId}/permissions
* @param {Object} req - Express request object
* @param {Object} req.params - Route parameters
* @param {string} req.params.resourceType - Resource type (e.g., 'agent')
* @param {string} req.params.resourceId - Resource ID
* @param {TUpdateResourcePermissionsRequest} req.body - Request body
* @param {Object} res - Express response object
* @returns {Promise<TUpdateResourcePermissionsResponse>} Updated permissions response
*/
const updateResourcePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
/** @type {TUpdateResourcePermissionsRequest} */
const { updated, removed, public: isPublic, publicAccessRoleId } = req.body;
const { id: userId } = req.user;
// Prepare principals for the service call
const updatedPrincipals = [];
const revokedPrincipals = [];
// Add updated principals
if (updated && Array.isArray(updated)) {
updatedPrincipals.push(...updated);
}
// Add public permission if enabled
if (isPublic && publicAccessRoleId) {
updatedPrincipals.push({
type: 'public',
id: null,
accessRoleId: publicAccessRoleId,
});
}
// Prepare authentication context for enhanced group member fetching
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
const authHeader = req.headers.authorization;
const accessToken =
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
const authContext =
useEntraId && accessToken
? {
accessToken,
sub: req.user.openidId,
}
: null;
// Ensure updated principals exist in the database before processing permissions
const validatedPrincipals = [];
for (const principal of updatedPrincipals) {
try {
let principalId;
if (principal.type === 'public') {
principalId = null; // Public principals don't need database records
} else if (principal.type === 'user') {
principalId = await ensurePrincipalExists(principal);
} else if (principal.type === 'group') {
// Pass authContext to enable member fetching for Entra ID groups when available
principalId = await ensureGroupPrincipalExists(principal, authContext);
} else {
logger.error(`Unsupported principal type: ${principal.type}`);
continue; // Skip invalid principal types
}
// Update the principal with the validated ID for ACL operations
validatedPrincipals.push({
...principal,
id: principalId,
});
} catch (error) {
logger.error('Error ensuring principal exists:', {
principal: {
type: principal.type,
id: principal.id,
name: principal.name,
source: principal.source,
},
error: error.message,
});
// Continue with other principals instead of failing the entire operation
continue;
}
}
// Add removed principals
if (removed && Array.isArray(removed)) {
revokedPrincipals.push(...removed);
}
// If public is disabled, add public to revoked list
if (!isPublic) {
revokedPrincipals.push({
type: 'public',
id: null,
});
}
const results = await bulkUpdateResourcePermissions({
resourceType,
resourceId,
updatedPrincipals: validatedPrincipals,
revokedPrincipals,
grantedBy: userId,
});
/** @type {TUpdateResourcePermissionsResponse} */
const response = {
message: 'Permissions updated successfully',
results: {
principals: results.granted,
public: isPublic || false,
publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
},
};
res.status(200).json(response);
} catch (error) {
logger.error('Error updating resource permissions:', error);
res.status(400).json({
error: 'Failed to update permissions',
details: error.message,
});
}
};
/**
* Get principals with their permission roles for a resource (UI-friendly format)
* Uses efficient aggregation pipeline to join User/Group data in single query
* @route GET /api/permissions/{resourceType}/{resourceId}
*/
const getResourcePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
// Use aggregation pipeline for efficient single-query data retrieval
const results = await AclEntry.aggregate([
// Match ACL entries for this resource
{
$match: {
resourceType,
resourceId: mongoose.Types.ObjectId.isValid(resourceId)
? mongoose.Types.ObjectId.createFromHexString(resourceId)
: resourceId,
},
},
// Lookup AccessRole information
{
$lookup: {
from: 'accessroles',
localField: 'roleId',
foreignField: '_id',
as: 'role',
},
},
// Lookup User information (for user principals)
{
$lookup: {
from: 'users',
localField: 'principalId',
foreignField: '_id',
as: 'userInfo',
},
},
// Lookup Group information (for group principals)
{
$lookup: {
from: 'groups',
localField: 'principalId',
foreignField: '_id',
as: 'groupInfo',
},
},
// Project final structure
{
$project: {
principalType: 1,
principalId: 1,
accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] },
userInfo: { $arrayElemAt: ['$userInfo', 0] },
groupInfo: { $arrayElemAt: ['$groupInfo', 0] },
},
},
]);
const principals = [];
let publicPermission = null;
// Process aggregation results
for (const result of results) {
if (result.principalType === 'public') {
publicPermission = {
public: true,
publicAccessRoleId: result.accessRoleId,
};
} else if (result.principalType === 'user' && result.userInfo) {
principals.push({
type: 'user',
id: result.userInfo._id.toString(),
name: result.userInfo.name || result.userInfo.username,
email: result.userInfo.email,
avatar: result.userInfo.avatar,
source: !result.userInfo._id ? 'entra' : 'local',
idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(),
accessRoleId: result.accessRoleId,
});
} else if (result.principalType === 'group' && result.groupInfo) {
principals.push({
type: 'group',
id: result.groupInfo._id.toString(),
name: result.groupInfo.name,
email: result.groupInfo.email,
description: result.groupInfo.description,
avatar: result.groupInfo.avatar,
source: result.groupInfo.source || 'local',
idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
accessRoleId: result.accessRoleId,
});
}
}
// Return response in format expected by frontend
const response = {
resourceType,
resourceId,
principals,
public: publicPermission?.public || false,
...(publicPermission?.publicAccessRoleId && {
publicAccessRoleId: publicPermission.publicAccessRoleId,
}),
};
res.status(200).json(response);
} catch (error) {
logger.error('Error getting resource permissions principals:', error);
res.status(500).json({
error: 'Failed to get permissions principals',
details: error.message,
});
}
};
/**
* Get available roles for a resource type
* @route GET /api/{resourceType}/roles
*/
const getResourceRoles = async (req, res) => {
try {
const { resourceType } = req.params;
const roles = await getAvailableRoles({ resourceType });
res.status(200).json(
roles.map((role) => ({
accessRoleId: role.accessRoleId,
name: role.name,
description: role.description,
permBits: role.permBits,
})),
);
} catch (error) {
logger.error('Error getting resource roles:', error);
res.status(500).json({
error: 'Failed to get roles',
details: error.message,
});
}
};
/**
* Get user's effective permission bitmask for a resource
* @route GET /api/{resourceType}/{resourceId}/effective
*/
const getUserEffectivePermissions = async (req, res) => {
try {
const { resourceType, resourceId } = req.params;
const { id: userId } = req.user;
const permissionBits = await getEffectivePermissions({
userId,
resourceType,
resourceId,
});
res.status(200).json({
permissionBits,
});
} catch (error) {
logger.error('Error getting user effective permissions:', error);
res.status(500).json({
error: 'Failed to get effective permissions',
details: error.message,
});
}
};
/**
* Search for users and groups to grant permissions
* Supports hybrid local database + Entra ID search when configured
* @route GET /api/permissions/search-principals
*/
const searchPrincipals = async (req, res) => {
try {
const { q: query, limit = 20, type } = req.query;
if (!query || query.trim().length === 0) {
return res.status(400).json({
error: 'Query parameter "q" is required and must not be empty',
});
}
if (query.trim().length < 2) {
return res.status(400).json({
error: 'Query must be at least 2 characters long',
});
}
const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50);
const typeFilter = ['user', 'group'].includes(type) ? type : null;
const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilter);
let allPrincipals = [...localResults];
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
if (useEntraId && localResults.length < searchLimit) {
try {
const graphTypeMap = {
user: 'users',
group: 'groups',
null: 'all',
};
const authHeader = req.headers.authorization;
const accessToken =
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
if (accessToken) {
const graphResults = await searchEntraIdPrincipals(
accessToken,
req.user.openidId,
query.trim(),
graphTypeMap[typeFilter],
searchLimit - localResults.length,
);
const localEmails = new Set(
localResults.map((p) => p.email?.toLowerCase()).filter(Boolean),
);
const localGroupSourceIds = new Set(
localResults.map((p) => p.idOnTheSource).filter(Boolean),
);
for (const principal of graphResults) {
const isDuplicateByEmail =
principal.email && localEmails.has(principal.email.toLowerCase());
const isDuplicateBySourceId =
principal.idOnTheSource && localGroupSourceIds.has(principal.idOnTheSource);
if (!isDuplicateByEmail && !isDuplicateBySourceId) {
allPrincipals.push(principal);
}
}
}
} catch (graphError) {
logger.warn('Graph API search failed, falling back to local results:', graphError.message);
}
}
const scoredResults = allPrincipals.map((item) => ({
...item,
_searchScore: calculateRelevanceScore(item, query.trim()),
}));
allPrincipals = sortPrincipalsByRelevance(scoredResults)
.slice(0, searchLimit)
.map((result) => {
const { _searchScore, ...resultWithoutScore } = result;
return resultWithoutScore;
});
res.status(200).json({
query: query.trim(),
limit: searchLimit,
type: typeFilter,
results: allPrincipals,
count: allPrincipals.length,
sources: {
local: allPrincipals.filter((r) => r.source === 'local').length,
entra: allPrincipals.filter((r) => r.source === 'entra').length,
},
});
} catch (error) {
logger.error('Error searching principals:', error);
res.status(500).json({
error: 'Failed to search principals',
details: error.message,
});
}
};
module.exports = {
updateResourcePermissions,
getResourcePermissions,
getResourceRoles,
getUserEffectivePermissions,
searchPrincipals,
};

View File

@@ -1,9 +1,11 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, AuthType } = require('librechat-data-provider');
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
const { getToolkitKey } = require('~/server/services/ToolService');
const { getCustomConfig } = require('~/server/services/Config');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { availableTools } = require('~/app/clients/tools');
const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache');
const { Constants } = require('librechat-data-provider');
/**
* Filters out duplicate plugins from the list of plugins.
@@ -84,6 +86,45 @@ const getAvailablePluginsController = async (req, res) => {
}
};
function createServerToolsCallback() {
/**
* @param {string} serverName
* @param {TPlugin[] | null} serverTools
*/
return async function (serverName, serverTools) {
try {
const mcpToolsCache = getLogStores(CacheKeys.MCP_TOOLS);
if (!serverName || !mcpToolsCache) {
return;
}
await mcpToolsCache.set(serverName, serverTools);
logger.debug(`MCP tools for ${serverName} added to cache.`);
} catch (error) {
logger.error('Error retrieving MCP tools from cache:', error);
}
};
}
function createGetServerTools() {
/**
* Retrieves cached server tools
* @param {string} serverName
* @returns {Promise<TPlugin[] | null>}
*/
return async function (serverName) {
try {
const mcpToolsCache = getLogStores(CacheKeys.MCP_TOOLS);
if (!mcpToolsCache) {
return null;
}
return await mcpToolsCache.get(serverName);
} catch (error) {
logger.error('Error retrieving MCP tools from cache:', error);
return null;
}
};
}
/**
* Retrieves and returns a list of available tools, either from a cache or by reading a plugin manifest file.
*
@@ -109,7 +150,16 @@ const getAvailableTools = async (req, res) => {
const customConfig = await getCustomConfig();
if (customConfig?.mcpServers != null) {
const mcpManager = getMCPManager();
pluginManifest = await mcpManager.loadManifestTools(pluginManifest);
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
const serverToolsCallback = createServerToolsCallback();
const getServerTools = createGetServerTools();
const mcpTools = await mcpManager.loadManifestTools({
flowManager,
serverToolsCallback,
getServerTools,
});
pluginManifest = [...mcpTools, ...pluginManifest];
}
/** @type {TPlugin[]} */
@@ -123,17 +173,57 @@ const getAvailableTools = async (req, res) => {
}
});
const toolDefinitions = req.app.locals.availableTools;
const tools = authenticatedPlugins.filter(
(plugin) =>
toolDefinitions[plugin.pluginKey] !== undefined ||
(plugin.toolkit === true &&
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey)),
);
const toolDefinitions = await getCachedTools({ includeGlobal: true });
await cache.set(CacheKeys.TOOLS, tools);
res.status(200).json(tools);
const toolsOutput = [];
for (const plugin of authenticatedPlugins) {
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
const isToolkit =
plugin.toolkit === true &&
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey);
if (!isToolDefined && !isToolkit) {
continue;
}
const toolToAdd = { ...plugin };
if (!plugin.pluginKey.includes(Constants.mcp_delimiter)) {
toolsOutput.push(toolToAdd);
continue;
}
const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
if (!serverConfig?.customUserVars) {
toolsOutput.push(toolToAdd);
continue;
}
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length === 0) {
toolToAdd.authConfig = [];
toolToAdd.authenticated = true;
} else {
toolToAdd.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
}));
toolToAdd.authenticated = false;
}
toolsOutput.push(toolToAdd);
}
const finalTools = filterUniquePlugins(toolsOutput);
await cache.set(CacheKeys.TOOLS, finalTools);
res.status(200).json(finalTools);
} catch (error) {
logger.error('[getAvailableTools]', error);
res.status(500).json({ message: error.message });
}
};

View File

@@ -1,3 +1,4 @@
const { encryptV3 } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
verifyTOTP,
@@ -7,7 +8,6 @@ const {
generateBackupCodes,
} = require('~/server/services/twoFactorService');
const { getUserById, updateUser } = require('~/models');
const { encryptV3 } = require('~/server/utils/crypto');
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');

View File

@@ -1,5 +1,6 @@
const {
Tools,
Constants,
FileSources,
webSearchKeys,
extractWebSearchEnvVars,
@@ -17,12 +18,13 @@ const {
} = require('~/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
const { verifyEmail, resendVerificationEmail } = require('@librechat/auth');
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 } = require('~/db/models');
const { deleteAllSharedLinks } = require('~/models/Share');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteAllSharedLinks } = require('~/models');
const { getMCPManager } = require('~/config');
const getUserController = async (req, res) => {
/** @type {MongoUser} */
@@ -102,10 +104,22 @@ const updateUserPluginsController = async (req, res) => {
}
let keys = Object.keys(auth);
if (keys.length === 0 && pluginKey !== Tools.web_search) {
const values = Object.values(auth); // Used in 'install' block
const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter);
// Early exit condition:
// If keys are empty (meaning auth: {} was likely sent for uninstall, or auth was empty for install)
// AND it's not web_search (which has special key handling to populate `keys` for uninstall)
// AND it's NOT (an uninstall action FOR an MCP tool - we need to proceed for this case to clear all its auth)
// THEN return.
if (
keys.length === 0 &&
pluginKey !== Tools.web_search &&
!(action === 'uninstall' && isMCPTool)
) {
return res.status(200).send();
}
const values = Object.values(auth);
/** @type {number} */
let status = 200;
@@ -132,16 +146,53 @@ const updateUserPluginsController = async (req, res) => {
}
}
} else if (action === 'uninstall') {
for (let i = 0; i < keys.length; i++) {
authService = await deleteUserPluginAuth(user.id, keys[i]);
// const isMCPTool was defined earlier
if (isMCPTool && keys.length === 0) {
// This handles the case where auth: {} is sent for an MCP tool uninstall.
// It means "delete all credentials associated with this MCP pluginKey".
authService = await deleteUserPluginAuth(user.id, null, true, pluginKey);
if (authService instanceof Error) {
logger.error('[authService]', authService);
logger.error(
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
authService,
);
({ status, message } = authService);
}
} else {
// This handles:
// 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
// 2. Other tools uninstall (if keys were provided).
// 3. MCP tool uninstall if specific keys were provided in `auth` (not current frontend behavior).
// If keys is empty for non-MCP tools (and not web_search), this loop won't run, and nothing is deleted.
for (let i = 0; i < keys.length; i++) {
authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
if (authService instanceof Error) {
logger.error('[authService] Error deleting specific auth key:', authService);
({ status, message } = authService);
}
}
}
}
if (status === 200) {
// If auth was updated successfully, disconnect MCP sessions as they might use these credentials
if (pluginKey.startsWith(Constants.mcp_prefix)) {
try {
const mcpManager = getMCPManager(user.id);
if (mcpManager) {
logger.info(
`[updateUserPluginsController] Disconnecting MCP connections for user ${user.id} after plugin auth update for ${pluginKey}.`,
);
await mcpManager.disconnectUserConnections(user.id);
}
} catch (disconnectError) {
logger.error(
`[updateUserPluginsController] Error disconnecting MCP connections for user ${user.id} after plugin auth update:`,
disconnectError,
);
// Do not fail the request for this, but log it.
}
}
return res.status(status).send();
}

View File

@@ -0,0 +1,195 @@
const { duplicateAgent } = require('../v1');
const { getAgent, createAgent } = require('~/models/Agent');
const { getActions } = require('~/models/Action');
const { nanoid } = require('nanoid');
jest.mock('~/models/Agent');
jest.mock('~/models/Action');
jest.mock('nanoid');
describe('duplicateAgent', () => {
let req, res;
beforeEach(() => {
req = {
params: { id: 'agent_123' },
user: { id: 'user_456' },
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
jest.clearAllMocks();
});
it('should duplicate an agent successfully', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
author: 'user_789',
versions: [{ name: 'Test Agent', version: 1 }],
__v: 0,
};
const mockNewAgent = {
id: 'agent_new_123',
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
author: 'user_456',
versions: [
{
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
createdAt: new Date(),
updatedAt: new Date(),
},
],
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue(mockNewAgent);
await duplicateAgent(req, res);
expect(getAgent).toHaveBeenCalledWith({ id: 'agent_123' });
expect(getActions).toHaveBeenCalledWith({ agent_id: 'agent_123' }, true);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
id: 'agent_new_123',
author: 'user_456',
name: expect.stringContaining('Test Agent ('),
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
}),
);
expect(createAgent).toHaveBeenCalledWith(
expect.not.objectContaining({
versions: expect.anything(),
__v: expect.anything(),
}),
);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
agent: mockNewAgent,
actions: [],
});
});
it('should ensure duplicated agent has clean versions array without nested fields', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
description: 'Test Description',
versions: [
{
name: 'Test Agent',
versions: [{ name: 'Nested' }],
__v: 1,
},
],
__v: 2,
};
const mockNewAgent = {
id: 'agent_new_123',
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
versions: [
{
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
createdAt: new Date(),
updatedAt: new Date(),
},
],
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue(mockNewAgent);
await duplicateAgent(req, res);
expect(mockNewAgent.versions).toHaveLength(1);
const firstVersion = mockNewAgent.versions[0];
expect(firstVersion).not.toHaveProperty('versions');
expect(firstVersion).not.toHaveProperty('__v');
expect(mockNewAgent).not.toHaveProperty('__v');
expect(res.status).toHaveBeenCalledWith(201);
});
it('should return 404 if agent not found', async () => {
getAgent.mockResolvedValue(null);
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Agent not found',
status: 'error',
});
});
it('should handle tool_resources.ocr correctly', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
tool_resources: {
ocr: { enabled: true, config: 'test' },
other: { should: 'not be copied' },
},
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue({ id: 'agent_new_123' });
await duplicateAgent(req, res);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
tool_resources: {
ocr: { enabled: true, config: 'test' },
},
}),
);
});
it('should handle errors gracefully', async () => {
getAgent.mockRejectedValue(new Error('Database error'));
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Database error' });
});
});

View File

@@ -4,11 +4,13 @@ const {
sendEvent,
createRun,
Tokenizer,
checkAccess,
memoryInstructions,
createMemoryProcessor,
} = require('@librechat/api');
const {
Callback,
Providers,
GraphEvents,
formatMessage,
formatAgentMessages,
@@ -31,18 +33,29 @@ const {
} = require('librechat-data-provider');
const { DynamicStructuredTool } = require('@langchain/core/tools');
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
const { getCustomEndpointConfig, checkCapability } = require('~/server/services/Config');
const { createGetMCPAuthMap, checkCapability } = require('~/server/services/Config');
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { setMemory, deleteMemory, getFormattedMemories } = require('~/models');
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
const { checkAccess } = require('~/server/middleware/roles/access');
const { getProviderConfig } = require('~/server/services/Endpoints');
const BaseClient = require('~/app/clients/BaseClient');
const { getRoleByName } = require('~/models/Role');
const { loadAgent } = require('~/models/Agent');
const { getMCPManager } = require('~/config');
const omitTitleOptions = new Set([
'stream',
'thinking',
'streaming',
'clientOptions',
'thinkingConfig',
'thinkingBudget',
'includeThoughts',
'maxOutputTokens',
]);
/**
* @param {ServerRequest} req
* @param {Agent} agent
@@ -389,7 +402,12 @@ class AgentClient extends BaseClient {
if (user.personalization?.memories === false) {
return;
}
const hasAccess = await checkAccess(user, PermissionTypes.MEMORIES, [Permissions.USE]);
const hasAccess = await checkAccess({
user,
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE],
getRoleByName,
});
if (!hasAccess) {
logger.debug(
@@ -673,12 +691,14 @@ class AgentClient extends BaseClient {
hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
user: this.options.req.user,
},
recursionLimit: agentsEConfig?.recursionLimit,
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
signal: abortController.signal,
streamMode: 'values',
version: 'v2',
};
const getUserMCPAuthMap = await createGetMCPAuthMap();
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
payload,
@@ -798,6 +818,20 @@ class AgentClient extends BaseClient {
run.Graph.contentData = contentData;
}
try {
if (getUserMCPAuthMap) {
config.configurable.userMCPAuthMap = await getUserMCPAuthMap({
tools: agent.tools,
userId: this.options.req.user.id,
});
}
} catch (err) {
logger.error(
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent ${agent.id}`,
err,
);
}
await run.processStream({ messages }, config, {
keepContent: i !== 0,
tokenCounter: createTokenCounter(this.getEncoding()),
@@ -963,23 +997,26 @@ class AgentClient extends BaseClient {
throw new Error('Run not initialized');
}
const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator();
const endpoint = this.options.agent.endpoint;
const { req, res } = this.options;
const { req, res, agent } = this.options;
const endpoint = agent.endpoint;
/** @type {import('@librechat/agents').ClientOptions} */
let clientOptions = {
maxTokens: 75,
model: agent.model_parameters.model,
};
let endpointConfig = req.app.locals[endpoint];
const { getOptions, overrideProvider, customEndpointConfig } =
await getProviderConfig(endpoint);
/** @type {TEndpoint | undefined} */
const endpointConfig = req.app.locals[endpoint] ?? customEndpointConfig;
if (!endpointConfig) {
try {
endpointConfig = await getCustomEndpointConfig(endpoint);
} catch (err) {
logger.error(
'[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config',
err,
);
}
logger.warn(
'[api/server/controllers/agents/client.js #titleConvo] Error getting endpoint config',
);
}
if (
endpointConfig &&
endpointConfig.titleModel &&
@@ -987,30 +1024,50 @@ class AgentClient extends BaseClient {
) {
clientOptions.model = endpointConfig.titleModel;
}
const options = await getOptions({
req,
res,
optionsOnly: true,
overrideEndpoint: endpoint,
overrideModel: clientOptions.model,
endpointOption: { model_parameters: clientOptions },
});
let provider = options.provider ?? overrideProvider ?? agent.provider;
if (
endpoint === EModelEndpoint.azureOpenAI &&
clientOptions.model &&
this.options.agent.model_parameters.model !== clientOptions.model
options.llmConfig?.azureOpenAIApiInstanceName == null
) {
clientOptions =
(
await initOpenAI({
req,
res,
optionsOnly: true,
overrideModel: clientOptions.model,
overrideEndpoint: endpoint,
endpointOption: {
model_parameters: clientOptions,
},
})
)?.llmConfig ?? clientOptions;
provider = Providers.OPENAI;
}
if (/\b(o\d)\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
/** @type {import('@librechat/agents').ClientOptions} */
clientOptions = { ...options.llmConfig };
if (options.configOptions) {
clientOptions.configuration = options.configOptions;
}
// Ensure maxTokens is set for non-o1 models
if (!/\b(o\d)\b/i.test(clientOptions.model) && !clientOptions.maxTokens) {
clientOptions.maxTokens = 75;
} else if (/\b(o\d)\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
delete clientOptions.maxTokens;
}
clientOptions = Object.assign(
Object.fromEntries(
Object.entries(clientOptions).filter(([key]) => !omitTitleOptions.has(key)),
),
);
if (provider === Providers.GOOGLE) {
clientOptions.json = true;
}
try {
const titleResult = await this.run.generateTitle({
provider,
inputText: text,
contentParts: this.contentParts,
clientOptions,
@@ -1028,8 +1085,10 @@ class AgentClient extends BaseClient {
let input_tokens, output_tokens;
if (item.usage) {
input_tokens = item.usage.input_tokens || item.usage.inputTokens;
output_tokens = item.usage.output_tokens || item.usage.outputTokens;
input_tokens =
item.usage.prompt_tokens || item.usage.input_tokens || item.usage.inputTokens;
output_tokens =
item.usage.completion_tokens || item.usage.output_tokens || item.usage.outputTokens;
} else if (item.tokenUsage) {
input_tokens = item.tokenUsage.promptTokens;
output_tokens = item.tokenUsage.completionTokens;

View File

@@ -1,10 +1,10 @@
// errorHandler.js
const { logger } = require('~/config');
const getLogStores = require('~/cache/getLogStores');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, ViolationTypes } = require('librechat-data-provider');
const { sendResponse } = require('~/server/middleware/error');
const { recordUsage } = require('~/server/services/Threads');
const { getConvo } = require('~/models/Conversation');
const { sendResponse } = require('~/server/utils');
const getLogStores = require('~/cache/getLogStores');
/**
* @typedef {Object} ErrorHandlerContext
@@ -75,7 +75,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === 'azureAssistants'
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
: ''
}`;
return sendResponse(req, res, messageData, errorMessage);

View File

@@ -1,106 +0,0 @@
const { HttpsProxyAgent } = require('https-proxy-agent');
const { resolveHeaders } = require('librechat-data-provider');
const { createLLM } = require('~/app/clients/llm');
/**
* Initializes and returns a Language Learning Model (LLM) instance.
*
* @param {Object} options - Configuration options for the LLM.
* @param {string} options.model - The model identifier.
* @param {string} options.modelName - The specific name of the model.
* @param {number} options.temperature - The temperature setting for the model.
* @param {number} options.presence_penalty - The presence penalty for the model.
* @param {number} options.frequency_penalty - The frequency penalty for the model.
* @param {number} options.max_tokens - The maximum number of tokens for the model output.
* @param {boolean} options.streaming - Whether to use streaming for the model output.
* @param {Object} options.context - The context for the conversation.
* @param {number} options.tokenBuffer - The token buffer size.
* @param {number} options.initialMessageCount - The initial message count.
* @param {string} options.conversationId - The ID of the conversation.
* @param {string} options.user - The user identifier.
* @param {string} options.langchainProxy - The langchain proxy URL.
* @param {boolean} options.useOpenRouter - Whether to use OpenRouter.
* @param {Object} options.options - Additional options.
* @param {Object} options.options.headers - Custom headers for the request.
* @param {string} options.options.proxy - Proxy URL.
* @param {Object} options.options.req - The request object.
* @param {Object} options.options.res - The response object.
* @param {boolean} options.options.debug - Whether to enable debug mode.
* @param {string} options.apiKey - The API key for authentication.
* @param {Object} options.azure - Azure-specific configuration.
* @param {Object} options.abortController - The AbortController instance.
* @returns {Object} The initialized LLM instance.
*/
function initializeLLM(options) {
const {
model,
modelName,
temperature,
presence_penalty,
frequency_penalty,
max_tokens,
streaming,
user,
langchainProxy,
useOpenRouter,
options: { headers, proxy },
apiKey,
azure,
} = options;
const modelOptions = {
modelName: modelName || model,
temperature,
presence_penalty,
frequency_penalty,
user,
};
if (max_tokens) {
modelOptions.max_tokens = max_tokens;
}
const configOptions = {};
if (langchainProxy) {
configOptions.basePath = langchainProxy;
}
if (useOpenRouter) {
configOptions.basePath = 'https://openrouter.ai/api/v1';
configOptions.baseOptions = {
headers: {
'HTTP-Referer': 'https://librechat.ai',
'X-Title': 'LibreChat',
},
};
}
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
configOptions.baseOptions = {
headers: resolveHeaders({
...headers,
...configOptions?.baseOptions?.headers,
}),
};
}
if (proxy) {
configOptions.httpAgent = new HttpsProxyAgent(proxy);
configOptions.httpsAgent = new HttpsProxyAgent(proxy);
}
const llm = createLLM({
modelOptions,
configOptions,
openAIApiKey: apiKey,
azure,
streaming,
});
return llm;
}
module.exports = {
initializeLLM,
};

View File

@@ -1,3 +1,5 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const {
handleAbortError,
@@ -5,9 +7,7 @@ const {
cleanupAbortController,
} = require('~/server/middleware');
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
const { sendMessage } = require('~/server/utils');
const { saveMessage } = require('~/models');
const { logger } = require('~/config');
const AgentController = async (req, res, next, initializeClient, addTitle) => {
let {
@@ -206,7 +206,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
// Create a new response object with minimal copies
const finalResponse = { ...response };
sendMessage(res, {
sendEvent(res, {
final: true,
conversation,
title: conversation.title,

View File

@@ -1,11 +1,10 @@
const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { logger, PermissionBits } = require('@librechat/data-schemas');
const {
Tools,
Constants,
FileContext,
FileSources,
SystemRoles,
FileSources,
EToolResources,
actionDelimiter,
} = require('librechat-data-provider');
@@ -14,18 +13,24 @@ const {
createAgent,
updateAgent,
deleteAgent,
getListAgents,
getListAgentsByAccess,
countPromotedAgents,
revertAgentVersion,
} = require('~/models/Agent');
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
const {
grantPermission,
findAccessibleResources,
findPubliclyAccessibleResources,
hasPublicPermission,
} = require('~/server/services/PermissionService');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('@librechat/auth');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { filterFile } = require('~/server/services/Files/process');
const { updateAction, getActions } = require('~/models/Action');
const { updateAgentProjects } = require('~/models/Agent');
const { getProjectByName } = require('~/models/Project');
const { getCachedTools } = require('~/server/services/Config');
const { deleteFileByFilter } = require('~/models/File');
const { revertAgentVersion } = require('~/models/Agent');
const { logger } = require('~/config');
const { getCategoriesWithCounts } = require('~/models');
const systemTools = {
[Tools.execute_code]: true,
@@ -47,8 +52,9 @@ const createAgentHandler = async (req, res) => {
agentData.tools = [];
const availableTools = await getCachedTools({ includeGlobal: true });
for (const tool of tools) {
if (req.app.locals.availableTools[tool]) {
if (availableTools[tool]) {
agentData.tools.push(tool);
}
@@ -68,6 +74,27 @@ const createAgentHandler = async (req, res) => {
agentData.id = `agent_${nanoid()}`;
const agent = await createAgent(agentData);
// Automatically grant owner permissions to the creator
try {
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_owner',
grantedBy: userId,
});
logger.debug(
`[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
);
} catch (permissionError) {
logger.error(
`[createAgent] Failed to grant owner permissions for agent ${agent.id}:`,
permissionError,
);
}
res.status(201).json(agent);
} catch (error) {
logger.error('[/Agents] Error creating agent', error);
@@ -86,21 +113,14 @@ const createAgentHandler = async (req, res) => {
* @returns {Promise<Agent>} 200 - success response - application/json
* @returns {Error} 404 - Agent not found
*/
const getAgentHandler = async (req, res) => {
const getAgentHandler = async (req, res, expandProperties = false) => {
try {
const id = req.params.id;
const author = req.user.id;
let query = { id, author };
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, ['agentIds']);
if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) {
query = {
$or: [{ id, $in: globalProject.agentIds }, query],
};
}
const agent = await getAgent(query);
// Permissions are validated by middleware before calling this function
// Simply load the agent by ID
const agent = await getAgent({ id });
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
@@ -117,23 +137,45 @@ const getAgentHandler = async (req, res) => {
}
agent.author = agent.author.toString();
// @deprecated - isCollaborative replaced by ACL permissions
agent.isCollaborative = !!agent.isCollaborative;
// Check if agent is public
const isPublic = await hasPublicPermission({
resourceType: 'agent',
resourceId: agent._id,
requiredPermissions: PermissionBits.VIEW,
});
agent.isPublic = isPublic;
if (agent.author !== author) {
delete agent.author;
}
if (!agent.isCollaborative && agent.author !== author && req.user.role !== SystemRoles.ADMIN) {
if (!expandProperties) {
// VIEW permission: Basic agent info only
return res.status(200).json({
_id: agent._id,
id: agent.id,
name: agent.name,
description: agent.description,
avatar: agent.avatar,
author: agent.author,
provider: agent.provider,
model: agent.model,
projectIds: agent.projectIds,
// @deprecated - isCollaborative replaced by ACL permissions
isCollaborative: agent.isCollaborative,
isPublic: agent.isPublic,
version: agent.version,
// Safe metadata
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
});
}
// EDIT permission: Full agent details including sensitive configuration
return res.status(200).json(agent);
} catch (error) {
logger.error('[/Agents/:id] Error retrieving agent', error);
@@ -153,42 +195,20 @@ const getAgentHandler = async (req, res) => {
const updateAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const { projectIds, removeProjectIds, ...updateData } = req.body;
const isAdmin = req.user.role === SystemRoles.ADMIN;
const { _id, ...updateData } = req.body;
const existingAgent = await getAgent({ id });
const isAuthor = existingAgent.author.toString() === req.user.id;
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
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',
});
}
/** @type {boolean} */
const isProjectUpdate = (projectIds?.length ?? 0) > 0 || (removeProjectIds?.length ?? 0) > 0;
let updatedAgent =
Object.keys(updateData).length > 0
? await updateAgent({ id }, updateData, {
updatingUserId: req.user.id,
skipVersioning: isProjectUpdate,
})
: existingAgent;
if (isProjectUpdate) {
updatedAgent = await updateAgentProjects({
user: req.user,
agentId: id,
projectIds,
removeProjectIds,
});
}
if (updatedAgent.author) {
updatedAgent.author = updatedAgent.author.toString();
}
@@ -241,6 +261,8 @@ const duplicateAgentHandler = async (req, res) => {
createdAt: _createdAt,
updatedAt: _updatedAt,
tool_resources: _tool_resources = {},
versions: _versions,
__v: _v,
...cloneData
} = agent;
cloneData.name = `${agent.name} (${new Date().toLocaleString('en-US', {
@@ -306,6 +328,26 @@ const duplicateAgentHandler = async (req, res) => {
newAgentData.actions = agentActions;
const newAgent = await createAgent(newAgentData);
// Automatically grant owner permissions to the duplicator
try {
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: newAgent._id,
accessRoleId: 'agent_owner',
grantedBy: userId,
});
logger.debug(
`[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
);
} catch (permissionError) {
logger.error(
`[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`,
permissionError,
);
}
return res.status(201).json({
agent: newAgent,
actions: newActionsList,
@@ -332,7 +374,7 @@ const deleteAgentHandler = async (req, res) => {
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
}
await deleteAgent({ id, author: req.user.id });
await deleteAgent({ id });
return res.json({ message: 'Agent deleted' });
} catch (error) {
logger.error('[/Agents/:id] Error deleting Agent', error);
@@ -341,7 +383,7 @@ const deleteAgentHandler = async (req, res) => {
};
/**
*
* Lists agents using ACL-aware permissions (ownership + explicit shares).
* @route GET /Agents
* @param {object} req - Express Request
* @param {object} req.query - Request query
@@ -350,9 +392,64 @@ const deleteAgentHandler = async (req, res) => {
*/
const getListAgentsHandler = async (req, res) => {
try {
const data = await getListAgents({
author: req.user.id,
const userId = req.user.id;
const { category, search, limit, cursor, promoted } = req.query;
let requiredPermission = req.query.requiredPermission;
if (typeof requiredPermission === 'string') {
requiredPermission = parseInt(requiredPermission, 10);
if (isNaN(requiredPermission)) {
requiredPermission = PermissionBits.VIEW;
}
} else if (typeof requiredPermission !== 'number') {
requiredPermission = PermissionBits.VIEW;
}
// Base filter
const filter = {};
// Handle category filter - only apply if category is defined
if (category !== undefined && category.trim() !== '') {
filter.category = category;
}
// Handle promoted filter - only from query param
if (promoted === '1') {
filter.is_promoted = true;
} else if (promoted === '0') {
filter.is_promoted = { $ne: true };
}
// Handle search filter
if (search && search.trim() !== '') {
filter.$or = [
{ name: { $regex: search.trim(), $options: 'i' } },
{ description: { $regex: search.trim(), $options: 'i' } },
];
}
// Get agent IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({
userId,
resourceType: 'agent',
requiredPermissions: requiredPermission,
});
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: 'agent',
requiredPermissions: PermissionBits.VIEW,
});
// Use the new ACL-aware function
const data = await getListAgentsByAccess({
accessibleIds,
otherParams: filter,
limit,
after: cursor,
});
if (data?.data?.length) {
data.data = data.data.map((agent) => {
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
agent.isPublic = true;
}
return agent;
});
}
return res.json(data);
} catch (error) {
logger.error('[/Agents] Error listing Agents', error);
@@ -430,7 +527,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
};
promises.push(
await updateAgent({ id: agent_id, author: req.user.id }, data, {
await updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
}),
);
@@ -445,7 +542,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
try {
await fs.unlink(req.file.path);
logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
} catch (error) {
} catch {
logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
}
}
@@ -510,7 +607,48 @@ const revertAgentVersionHandler = async (req, res) => {
res.status(500).json({ error: error.message });
}
};
/**
* Get all agent categories with counts
*
* @param {Object} _req - Express request object (unused)
* @param {Object} res - Express response object
*/
const getAgentCategories = async (_req, res) => {
try {
const categories = await getCategoriesWithCounts();
const promotedCount = await countPromotedAgents();
const formattedCategories = categories.map((category) => ({
value: category.value,
label: category.label,
count: category.agentCount,
description: category.description,
}));
if (promotedCount > 0) {
formattedCategories.unshift({
value: 'promoted',
label: 'Promoted',
count: promotedCount,
description: 'Our recommended agents',
});
}
formattedCategories.push({
value: 'all',
label: 'All',
description: 'All available agents',
});
res.status(200).json(formattedCategories);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
res.status(500).json({
error: 'Failed to fetch agent categories',
userMessage: 'Unable to load categories. Please refresh the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
module.exports = {
createAgent: createAgentHandler,
getAgent: getAgentHandler,
@@ -520,4 +658,5 @@ module.exports = {
getListAgents: getListAgentsHandler,
uploadAgentAvatar: uploadAgentAvatarHandler,
revertAgentVersion: revertAgentVersionHandler,
getAgentCategories,
};

View File

@@ -1,4 +1,7 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Time,
Constants,
@@ -19,20 +22,20 @@ const {
addThreadMetadata,
saveAssistantMessage,
} = require('~/server/services/Threads');
const { sendResponse, sendMessage, sleep, countTokens } = require('~/server/utils');
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { formatMessage, createVisionPrompt } = require('~/app/clients/prompts');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { createRunBody } = require('~/server/services/createRunBody');
const { sendResponse } = require('~/server/middleware/error');
const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/**
* @route POST /
@@ -471,7 +474,7 @@ const chatV1 = async (req, res) => {
await Promise.all(promises);
const sendInitialResponse = () => {
sendMessage(res, {
sendEvent(res, {
sync: true,
conversationId,
// messages: previousMessages,
@@ -587,7 +590,7 @@ const chatV1 = async (req, res) => {
iconURL: endpointOption.iconURL,
};
sendMessage(res, {
sendEvent(res, {
final: true,
conversation,
requestMessage: {

View File

@@ -1,4 +1,7 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Time,
Constants,
@@ -22,15 +25,14 @@ const { createErrorHandler } = require('~/server/controllers/assistants/errors')
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { sendMessage, sleep, countTokens } = require('~/server/utils');
const { createRunBody } = require('~/server/services/createRunBody');
const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/**
* @route POST /
@@ -309,7 +311,7 @@ const chatV2 = async (req, res) => {
await Promise.all(promises);
const sendInitialResponse = () => {
sendMessage(res, {
sendEvent(res, {
sync: true,
conversationId,
// messages: previousMessages,
@@ -432,7 +434,7 @@ const chatV2 = async (req, res) => {
iconURL: endpointOption.iconURL,
};
sendMessage(res, {
sendEvent(res, {
final: true,
conversation,
requestMessage: {

View File

@@ -1,10 +1,10 @@
// errorHandler.js
const { sendResponse } = require('~/server/utils');
const { logger } = require('~/config');
const getLogStores = require('~/cache/getLogStores');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider');
const { getConvo } = require('~/models/Conversation');
const { recordUsage, checkMessageGaps } = require('~/server/services/Threads');
const { sendResponse } = require('~/server/middleware/error');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
/**
* @typedef {Object} ErrorHandlerContext
@@ -78,7 +78,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === 'azureAssistants'
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
: ''
}`;
return sendResponse(req, res, messageData, errorMessage);

View File

@@ -1,4 +1,5 @@
const fs = require('fs').promises;
const { logger } = require('@librechat/data-schemas');
const { FileContext } = require('librechat-data-provider');
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
@@ -6,9 +7,9 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { deleteAssistantActions } = require('~/server/services/ActionService');
const { updateAssistantDoc, getAssistants } = require('~/models/Assistant');
const { getOpenAIClient, fetchAssistants } = require('./helpers');
const { getCachedTools } = require('~/server/services/Config');
const { manifestToolMap } = require('~/app/clients/tools');
const { deleteFileByFilter } = require('~/models/File');
const { logger } = require('~/config');
/**
* Create an assistant.
@@ -30,21 +31,20 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
assistantData.tools = tools
.map((tool) => {
if (typeof tool !== 'string') {
return tool;
}
const toolDefinitions = req.app.locals.availableTools;
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return (
Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val)
);
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;
@@ -135,21 +135,21 @@ const patchAssistant = async (req, res) => {
append_current_datetime,
...updateData
} = req.body;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
updateData.tools = (updateData.tools ?? [])
.map((tool) => {
if (typeof tool !== 'string') {
return tool;
}
const toolDefinitions = req.app.locals.availableTools;
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return (
Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val)
);
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;

View File

@@ -1,10 +1,11 @@
const { logger } = require('@librechat/data-schemas');
const { ToolCallTypes } = require('librechat-data-provider');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { validateAndUpdateTool } = require('~/server/services/ActionService');
const { getCachedTools } = require('~/server/services/Config');
const { updateAssistantDoc } = require('~/models/Assistant');
const { manifestToolMap } = require('~/app/clients/tools');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/**
* Create an assistant.
@@ -27,21 +28,20 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
assistantData.tools = tools
.map((tool) => {
if (typeof tool !== 'string') {
return tool;
}
const toolDefinitions = req.app.locals.availableTools;
const toolDef = toolDefinitions[tool];
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
return (
Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val)
);
return Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
.map(([_, val]) => val);
}
return toolDef;
@@ -125,13 +125,13 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
let hasFileSearch = false;
for (const tool of updateData.tools ?? []) {
const toolDefinitions = req.app.locals.availableTools;
const toolDefinitions = await getCachedTools({ includeGlobal: true });
let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool;
if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
actualTool = Object.entries(toolDefinitions)
.filter(([key]) => key.startsWith(`${tool}_`))
// eslint-disable-next-line no-unused-vars
.map(([_, val]) => val);
} else if (!actualTool) {
continue;

View File

@@ -1,5 +1,5 @@
const { generate2FATempToken } = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('@librechat/auth');
const { setAuthTokens } = require('~/server/services/AuthService');
const { logger } = require('~/config');
const loginController = async (req, res) => {

View File

@@ -1,6 +1,6 @@
const cookies = require('cookie');
const { getOpenIdConfig } = require('@librechat/auth');
const { logoutUser } = require('@librechat/auth');
const { getOpenIdConfig } = require('~/strategies');
const { logoutUser } = require('~/server/services/AuthService');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');

View File

@@ -5,7 +5,7 @@ const {
getTOTPSecret,
verifyBackupCode,
} = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('@librechat/auth');
const { setAuthTokens } = require('~/server/services/AuthService');
const { getUserById } = require('~/models');
/**

View File

@@ -1,5 +1,7 @@
const { nanoid } = require('nanoid');
const { EnvVar } = require('@librechat/agents');
const { checkAccess } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Tools,
AuthType,
@@ -13,9 +15,8 @@ const { processCodeOutput } = require('~/server/services/Files/Code/process');
const { createToolCall, getToolCallsByConvo } = require('~/models/ToolCall');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { loadTools } = require('~/app/clients/tools/util');
const { checkAccess } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const { getMessage } = require('~/models/Message');
const { logger } = require('~/config');
const fieldsMap = {
[Tools.execute_code]: [EnvVar.CODE_API_KEY],
@@ -79,6 +80,7 @@ const verifyToolAuth = async (req, res) => {
throwError: false,
});
} catch (error) {
logger.error('Error loading auth values', error);
res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED });
return;
}
@@ -132,7 +134,12 @@ const callTool = async (req, res) => {
logger.debug(`[${toolId}/call] User: ${req.user.id}`);
let hasAccess = true;
if (toolAccessPermType[toolId]) {
hasAccess = await checkAccess(req.user, toolAccessPermType[toolId], [Permissions.USE]);
hasAccess = await checkAccess({
user: req.user,
permissionType: toolAccessPermType[toolId],
permissions: [Permissions.USE],
getRoleByName,
});
}
if (!hasAccess) {
logger.warn(

View File

@@ -1,29 +1,27 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
require('module-alias')({ base: path.resolve(__dirname, '..') });
const cors = require('cors');
const axios = require('axios');
const express = require('express');
const compression = require('compression');
const passport = require('passport');
const mongoSanitize = require('express-mongo-sanitize');
const fs = require('fs');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const { connectDb, indexSync } = require('~/db');
const { initAuth, passportLogin, ldapLogin, jwtLogin } = require('@librechat/auth');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const validateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const errorController = require('./controllers/ErrorController');
const initializeMCP = require('./services/initializeMCP');
const configureSocialLogins = require('./socialLogins');
const AppService = require('./services/AppService');
const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex');
const routes = require('./routes');
const { getBalanceConfig } = require('./services/Config');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { FileSources } = require('librechat-data-provider');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
@@ -38,15 +36,12 @@ const startServer = async () => {
if (typeof Bun !== 'undefined') {
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
}
const mongooseInstance = await connectDb();
const balanceConfig = await getBalanceConfig();
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER ?? FileSources.local);
// initialize the auth package
initAuth(mongooseInstance, balanceConfig, saveBuffer);
await connectDb();
logger.info('Connected to MongoDB');
await indexSync();
indexSync().catch((err) => {
logger.error('[indexSync] Background sync failed:', err);
});
app.disable('x-powered-by');
app.set('trust proxy', trusted_proxy);
@@ -102,7 +97,6 @@ const startServer = async () => {
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/user', routes.user);
app.use('/api/ask', routes.ask);
app.use('/api/search', routes.search);
app.use('/api/edit', routes.edit);
app.use('/api/messages', routes.messages);
@@ -123,9 +117,11 @@ const startServer = async () => {
app.use('/api/roles', routes.roles);
app.use('/api/agents', routes.agents);
app.use('/api/banner', routes.banner);
app.use('/api/bedrock', routes.bedrock);
app.use('/api/memories', routes.memories);
app.use('/api/permissions', routes.accessPermissions);
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
app.use((req, res) => {
res.set({
@@ -149,6 +145,8 @@ const startServer = async () => {
} else {
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
}
initializeMCP(app);
});
};
@@ -191,5 +189,5 @@ process.on('uncaughtException', (err) => {
process.exit(1);
});
// export app for easier testing purposes
/** Export app for easier testing purposes */
module.exports = app;

View File

@@ -1,13 +1,13 @@
// abortMiddleware.js
const { logger } = require('@librechat/data-schemas');
const { countTokens, isEnabled, sendEvent } = require('@librechat/api');
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const { sendMessage, sendError, countTokens, isEnabled } = require('~/server/utils');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const clearPendingReq = require('~/cache/clearPendingReq');
const { sendError } = require('~/server/middleware/error');
const { spendTokens } = require('~/models/spendTokens');
const abortControllers = require('./abortControllers');
const { saveMessage, getConvo } = require('~/models');
const { abortRun } = require('./abortRun');
const { logger } = require('~/config');
const abortDataMap = new WeakMap();
@@ -101,7 +101,7 @@ async function abortMessage(req, res) {
cleanupAbortController(abortKey);
if (res.headersSent && finalEvent) {
return sendMessage(res, finalEvent);
return sendEvent(res, finalEvent);
}
res.setHeader('Content-Type', 'application/json');
@@ -174,7 +174,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
* @param {string} responseMessageId
*/
const onStart = (userMessage, responseMessageId) => {
sendMessage(res, { message: userMessage, created: true });
sendEvent(res, { message: userMessage, created: true });
const abortKey = userMessage?.conversationId ?? req.user.id;
getReqData({ abortKey });

View File

@@ -1,11 +1,11 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, RunStatus, isUUID } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { checkMessageGaps, recordUsage } = require('~/server/services/Threads');
const { deleteMessages } = require('~/models/Message');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { sendMessage } = require('~/server/utils');
const { logger } = require('~/config');
const three_minutes = 1000 * 60 * 3;
@@ -34,7 +34,7 @@ async function abortRun(req, res) {
const [thread_id, run_id] = runValues.split(':');
if (!run_id) {
logger.warn('[abortRun] Couldn\'t find run for cancel request', { thread_id });
logger.warn("[abortRun] Couldn't find run for cancel request", { thread_id });
return res.status(204).send({ message: 'Run not found' });
} else if (run_id === 'cancelled') {
logger.warn('[abortRun] Run already cancelled', { thread_id });
@@ -93,7 +93,7 @@ async function abortRun(req, res) {
};
if (res.headersSent && finalEvent) {
return sendMessage(res, finalEvent);
return sendEvent(res, finalEvent);
}
res.json(finalEvent);

View File

@@ -0,0 +1,97 @@
const { logger } = require('@librechat/data-schemas');
const { Constants, isAgentsEndpoint } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { getAgent } = require('~/models/Agent');
/**
* Agent ID resolver function for agent_id from request body
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
* This is used specifically for chat routes where agent_id comes from request body
*
* @param {string} agentCustomId - Custom agent ID from request body
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
*/
const resolveAgentIdFromBody = async (agentCustomId) => {
// Handle ephemeral agents - they don't need permission checks
if (agentCustomId === Constants.EPHEMERAL_AGENT_ID) {
return null; // No permission check needed for ephemeral agents
}
return await getAgent({ id: agentCustomId });
};
/**
* Middleware factory that creates middleware to check agent access permissions from request body.
* This middleware is specifically designed for chat routes where the agent_id comes from req.body
* instead of route parameters.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for agent chat (requires VIEW permission)
* router.post('/chat',
* canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }),
* buildEndpointOption,
* chatController
* );
*/
const canAccessAgentFromBody = (options) => {
const { requiredPermission } = options;
// Validate required options
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessAgentFromBody: requiredPermission is required and must be a number');
}
return async (req, res, next) => {
try {
const { endpoint, agent_id } = req.body;
let agentId = agent_id;
if (!isAgentsEndpoint(endpoint)) {
agentId = Constants.EPHEMERAL_AGENT_ID;
}
if (!agentId) {
return res.status(400).json({
error: 'Bad Request',
message: 'agent_id is required in request body',
});
}
// Skip permission checks for ephemeral agents
if (agentId === Constants.EPHEMERAL_AGENT_ID) {
return next();
}
const agentAccessMiddleware = canAccessResource({
resourceType: 'agent',
requiredPermission,
resourceIdParam: 'agent_id', // This will be ignored since we use custom resolver
idResolver: () => resolveAgentIdFromBody(agentId),
});
const tempReq = {
...req,
params: {
...req.params,
agent_id: agentId,
},
};
return agentAccessMiddleware(tempReq, res, next);
} catch (error) {
logger.error('Failed to validate agent access permissions', error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to validate agent access permissions',
});
}
};
};
module.exports = {
canAccessAgentFromBody,
};

View File

@@ -0,0 +1,58 @@
const { getAgent } = require('~/models/Agent');
const { canAccessResource } = require('./canAccessResource');
/**
* Agent ID resolver function
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
*
* @param {string} agentCustomId - Custom agent ID from route parameter
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
*/
const resolveAgentId = async (agentCustomId) => {
return await getAgent({ id: agentCustomId });
};
/**
* Agent-specific middleware factory that creates middleware to check agent access permissions.
* This middleware extends the generic canAccessResource to handle agent custom ID resolution.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='id'] - The name of the route parameter containing the agent custom ID
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for viewing agents
* router.get('/agents/:id',
* canAccessAgentResource({ requiredPermission: 1 }),
* getAgent
* );
*
* @example
* // Custom resource ID parameter and edit permission
* router.patch('/agents/:agent_id',
* canAccessAgentResource({
* requiredPermission: 2,
* resourceIdParam: 'agent_id'
* }),
* updateAgent
* );
*/
const canAccessAgentResource = (options) => {
const { requiredPermission, resourceIdParam = 'id' } = options;
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessAgentResource: requiredPermission is required and must be a number');
}
return canAccessResource({
resourceType: 'agent',
requiredPermission,
resourceIdParam,
idResolver: resolveAgentId,
});
};
module.exports = {
canAccessAgentResource,
};

View File

@@ -0,0 +1,384 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { canAccessAgentResource } = require('./canAccessAgentResource');
const { User, Role, AclEntry } = require('~/db/models');
const { createAgent } = require('~/models/Agent');
describe('canAccessAgentResource middleware', () => {
let mongoServer;
let req, res, next;
let testUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
await Role.create({
name: 'test-role',
permissions: {
AGENTS: {
USE: true,
CREATE: true,
SHARED_GLOBAL: false,
},
},
});
// Create a test user
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});
req = {
user: { id: testUser._id.toString(), role: 'test-role' },
params: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('middleware factory', () => {
test('should throw error if requiredPermission is not provided', () => {
expect(() => canAccessAgentResource({})).toThrow(
'canAccessAgentResource: requiredPermission is required and must be a number',
);
});
test('should throw error if requiredPermission is not a number', () => {
expect(() => canAccessAgentResource({ requiredPermission: '1' })).toThrow(
'canAccessAgentResource: requiredPermission is required and must be a number',
);
});
test('should create middleware with default resourceIdParam', () => {
const middleware = canAccessAgentResource({ requiredPermission: 1 });
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // Express middleware signature
});
test('should create middleware with custom resourceIdParam', () => {
const middleware = canAccessAgentResource({
requiredPermission: 2,
resourceIdParam: 'agent_id',
});
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3);
});
});
describe('permission checking with real agents', () => {
test('should allow access when user is the agent author', async () => {
// Create an agent owned by the test user
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry for the author (owner permissions)
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when user is not the author and has no ACL entry', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Other User Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry for the other user (owner)
await AclEntry.create({
principalType: 'user',
principalId: otherUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this agent',
});
});
test('should allow access when user has ACL entry with sufficient permissions', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other2@example.com',
name: 'Other User 2',
username: 'otheruser2',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Shared Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry granting view permission to test user
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 1, // VIEW permission
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when ACL permissions are insufficient', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other3@example.com',
name: 'Other User 3',
username: 'otheruser3',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Limited Access Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry granting only view permission
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 1, // VIEW permission only
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 2 }); // EDIT permission required
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this agent',
});
});
test('should handle non-existent agent', async () => {
req.params.id = 'agent_nonexistent';
const middleware = canAccessAgentResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'agent not found',
});
});
test('should use custom resourceIdParam', async () => {
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Custom Param Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.agent_id = agent.id; // Using custom param name
const middleware = canAccessAgentResource({
requiredPermission: 1,
resourceIdParam: 'agent_id',
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('permission levels', () => {
let agent;
beforeEach(async () => {
agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Permission Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry with all permissions for the owner
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.id = agent.id;
});
test('should support view permission (1)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support edit permission (2)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 2 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support delete permission (4)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 4 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support share permission (8)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 8 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support combined permissions', async () => {
const viewAndEdit = 1 | 2; // 3
const middleware = canAccessAgentResource({ requiredPermission: viewAndEdit });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('integration with agent operations', () => {
test('should work with agent CRUD operations', async () => {
const agentId = `agent_${Date.now()}`;
// Create agent
const agent = await createAgent({
id: agentId,
name: 'Integration Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
description: 'Testing integration',
});
// Create ACL entry for the author
await AclEntry.create({
principalType: 'user',
principalId: testUser._id,
principalModel: 'User',
resourceType: 'agent',
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.id = agentId;
// Test view access
const viewMiddleware = canAccessAgentResource({ requiredPermission: 1 });
await viewMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
jest.clearAllMocks();
// Update the agent
const { updateAgent } = require('~/models/Agent');
await updateAgent({ id: agentId }, { description: 'Updated description' });
// Test edit access
const editMiddleware = canAccessAgentResource({ requiredPermission: 2 });
await editMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,157 @@
const { logger } = require('@librechat/data-schemas');
const { SystemRoles } = require('librechat-data-provider');
const { checkPermission } = require('~/server/services/PermissionService');
/**
* Generic base middleware factory that creates middleware to check resource access permissions.
* This middleware expects MongoDB ObjectIds as resource identifiers for ACL permission checks.
*
* @param {Object} options - Configuration options
* @param {string} options.resourceType - The type of resource (e.g., 'agent', 'file', 'project')
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='resourceId'] - The name of the route parameter containing the resource ID
* @param {Function} [options.idResolver] - Optional function to resolve custom IDs to ObjectIds
* @returns {Function} Express middleware function
*
* @example
* // Direct usage with ObjectId (for resources that use MongoDB ObjectId in routes)
* router.get('/prompts/:promptId',
* canAccessResource({ resourceType: 'prompt', requiredPermission: 1 }),
* getPrompt
* );
*
* @example
* // Usage with custom ID resolver (for resources that use custom string IDs)
* router.get('/agents/:id',
* canAccessResource({
* resourceType: 'agent',
* requiredPermission: 1,
* resourceIdParam: 'id',
* idResolver: (customId) => resolveAgentId(customId)
* }),
* getAgent
* );
*/
const canAccessResource = (options) => {
const {
resourceType,
requiredPermission,
resourceIdParam = 'resourceId',
idResolver = null,
} = options;
if (!resourceType || typeof resourceType !== 'string') {
throw new Error('canAccessResource: resourceType is required and must be a string');
}
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessResource: requiredPermission is required and must be a number');
}
return async (req, res, next) => {
try {
// Extract resource ID from route parameters
const rawResourceId = req.params[resourceIdParam];
if (!rawResourceId) {
logger.warn(`[canAccessResource] Missing ${resourceIdParam} in route parameters`);
return res.status(400).json({
error: 'Bad Request',
message: `${resourceIdParam} is required`,
});
}
// Check if user is authenticated
if (!req.user || !req.user.id) {
logger.warn(
`[canAccessResource] Unauthenticated request for ${resourceType} ${rawResourceId}`,
);
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
// if system admin let through
if (req.user.role === SystemRoles.ADMIN) {
return next();
}
const userId = req.user.id;
let resourceId = rawResourceId;
let resourceInfo = null;
// Resolve custom ID to ObjectId if resolver is provided
if (idResolver) {
logger.debug(
`[canAccessResource] Resolving ${resourceType} custom ID ${rawResourceId} to ObjectId`,
);
const resolutionResult = await idResolver(rawResourceId);
if (!resolutionResult) {
logger.warn(`[canAccessResource] ${resourceType} not found: ${rawResourceId}`);
return res.status(404).json({
error: 'Not Found',
message: `${resourceType} not found`,
});
}
// Handle different resolver return formats
if (typeof resolutionResult === 'string' || resolutionResult._id) {
resourceId = resolutionResult._id || resolutionResult;
resourceInfo = typeof resolutionResult === 'object' ? resolutionResult : null;
} else {
resourceId = resolutionResult;
}
logger.debug(
`[canAccessResource] Resolved ${resourceType} ${rawResourceId} to ObjectId ${resourceId}`,
);
}
// Check permissions using PermissionService with ObjectId
const hasPermission = await checkPermission({
userId,
resourceType,
resourceId,
requiredPermission,
});
if (hasPermission) {
logger.debug(
`[canAccessResource] User ${userId} has permission ${requiredPermission} on ${resourceType} ${rawResourceId} (${resourceId})`,
);
req.resourceAccess = {
resourceType,
resourceId, // MongoDB ObjectId for ACL operations
customResourceId: rawResourceId, // Original ID from route params
permission: requiredPermission,
userId,
...(resourceInfo && { resourceInfo }),
};
return next();
}
logger.warn(
`[canAccessResource] User ${userId} denied access to ${resourceType} ${rawResourceId} ` +
`(required permission: ${requiredPermission})`,
);
return res.status(403).json({
error: 'Forbidden',
message: `Insufficient permissions to access this ${resourceType}`,
});
} catch (error) {
logger.error(`[canAccessResource] Error checking access for ${resourceType}:`, error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check resource access permissions',
});
}
};
};
module.exports = {
canAccessResource,
};

View File

@@ -0,0 +1,9 @@
const { canAccessResource } = require('./canAccessResource');
const { canAccessAgentResource } = require('./canAccessAgentResource');
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
module.exports = {
canAccessResource,
canAccessAgentResource,
canAccessAgentFromBody,
};

View File

@@ -1,13 +1,12 @@
const { logger } = require('@librechat/data-schemas');
const {
parseCompactConvo,
EndpointURLs,
EModelEndpoint,
isAgentsEndpoint,
EndpointURLs,
parseCompactConvo,
} = require('librechat-data-provider');
const azureAssistants = require('~/server/services/Endpoints/azureAssistants');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const assistants = require('~/server/services/Endpoints/assistants');
const gptPlugins = require('~/server/services/Endpoints/gptPlugins');
const { processFiles } = require('~/server/services/Files/process');
const anthropic = require('~/server/services/Endpoints/anthropic');
const bedrock = require('~/server/services/Endpoints/bedrock');
@@ -25,7 +24,6 @@ const buildFunction = {
[EModelEndpoint.bedrock]: bedrock.buildOptions,
[EModelEndpoint.azureOpenAI]: openAI.buildOptions,
[EModelEndpoint.anthropic]: anthropic.buildOptions,
[EModelEndpoint.gptPlugins]: gptPlugins.buildOptions,
[EModelEndpoint.assistants]: assistants.buildOptions,
[EModelEndpoint.azureAssistants]: azureAssistants.buildOptions,
};
@@ -36,6 +34,9 @@ async function buildEndpointOption(req, res, next) {
try {
parsedBody = parseCompactConvo({ endpoint, endpointType, conversation: req.body });
} catch (error) {
logger.warn(
`Error parsing conversation for endpoint ${endpoint}${error?.message ? `: ${error.message}` : ''}`,
);
return handleError(res, { text: 'Error parsing conversation' });
}
@@ -57,15 +58,6 @@ async function buildEndpointOption(req, res, next) {
return handleError(res, { text: 'Model spec mismatch' });
}
if (
currentModelSpec.preset.endpoint !== EModelEndpoint.gptPlugins &&
currentModelSpec.preset.tools
) {
return handleError(res, {
text: `Only the "${EModelEndpoint.gptPlugins}" endpoint can have tools defined in the preset`,
});
}
try {
currentModelSpec.preset.spec = spec;
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
@@ -77,6 +69,7 @@ async function buildEndpointOption(req, res, next) {
conversation: currentModelSpec.preset,
});
} catch (error) {
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
return handleError(res, { text: 'Error parsing model spec' });
}
}
@@ -84,20 +77,23 @@ async function buildEndpointOption(req, res, next) {
try {
const isAgents =
isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]);
const endpointFn = buildFunction[isAgents ? EModelEndpoint.agents : (endpointType ?? endpoint)];
const builder = isAgents ? (...args) => endpointFn(req, ...args) : endpointFn;
const builder = isAgents
? (...args) => buildFunction[EModelEndpoint.agents](req, ...args)
: buildFunction[endpointType ?? endpoint];
// TODO: use object params
req.body.endpointOption = await builder(endpoint, parsedBody, endpointType);
// TODO: use `getModelsConfig` only when necessary
const modelsConfig = await getModelsConfig(req);
req.body.endpointOption.modelsConfig = modelsConfig;
if (req.body.files && !isAgents) {
req.body.endpointOption.attachments = processFiles(req.body.files);
}
next();
} catch (error) {
logger.error(
`Error building endpoint option for endpoint ${endpoint} with type ${endpointType}`,
error,
);
return handleError(res, { text: 'Error building endpoint option' });
}
}

View File

@@ -1,6 +1,7 @@
const crypto = require('crypto');
const { sendEvent } = require('@librechat/api');
const { getResponseSender, Constants } = require('librechat-data-provider');
const { sendMessage, sendError } = require('~/server/utils');
const { sendError } = require('~/server/middleware/error');
const { saveMessage } = require('~/models');
/**
@@ -36,7 +37,7 @@ const denyRequest = async (req, res, errorMessage) => {
isCreatedByUser: true,
text,
};
sendMessage(res, { message: userMessage, created: true });
sendEvent(res, { message: userMessage, created: true });
const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT;

View File

@@ -1,31 +1,9 @@
const crypto = require('crypto');
const { logger } = require('@librechat/data-schemas');
const { parseConvo } = require('librechat-data-provider');
const { sendEvent, handleError } = require('@librechat/api');
const { saveMessage, getMessages } = require('~/models/Message');
const { getConvo } = require('~/models/Conversation');
const { logger } = require('~/config');
/**
* Sends error data in Server Sent Events format and ends the response.
* @param {object} res - The server response.
* @param {string} message - The error message.
*/
const handleError = (res, message) => {
res.write(`event: error\ndata: ${JSON.stringify(message)}\n\n`);
res.end();
};
/**
* Sends message data in Server Sent Events format.
* @param {Express.Response} res - - The server response.
* @param {string | Object} message - The message to be sent.
* @param {'message' | 'error' | 'cancel'} event - [Optional] The type of event. Default is 'message'.
*/
const sendMessage = (res, message, event = 'message') => {
if (typeof message === 'string' && message.length === 0) {
return;
}
res.write(`event: ${event}\ndata: ${JSON.stringify(message)}\n\n`);
};
/**
* Processes an error with provided options, saves the error message and sends a corresponding SSE response
@@ -91,7 +69,7 @@ const sendError = async (req, res, options, callback) => {
convo = parseConvo(errorMessage);
}
return sendMessage(res, {
return sendEvent(res, {
final: true,
requestMessage: query?.[0] ? query[0] : requestMessage,
responseMessage: errorMessage,
@@ -120,12 +98,10 @@ const sendResponse = (req, res, data, errorMessage) => {
if (errorMessage) {
return sendError(req, res, { ...data, text: errorMessage });
}
return sendMessage(res, data);
return sendEvent(res, data);
};
module.exports = {
sendResponse,
handleError,
sendMessage,
sendError,
sendResponse,
};

View File

@@ -8,6 +8,7 @@ const concurrentLimiter = require('./concurrentLimiter');
const validateEndpoint = require('./validateEndpoint');
const requireLocalAuth = require('./requireLocalAuth');
const canDeleteAccount = require('./canDeleteAccount');
const accessResources = require('./accessResources');
const setBalanceConfig = require('./setBalanceConfig');
const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
@@ -29,6 +30,7 @@ module.exports = {
...validate,
...limiters,
...roles,
...accessResources,
noIndex,
checkBan,
uaParser,

View File

@@ -1,78 +0,0 @@
const { getRoleByName } = require('~/models/Role');
const { logger } = require('~/config');
/**
* Core function to check if a user has one or more required permissions
*
* @param {object} user - The user object
* @param {PermissionTypes} permissionType - The type of permission to check
* @param {Permissions[]} permissions - The list of specific permissions to check
* @param {Record<Permissions, string[]>} [bodyProps] - An optional object where keys are permissions and values are arrays of properties to check
* @param {object} [checkObject] - The object to check properties against
* @returns {Promise<boolean>} Whether the user has the required permissions
*/
const checkAccess = async (user, permissionType, permissions, bodyProps = {}, checkObject = {}) => {
if (!user) {
return false;
}
const role = await getRoleByName(user.role);
if (role && role.permissions && role.permissions[permissionType]) {
const hasAnyPermission = permissions.some((permission) => {
if (role.permissions[permissionType][permission]) {
return true;
}
if (bodyProps[permission] && checkObject) {
return bodyProps[permission].some((prop) =>
Object.prototype.hasOwnProperty.call(checkObject, prop),
);
}
return false;
});
return hasAnyPermission;
}
return false;
};
/**
* Middleware to check if a user has one or more required permissions, optionally based on `req.body` properties.
*
* @param {PermissionTypes} permissionType - The type of permission to check.
* @param {Permissions[]} permissions - The list of specific permissions to check.
* @param {Record<Permissions, string[]>} [bodyProps] - An optional object where keys are permissions and values are arrays of `req.body` properties to check.
* @returns {(req: ServerRequest, res: ServerResponse, next: NextFunction) => Promise<void>} Express middleware function.
*/
const generateCheckAccess = (permissionType, permissions, bodyProps = {}) => {
return async (req, res, next) => {
try {
const hasAccess = await checkAccess(
req.user,
permissionType,
permissions,
bodyProps,
req.body,
);
if (hasAccess) {
return next();
}
logger.warn(
`[${permissionType}] Forbidden: Insufficient permissions for User ${req.user.id}: ${permissions.join(', ')}`,
);
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
} catch (error) {
logger.error(error);
return res.status(500).json({ message: `Server error: ${error.message}` });
}
};
};
module.exports = {
checkAccess,
generateCheckAccess,
};

View File

@@ -0,0 +1,357 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { checkAccess, generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { getRoleByName } = require('~/models/Role');
const { Role } = require('~/db/models');
// Mock the logger from @librechat/data-schemas
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
},
}));
// Mock the cache to use a simple in-memory implementation
const mockCache = new Map();
jest.mock('~/cache/getLogStores', () => {
return jest.fn(() => ({
get: jest.fn(async (key) => mockCache.get(key)),
set: jest.fn(async (key, value) => mockCache.set(key, value)),
clear: jest.fn(async () => mockCache.clear()),
}));
});
describe('Access Middleware', () => {
let mongoServer;
let req, res, next;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
mockCache.clear(); // Clear the cache between tests
// Create test roles
await Role.create({
name: 'user',
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: false,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
},
});
await Role.create({
name: 'admin',
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: true,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARED_GLOBAL]: true,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
},
});
// Create limited role with no AGENTS permissions
await Role.create({
name: 'limited',
permissions: {
// Explicitly set AGENTS permissions to false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
// Has permissions for other types
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
},
},
});
req = {
user: { id: 'user123', role: 'user' },
body: {},
originalUrl: '/test',
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('checkAccess', () => {
test('should return false if user is not provided', async () => {
const result = await checkAccess({
user: null,
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has required permission', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(true);
});
test('should return false if user lacks required permission', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has any of multiple permissions', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE, Permissions.USE],
getRoleByName,
});
expect(result).toBe(true);
});
test('should check body properties when permission is not directly granted', async () => {
const req = { body: { id: 'agent123' } };
const result = await checkAccess({
req,
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.UPDATE],
bodyProps: {
[Permissions.UPDATE]: ['id'],
},
checkObject: req.body,
getRoleByName,
});
expect(result).toBe(true);
});
test('should return false if role is not found', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'nonexistent' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return false if role has no permissions for the requested type', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'limited' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should handle admin role with all permissions', async () => {
const createResult = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
expect(createResult).toBe(true);
const shareResult = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.SHARED_GLOBAL],
getRoleByName,
});
expect(shareResult).toBe(true);
});
});
describe('generateCheckAccess', () => {
test('should call next() when user has required permission', async () => {
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should return 403 when user lacks permission', async () => {
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
test('should check body properties when configured', async () => {
req.body = { agentId: 'agent123', description: 'test' };
const bodyProps = {
[Permissions.CREATE]: ['agentId'],
};
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
bodyProps,
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should handle database errors gracefully', async () => {
// Mock getRoleByName to throw an error
const mockGetRoleByName = jest
.fn()
.mockRejectedValue(new Error('Database connection failed'));
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName: mockGetRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
message: expect.stringContaining('Server error:'),
});
});
test('should work with multiple permission types', async () => {
req.user.role = 'admin';
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARED_GLOBAL],
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle missing user gracefully', async () => {
req.user = null;
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
test('should handle role with no AGENTS permissions', async () => {
await Role.create({
name: 'noaccess',
permissions: {
// Explicitly set AGENTS with all permissions false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
},
});
req.user.role = 'noaccess';
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
});
});

View File

@@ -1,8 +1,5 @@
const checkAdmin = require('./admin');
const { checkAccess, generateCheckAccess } = require('./access');
module.exports = {
checkAdmin,
checkAccess,
generateCheckAccess,
};

View File

@@ -1,8 +1,8 @@
const { isEnabled } = require('@librechat/api');
const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
const { searchConversation } = require('~/models/Conversation');
const denyRequest = require('~/server/middleware/denyRequest');
const { logViolation, getLogStores } = require('~/cache');
const { isEnabled } = require('~/server/utils');
const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {};

View File

@@ -0,0 +1,62 @@
const express = require('express');
const { PermissionBits } = require('@librechat/data-schemas');
const {
getUserEffectivePermissions,
updateResourcePermissions,
getResourcePermissions,
getResourceRoles,
searchPrincipals,
} = require('~/server/controllers/PermissionsController');
const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware');
const router = express.Router();
// Apply common middleware
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
/**
* Generic routes for resource permissions
* Pattern: /api/permissions/{resourceType}/{resourceId}
*/
/**
* GET /api/permissions/search-principals
* Search for users and groups to grant permissions
*/
router.get('/search-principals', searchPrincipals);
/**
* GET /api/permissions/{resourceType}/roles
* Get available roles for a resource type
*/
router.get('/:resourceType/roles', getResourceRoles);
/**
* GET /api/permissions/{resourceType}/{resourceId}
* Get all permissions for a specific resource
*/
router.get('/:resourceType/:resourceId', getResourcePermissions);
/**
* PUT /api/permissions/{resourceType}/{resourceId}
* Bulk update permissions for a specific resource
*/
router.put(
'/:resourceType/:resourceId',
canAccessResource({
resourceType: 'agent',
requiredPermission: PermissionBits.SHARE,
resourceIdParam: 'resourceId',
}),
updateResourcePermissions,
);
/**
* GET /api/permissions/{resourceType}/{resourceId}/effective
* Get user's effective permissions for a specific resource
*/
router.get('/:resourceType/:resourceId/effective', getUserEffectivePermissions);
module.exports = router;

View File

@@ -1,8 +1,10 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { getAccessToken } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { getAccessToken } = require('~/server/services/TokenService');
const { logger, getFlowStateManager } = require('~/config');
const { findToken, updateToken, createToken } = require('~/models');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const router = express.Router();
@@ -28,18 +30,19 @@ router.get('/:action_id/oauth/callback', async (req, res) => {
try {
decodedState = jwt.verify(state, JWT_SECRET);
} catch (err) {
logger.error('Error verifying state parameter:', err);
await flowManager.failFlow(identifier, 'oauth', 'Invalid or expired state parameter');
return res.status(400).send('Invalid or expired state parameter');
return res.redirect('/oauth/error?error=invalid_state');
}
if (decodedState.action_id !== action_id) {
await flowManager.failFlow(identifier, 'oauth', 'Mismatched action ID in state parameter');
return res.status(400).send('Mismatched action ID in state parameter');
return res.redirect('/oauth/error?error=invalid_state');
}
if (!decodedState.user) {
await flowManager.failFlow(identifier, 'oauth', 'Invalid user ID in state parameter');
return res.status(400).send('Invalid user ID in state parameter');
return res.redirect('/oauth/error?error=invalid_state');
}
identifier = `${decodedState.user}:${action_id}`;
const flowState = await flowManager.getFlowState(identifier, 'oauth');
@@ -47,91 +50,34 @@ router.get('/:action_id/oauth/callback', async (req, res) => {
throw new Error('OAuth flow not found');
}
const tokenData = await getAccessToken({
code,
userId: decodedState.user,
identifier,
client_url: flowState.metadata.client_url,
redirect_uri: flowState.metadata.redirect_uri,
token_exchange_method: flowState.metadata.token_exchange_method,
/** Encrypted values */
encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
});
const tokenData = await getAccessToken(
{
code,
userId: decodedState.user,
identifier,
client_url: flowState.metadata.client_url,
redirect_uri: flowState.metadata.redirect_uri,
token_exchange_method: flowState.metadata.token_exchange_method,
/** Encrypted values */
encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
},
{
findToken,
updateToken,
createToken,
},
);
await flowManager.completeFlow(identifier, 'oauth', tokenData);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
background-color: rgb(249, 250, 251);
margin: 0;
padding: 2rem;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
background-color: white;
border-radius: 0.5rem;
padding: 2rem;
max-width: 28rem;
width: 100%;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
text-align: center;
}
.heading {
color: rgb(17, 24, 39);
font-size: 1.875rem;
font-weight: 700;
margin: 0 0 1rem;
}
.description {
color: rgb(75, 85, 99);
font-size: 0.875rem;
margin: 0.5rem 0;
}
.countdown {
color: rgb(99, 102, 241);
font-weight: 500;
}
</style>
</head>
<body>
<div class="card">
<h1 class="heading">Authentication Successful</h1>
<p class="description">
Your authentication was successful. This window will close in
<span class="countdown" id="countdown">3</span> seconds.
</p>
</div>
<script>
let secondsLeft = 3;
const countdownElement = document.getElementById('countdown');
const countdown = setInterval(() => {
secondsLeft--;
countdownElement.textContent = secondsLeft;
if (secondsLeft <= 0) {
clearInterval(countdown);
window.close();
}
}, 1000);
</script>
</body>
</html>
`);
/** Redirect to React success page */
const serverName = flowState.metadata?.action_name || `Action ${action_id}`;
const redirectUrl = `/oauth/success?serverName=${encodeURIComponent(serverName)}`;
res.redirect(redirectUrl);
} catch (error) {
logger.error('Error in OAuth callback:', error);
await flowManager.failFlow(identifier, 'oauth', error);
res.status(500).send('Authentication failed. Please try again.');
res.redirect('/oauth/error?error=callback_failed');
}
});

View File

@@ -1,19 +1,27 @@
const express = require('express');
const { nanoid } = require('nanoid');
const { actionDelimiter, SystemRoles, removeNullishValues } = require('librechat-data-provider');
const { generateCheckAccess } = require('@librechat/api');
const { logger, PermissionBits } = require('@librechat/data-schemas');
const {
Permissions,
PermissionTypes,
actionDelimiter,
removeNullishValues,
} = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { canAccessAgentResource } = require('~/server/middleware');
const { getAgent, updateAgent } = require('~/models/Agent');
const { logger } = require('~/config');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
// If the user has ADMIN role
// then action edition is possible even if not owner of the assistant
const isAdmin = (req) => {
return req.user.role === SystemRoles.ADMIN;
};
const checkAgentCreate = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
/**
* Retrieves all user's actions
@@ -23,9 +31,8 @@ const isAdmin = (req) => {
*/
router.get('/', async (req, res) => {
try {
const admin = isAdmin(req);
// If admin, get all actions, otherwise only user's actions
const searchParams = admin ? {} : { user: req.user.id };
// Get all actions for the user (admin permissions handled by middleware if needed)
const searchParams = { user: req.user.id };
res.json(await getActions(searchParams));
} catch (error) {
res.status(500).json({ error: error.message });
@@ -41,106 +48,111 @@ router.get('/', async (req, res) => {
* @param {ActionMetadata} req.body.metadata - Metadata for the action.
* @returns {Object} 200 - success response - application/json
*/
router.post('/:agent_id', async (req, res) => {
try {
const { agent_id } = req.params;
router.post(
'/:agent_id',
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
checkAgentCreate,
async (req, res) => {
try {
const { agent_id } = req.params;
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
if (!functions.length) {
return res.status(400).json({ message: 'No functions provided' });
}
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });
}
let { domain } = metadata;
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const action_id = _action_id ?? nanoid();
const initialPromises = [];
const admin = isAdmin(req);
// If admin, can edit any agent, otherwise only user's agents
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
// TODO: share agents
initialPromises.push(getAgent(agentQuery));
if (_action_id) {
initialPromises.push(getActions({ action_id }, true));
}
/** @type {[Agent, [Action|undefined]]} */
const [agent, actions_result] = await Promise.all(initialPromises);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for adding action' });
}
if (actions_result && actions_result.length) {
const action = actions_result[0];
metadata = { ...action.metadata, ...metadata };
}
const { actions: _actions = [], author: agent_author } = agent ?? {};
const actions = [];
for (const action of _actions) {
const [_action_domain, current_action_id] = action.split(actionDelimiter);
if (current_action_id === action_id) {
continue;
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
if (!functions.length) {
return res.status(400).json({ message: 'No functions provided' });
}
actions.push(action);
}
actions.push(`${domain}${actionDelimiter}${action_id}`);
/** @type {string[]}} */
const { tools: _tools = [] } = agent;
const tools = _tools
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
// Force version update since actions are changing
const updatedAgent = await updateAgent(
agentQuery,
{ tools, actions },
{
updatingUserId: req.user.id,
forceVersion: true,
},
);
// Only update user field for new actions
const actionUpdateData = { metadata, agent_id };
if (!actions_result || !actions_result.length) {
// For new actions, use the agent owner's user ID
actionUpdateData.user = agent_author || req.user.id;
}
/** @type {[Action]} */
const updatedAction = await updateAction({ action_id }, actionUpdateData);
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
for (let field of sensitiveFields) {
if (updatedAction.metadata[field]) {
delete updatedAction.metadata[field];
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });
}
}
res.json([updatedAgent, updatedAction]);
} catch (error) {
const message = 'Trouble updating the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
});
let { domain } = metadata;
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const action_id = _action_id ?? nanoid();
const initialPromises = [];
// Permissions already validated by middleware - load agent directly
initialPromises.push(getAgent({ id: agent_id }));
if (_action_id) {
initialPromises.push(getActions({ action_id }, true));
}
/** @type {[Agent, [Action|undefined]]} */
const [agent, actions_result] = await Promise.all(initialPromises);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for adding action' });
}
if (actions_result && actions_result.length) {
const action = actions_result[0];
metadata = { ...action.metadata, ...metadata };
}
const { actions: _actions = [], author: agent_author } = agent ?? {};
const actions = [];
for (const action of _actions) {
const [_action_domain, current_action_id] = action.split(actionDelimiter);
if (current_action_id === action_id) {
continue;
}
actions.push(action);
}
actions.push(`${domain}${actionDelimiter}${action_id}`);
/** @type {string[]}} */
const { tools: _tools = [] } = agent;
const tools = _tools
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
// Force version update since actions are changing
const updatedAgent = await updateAgent(
{ id: agent_id },
{ tools, actions },
{
updatingUserId: req.user.id,
forceVersion: true,
},
);
// Only update user field for new actions
const actionUpdateData = { metadata, agent_id };
if (!actions_result || !actions_result.length) {
// For new actions, use the agent owner's user ID
actionUpdateData.user = agent_author || req.user.id;
}
/** @type {[Action]} */
const updatedAction = await updateAction({ action_id }, actionUpdateData);
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
for (let field of sensitiveFields) {
if (updatedAction.metadata[field]) {
delete updatedAction.metadata[field];
}
}
res.json([updatedAgent, updatedAction]);
} catch (error) {
const message = 'Trouble updating the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
},
);
/**
* Deletes an action for a specific agent.
@@ -149,52 +161,56 @@ router.post('/:agent_id', async (req, res) => {
* @param {string} req.params.action_id - The ID of the action to delete.
* @returns {Object} 200 - success response - application/json
*/
router.delete('/:agent_id/:action_id', async (req, res) => {
try {
const { agent_id, action_id } = req.params;
const admin = isAdmin(req);
router.delete(
'/:agent_id/:action_id',
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
checkAgentCreate,
async (req, res) => {
try {
const { agent_id, action_id } = req.params;
// If admin, can delete any agent, otherwise only user's agents
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
const agent = await getAgent(agentQuery);
if (!agent) {
return res.status(404).json({ message: 'Agent not found for deleting action' });
}
const { tools = [], actions = [] } = agent;
let domain = '';
const updatedActions = actions.filter((action) => {
if (action.includes(action_id)) {
[domain] = action.split(actionDelimiter);
return false;
// Permissions already validated by middleware - load agent directly
const agent = await getAgent({ id: agent_id });
if (!agent) {
return res.status(404).json({ message: 'Agent not found for deleting action' });
}
return true;
});
domain = await domainParser(domain, true);
const { tools = [], actions = [] } = agent;
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
let domain = '';
const updatedActions = actions.filter((action) => {
if (action.includes(action_id)) {
[domain] = action.split(actionDelimiter);
return false;
}
return true;
});
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
// Force version update since actions are being removed
await updateAgent(
{ id: agent_id },
{ tools: updatedTools, actions: updatedActions },
{ updatingUserId: req.user.id, forceVersion: true },
);
await deleteAction({ action_id });
res.status(200).json({ message: 'Action deleted successfully' });
} catch (error) {
const message = 'Trouble deleting the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
// Force version update since actions are being removed
await updateAgent(
agentQuery,
{ tools: updatedTools, actions: updatedActions },
{ updatingUserId: req.user.id, forceVersion: true },
);
// If admin, can delete any action, otherwise only user's actions
const actionQuery = admin ? { action_id } : { action_id, user: req.user.id };
await deleteAction(actionQuery);
res.status(200).json({ message: 'Action deleted successfully' });
} catch (error) {
const message = 'Trouble deleting the Agent Action';
logger.error(message, error);
res.status(500).json({ message });
}
});
},
);
module.exports = router;

View File

@@ -1,24 +1,36 @@
const express = require('express');
const { PermissionBits } = require('@librechat/data-schemas');
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
setHeaders,
moderateText,
// validateModel,
generateCheckAccess,
validateConvoAccess,
buildEndpointOption,
canAccessAgentFromBody,
} = require('~/server/middleware');
const { initializeClient } = require('~/server/services/Endpoints/agents');
const AgentController = require('~/server/controllers/agents/request');
const addTitle = require('~/server/services/Endpoints/agents/title');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
router.use(moderateText);
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
const checkAgentAccess = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
skipCheck: skipAgentCheck,
getRoleByName,
});
const checkAgentResourceAccess = canAccessAgentFromBody({
requiredPermission: PermissionBits.VIEW,
});
router.use(checkAgentAccess);
router.use(checkAgentResourceAccess);
router.use(validateConvoAccess);
router.use(buildEndpointOption);
router.use(setHeaders);

View File

@@ -37,4 +37,6 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
chatRouter.use('/', chat);
router.use('/chat', chatRouter);
// Add marketplace routes
module.exports = router;

View File

@@ -1,29 +1,37 @@
const express = require('express');
const { generateCheckAccess } = require('@librechat/api');
const { PermissionBits } = require('@librechat/data-schemas');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { requireJwtAuth, canAccessAgentResource } = require('~/server/middleware');
const v1 = require('~/server/controllers/agents/v1');
const { getRoleByName } = require('~/models/Role');
const actions = require('./actions');
const tools = require('./tools');
const router = express.Router();
const avatar = express.Router();
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
const checkAgentCreate = generateCheckAccess(PermissionTypes.AGENTS, [
Permissions.USE,
Permissions.CREATE,
]);
const checkAgentAccess = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
const checkAgentCreate = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
const checkGlobalAgentShare = generateCheckAccess(
PermissionTypes.AGENTS,
[Permissions.USE, Permissions.CREATE],
{
const checkGlobalAgentShare = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
bodyProps: {
[Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
},
);
getRoleByName,
});
router.use(requireJwtAuth);
router.use(checkAgentAccess);
/**
* Agent actions route.
@@ -37,6 +45,11 @@ router.use('/actions', actions);
*/
router.use('/tools', tools);
/**
* Get all agent categories with counts
* @route GET /agents/marketplace/categories
*/
router.get('/categories', v1.getAgentCategories);
/**
* Creates an agent.
* @route POST /agents
@@ -46,13 +59,38 @@ router.use('/tools', tools);
router.post('/', checkAgentCreate, v1.createAgent);
/**
* Retrieves an agent.
* Retrieves basic agent information (VIEW permission required).
* Returns safe, non-sensitive agent data for viewing purposes.
* @route GET /agents/:id
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - Success response - application/json
* @returns {Agent} 200 - Basic agent info - application/json
*/
router.get('/:id', checkAgentAccess, v1.getAgent);
router.get(
'/:id',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.VIEW,
resourceIdParam: 'id',
}),
v1.getAgent,
);
/**
* Retrieves full agent details including sensitive configuration (EDIT permission required).
* Returns complete agent data for editing/configuration purposes.
* @route GET /agents/:id/expanded
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - Full agent details - application/json
*/
router.get(
'/:id/expanded',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
(req, res) => v1.getAgent(req, res, true), // Expanded version
);
/**
* Updates an agent.
* @route PATCH /agents/:id
@@ -60,7 +98,15 @@ router.get('/:id', checkAgentAccess, v1.getAgent);
* @param {AgentUpdateParams} req.body - The agent update parameters.
* @returns {Agent} 200 - Success response - application/json
*/
router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);
router.patch(
'/:id',
checkGlobalAgentShare,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
v1.updateAgent,
);
/**
* Duplicates an agent.
@@ -68,7 +114,15 @@ router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 201 - Success response - application/json
*/
router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
router.post(
'/:id/duplicate',
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.VIEW,
resourceIdParam: 'id',
}),
v1.duplicateAgent,
);
/**
* Deletes an agent.
@@ -76,7 +130,15 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
* @param {string} req.params.id - Agent identifier.
* @returns {Agent} 200 - success response - application/json
*/
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
router.delete(
'/:id',
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.DELETE,
resourceIdParam: 'id',
}),
v1.deleteAgent,
);
/**
* Reverts an agent to a previous version.
@@ -103,6 +165,14 @@ router.get('/', checkAgentAccess, v1.getListAgents);
* @param {string} [req.body.metadata] - Optional metadata for the agent's avatar.
* @returns {Object} 200 - success response - application/json
*/
avatar.post('/:agent_id/avatar/', checkAgentAccess, v1.uploadAgentAvatar);
avatar.post(
'/:agent_id/avatar/',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'agent_id',
}),
v1.uploadAgentAvatar,
);
module.exports = { v1: router, avatar };

View File

@@ -1,63 +0,0 @@
const { Keyv } = require('keyv');
const { KeyvFile } = require('keyv-file');
const { logger } = require('~/config');
const addToCache = async ({ endpoint, endpointOption, userMessage, responseMessage }) => {
try {
const conversationsCache = new Keyv({
store: new KeyvFile({ filename: './data/cache.json' }),
namespace: 'chatgpt', // should be 'bing' for bing/sydney
});
const {
conversationId,
messageId: userMessageId,
parentMessageId: userParentMessageId,
text: userText,
} = userMessage;
const {
messageId: responseMessageId,
parentMessageId: responseParentMessageId,
text: responseText,
} = responseMessage;
let conversation = await conversationsCache.get(conversationId);
// used to generate a title for the conversation if none exists
// let isNewConversation = false;
if (!conversation) {
conversation = {
messages: [],
createdAt: Date.now(),
};
// isNewConversation = true;
}
const roles = (options) => {
if (endpoint === 'openAI') {
return options?.chatGptLabel || 'ChatGPT';
}
};
let _userMessage = {
id: userMessageId,
parentMessageId: userParentMessageId,
role: 'User',
message: userText,
};
let _responseMessage = {
id: responseMessageId,
parentMessageId: responseParentMessageId,
role: roles(endpointOption),
message: responseText,
};
conversation.messages.push(_userMessage, _responseMessage);
await conversationsCache.set(conversationId, conversation);
} catch (error) {
logger.error('[addToCache] Error adding conversation to cache', error);
}
};
module.exports = addToCache;

View File

@@ -1,25 +0,0 @@
const express = require('express');
const AskController = require('~/server/controllers/AskController');
const { addTitle, initializeClient } = require('~/server/services/Endpoints/anthropic');
const {
setHeaders,
handleAbort,
validateModel,
validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
const router = express.Router();
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await AskController(req, res, next, initializeClient, addTitle);
},
);
module.exports = router;

View File

@@ -1,25 +0,0 @@
const express = require('express');
const AskController = require('~/server/controllers/AskController');
const { initializeClient } = require('~/server/services/Endpoints/custom');
const { addTitle } = require('~/server/services/Endpoints/openAI');
const {
setHeaders,
validateModel,
validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
const router = express.Router();
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await AskController(req, res, next, initializeClient, addTitle);
},
);
module.exports = router;

View File

@@ -1,24 +0,0 @@
const express = require('express');
const AskController = require('~/server/controllers/AskController');
const { initializeClient, addTitle } = require('~/server/services/Endpoints/google');
const {
setHeaders,
validateModel,
validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
const router = express.Router();
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await AskController(req, res, next, initializeClient, addTitle);
},
);
module.exports = router;

View File

@@ -1,241 +0,0 @@
const express = require('express');
const { getResponseSender, Constants } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/gptPlugins');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { addTitle } = require('~/server/services/Endpoints/openAI');
const { saveMessage, updateMessage } = require('~/models');
const {
handleAbort,
createAbortController,
handleAbortError,
setHeaders,
validateModel,
validateEndpoint,
buildEndpointOption,
moderateText,
} = require('~/server/middleware');
const { validateTools } = require('~/app');
const { logger } = require('~/config');
const router = express.Router();
router.use(moderateText);
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res) => {
let {
text,
endpointOption,
conversationId,
parentMessageId = null,
overrideParentMessageId = null,
} = req.body;
logger.debug('[/ask/gptPlugins]', { text, conversationId, ...endpointOption });
let userMessage;
let userMessagePromise;
let promptTokens;
let userMessageId;
let responseMessageId;
const sender = getResponseSender({
...endpointOption,
model: endpointOption.modelOptions.model,
});
const newConvo = !conversationId;
const user = req.user.id;
const plugins = [];
const getReqData = (data = {}) => {
for (let key in data) {
if (key === 'userMessage') {
userMessage = data[key];
userMessageId = data[key].messageId;
} else if (key === 'userMessagePromise') {
userMessagePromise = data[key];
} else if (key === 'responseMessageId') {
responseMessageId = data[key];
} else if (key === 'promptTokens') {
promptTokens = data[key];
} else if (!conversationId && key === 'conversationId') {
conversationId = data[key];
}
}
};
let streaming = null;
let timer = null;
const {
onProgress: progressCallback,
sendIntermediateMessage,
getPartialText,
} = createOnProgress({
onProgress: () => {
if (timer) {
clearTimeout(timer);
}
streaming = new Promise((resolve) => {
timer = setTimeout(() => {
resolve();
}, 250);
});
},
});
const pluginMap = new Map();
const onAgentAction = async (action, runId) => {
pluginMap.set(runId, action.tool);
sendIntermediateMessage(res, {
plugins,
parentMessageId: userMessage.messageId,
messageId: responseMessageId,
});
};
const onToolStart = async (tool, input, runId, parentRunId) => {
const pluginName = pluginMap.get(parentRunId);
const latestPlugin = {
runId,
loading: true,
inputs: [input],
latest: pluginName,
outputs: null,
};
if (streaming) {
await streaming;
}
const extraTokens = ':::plugin:::\n';
plugins.push(latestPlugin);
sendIntermediateMessage(
res,
{ plugins, parentMessageId: userMessage.messageId, messageId: responseMessageId },
extraTokens,
);
};
const onToolEnd = async (output, runId) => {
if (streaming) {
await streaming;
}
const pluginIndex = plugins.findIndex((plugin) => plugin.runId === runId);
if (pluginIndex !== -1) {
plugins[pluginIndex].loading = false;
plugins[pluginIndex].outputs = output;
}
};
const getAbortData = () => ({
sender,
conversationId,
userMessagePromise,
messageId: responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId,
text: getPartialText(),
plugins: plugins.map((p) => ({ ...p, loading: false })),
userMessage,
promptTokens,
});
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
try {
endpointOption.tools = await validateTools(user, endpointOption.tools);
const { client } = await initializeClient({ req, res, endpointOption });
const onChainEnd = () => {
if (!client.skipSaveUserMessage) {
saveMessage(
req,
{ ...userMessage, user },
{ context: 'api/server/routes/ask/gptPlugins.js - onChainEnd' },
);
}
sendIntermediateMessage(res, {
plugins,
parentMessageId: userMessage.messageId,
messageId: responseMessageId,
});
};
let response = await client.sendMessage(text, {
user,
conversationId,
parentMessageId,
overrideParentMessageId,
getReqData,
onAgentAction,
onChainEnd,
onToolStart,
onToolEnd,
onStart,
getPartialText,
...endpointOption,
progressCallback,
progressOptions: {
res,
// parentMessageId: overrideParentMessageId || userMessageId,
plugins,
},
abortController,
});
if (overrideParentMessageId) {
response.parentMessageId = overrideParentMessageId;
}
logger.debug('[/ask/gptPlugins]', response);
const { conversation = {} } = await response.databasePromise;
delete response.databasePromise;
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
sendMessage(res, {
title: conversation.title,
final: true,
conversation,
requestMessage: userMessage,
responseMessage: response,
});
res.end();
if (parentMessageId === Constants.NO_PARENT && newConvo) {
addTitle(req, {
text,
response,
client,
});
}
response.plugins = plugins.map((p) => ({ ...p, loading: false }));
if (response.plugins?.length > 0) {
await updateMessage(
req,
{ ...response, user },
{ context: 'api/server/routes/ask/gptPlugins.js - save plugins used' },
);
}
} catch (error) {
const partialText = getPartialText();
handleAbortError(res, req, error, {
partialText,
conversationId,
sender,
messageId: responseMessageId,
parentMessageId: userMessageId ?? parentMessageId,
});
}
},
);
module.exports = router;

View File

@@ -1,47 +0,0 @@
const express = require('express');
const { EModelEndpoint } = require('librechat-data-provider');
const {
uaParser,
checkBan,
requireJwtAuth,
messageIpLimiter,
concurrentLimiter,
messageUserLimiter,
validateConvoAccess,
} = require('~/server/middleware');
const { isEnabled } = require('~/server/utils');
const gptPlugins = require('./gptPlugins');
const anthropic = require('./anthropic');
const custom = require('./custom');
const google = require('./google');
const openAI = require('./openAI');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
const router = express.Router();
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) {
router.use(concurrentLimiter);
}
if (isEnabled(LIMIT_MESSAGE_IP)) {
router.use(messageIpLimiter);
}
if (isEnabled(LIMIT_MESSAGE_USER)) {
router.use(messageUserLimiter);
}
router.use(validateConvoAccess);
router.use([`/${EModelEndpoint.azureOpenAI}`, `/${EModelEndpoint.openAI}`], openAI);
router.use(`/${EModelEndpoint.gptPlugins}`, gptPlugins);
router.use(`/${EModelEndpoint.anthropic}`, anthropic);
router.use(`/${EModelEndpoint.google}`, google);
router.use(`/${EModelEndpoint.custom}`, custom);
module.exports = router;

View File

@@ -1,27 +0,0 @@
const express = require('express');
const AskController = require('~/server/controllers/AskController');
const { addTitle, initializeClient } = require('~/server/services/Endpoints/openAI');
const {
handleAbort,
setHeaders,
validateModel,
validateEndpoint,
buildEndpointOption,
moderateText,
} = require('~/server/middleware');
const router = express.Router();
router.use(moderateText);
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await AskController(req, res, next, initializeClient, addTitle);
},
);
module.exports = router;

View File

@@ -1,37 +0,0 @@
const express = require('express');
const router = express.Router();
const {
setHeaders,
handleAbort,
moderateText,
// validateModel,
// validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
const { initializeClient } = require('~/server/services/Endpoints/bedrock');
const AgentController = require('~/server/controllers/agents/request');
const addTitle = require('~/server/services/Endpoints/agents/title');
router.use(moderateText);
/**
* @route POST /
* @desc Chat with an assistant
* @access Public
* @param {express.Request} req - The request object, containing the request data.
* @param {express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
router.post(
'/',
// validateModel,
// validateEndpoint,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await AgentController(req, res, next, initializeClient, addTitle);
},
);
module.exports = router;

View File

@@ -1,35 +0,0 @@
const express = require('express');
const {
uaParser,
checkBan,
requireJwtAuth,
messageIpLimiter,
concurrentLimiter,
messageUserLimiter,
} = require('~/server/middleware');
const { isEnabled } = require('~/server/utils');
const chat = require('./chat');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
const router = express.Router();
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) {
router.use(concurrentLimiter);
}
if (isEnabled(LIMIT_MESSAGE_IP)) {
router.use(messageIpLimiter);
}
if (isEnabled(LIMIT_MESSAGE_USER)) {
router.use(messageUserLimiter);
}
router.use('/chat', chat);
module.exports = router;

View File

@@ -1,10 +1,11 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getProjectByName } = require('~/models/Project');
const { isEnabled } = require('~/server/utils');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
const router = express.Router();
const emailLoginEnabled =
@@ -21,6 +22,7 @@ const publicSharedLinksEnabled =
router.get('/', async function (req, res) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
if (cachedStartupConfig) {
res.send(cachedStartupConfig);
@@ -96,6 +98,18 @@ router.get('/', async function (req, res) {
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
};
payload.mcpServers = {};
const config = await getCustomConfig();
if (config?.mcpServers != null) {
for (const serverName in config.mcpServers) {
const serverConfig = config.mcpServers[serverName];
payload.mcpServers[serverName] = {
customUserVars: serverConfig?.customUserVars || {},
};
}
}
/** @type {TCustomConfig['webSearch']} */
const webSearchConfig = req.app.locals.webSearch;
if (

View File

@@ -1,207 +0,0 @@
const express = require('express');
const { getResponseSender } = require('librechat-data-provider');
const {
setHeaders,
moderateText,
validateModel,
handleAbortError,
validateEndpoint,
buildEndpointOption,
createAbortController,
} = require('~/server/middleware');
const { sendMessage, createOnProgress, formatSteps, formatAction } = require('~/server/utils');
const { initializeClient } = require('~/server/services/Endpoints/gptPlugins');
const { saveMessage, updateMessage } = require('~/models');
const { validateTools } = require('~/app');
const { logger } = require('~/config');
const router = express.Router();
router.use(moderateText);
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res) => {
let {
text,
generation,
endpointOption,
conversationId,
responseMessageId,
isContinued = false,
parentMessageId = null,
overrideParentMessageId = null,
} = req.body;
logger.debug('[/edit/gptPlugins]', {
text,
generation,
isContinued,
conversationId,
...endpointOption,
});
let userMessage;
let userMessagePromise;
let promptTokens;
const sender = getResponseSender({
...endpointOption,
model: endpointOption.modelOptions.model,
});
const userMessageId = parentMessageId;
const user = req.user.id;
const plugin = {
loading: true,
inputs: [],
latest: null,
outputs: null,
};
const getReqData = (data = {}) => {
for (let key in data) {
if (key === 'userMessage') {
userMessage = data[key];
} else if (key === 'userMessagePromise') {
userMessagePromise = data[key];
} else if (key === 'responseMessageId') {
responseMessageId = data[key];
} else if (key === 'promptTokens') {
promptTokens = data[key];
}
}
};
const {
onProgress: progressCallback,
sendIntermediateMessage,
getPartialText,
} = createOnProgress({
generation,
onProgress: () => {
if (plugin.loading === true) {
plugin.loading = false;
}
},
});
const onChainEnd = (data) => {
let { intermediateSteps: steps } = data;
plugin.outputs = steps && steps[0].action ? formatSteps(steps) : 'An error occurred.';
plugin.loading = false;
saveMessage(
req,
{ ...userMessage, user },
{ context: 'api/server/routes/ask/gptPlugins.js - onChainEnd' },
);
sendIntermediateMessage(res, {
plugin,
parentMessageId: userMessage.messageId,
messageId: responseMessageId,
});
// logger.debug('CHAIN END', plugin.outputs);
};
const getAbortData = () => ({
sender,
conversationId,
userMessagePromise,
messageId: responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId,
text: getPartialText(),
plugin: { ...plugin, loading: false },
userMessage,
promptTokens,
});
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
try {
endpointOption.tools = await validateTools(user, endpointOption.tools);
const { client } = await initializeClient({ req, res, endpointOption });
const onAgentAction = (action, start = false) => {
const formattedAction = formatAction(action);
plugin.inputs.push(formattedAction);
plugin.latest = formattedAction.plugin;
if (!start && !client.skipSaveUserMessage) {
saveMessage(
req,
{ ...userMessage, user },
{ context: 'api/server/routes/ask/gptPlugins.js - onAgentAction' },
);
}
sendIntermediateMessage(res, {
plugin,
parentMessageId: userMessage.messageId,
messageId: responseMessageId,
});
// logger.debug('PLUGIN ACTION', formattedAction);
};
let response = await client.sendMessage(text, {
user,
generation,
isContinued,
isEdited: true,
conversationId,
parentMessageId,
responseMessageId,
overrideParentMessageId,
getReqData,
onAgentAction,
onChainEnd,
onStart,
...endpointOption,
progressCallback,
progressOptions: {
res,
plugin,
// parentMessageId: overrideParentMessageId || userMessageId,
},
abortController,
});
if (overrideParentMessageId) {
response.parentMessageId = overrideParentMessageId;
}
logger.debug('[/edit/gptPlugins] CLIENT RESPONSE', response);
const { conversation = {} } = await response.databasePromise;
delete response.databasePromise;
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
sendMessage(res, {
title: conversation.title,
final: true,
conversation,
requestMessage: userMessage,
responseMessage: response,
});
res.end();
response.plugin = { ...plugin, loading: false };
await updateMessage(
req,
{ ...response, user },
{ context: 'api/server/routes/edit/gptPlugins.js' },
);
} catch (error) {
const partialText = getPartialText();
handleAbortError(res, req, error, {
partialText,
conversationId,
sender,
messageId: responseMessageId,
parentMessageId: userMessageId ?? parentMessageId,
});
}
},
);
module.exports = router;

View File

@@ -3,7 +3,6 @@ const openAI = require('./openAI');
const custom = require('./custom');
const google = require('./google');
const anthropic = require('./anthropic');
const gptPlugins = require('./gptPlugins');
const { isEnabled } = require('~/server/utils');
const { EModelEndpoint } = require('librechat-data-provider');
const {
@@ -39,7 +38,6 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
router.use(validateConvoAccess);
router.use([`/${EModelEndpoint.azureOpenAI}`, `/${EModelEndpoint.openAI}`], openAI);
router.use(`/${EModelEndpoint.gptPlugins}`, gptPlugins);
router.use(`/${EModelEndpoint.anthropic}`, anthropic);
router.use(`/${EModelEndpoint.google}`, google);
router.use(`/${EModelEndpoint.custom}`, custom);

View File

@@ -1,6 +1,7 @@
const fs = require('fs').promises;
const express = require('express');
const { getAvatarProcessFunction, resizeAvatar } = require('@librechat/auth');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { filterFile } = require('~/server/services/Files/process');
const { logger } = require('~/config');
@@ -25,7 +26,7 @@ router.post('/', async (req, res) => {
desiredFormat,
});
const processAvatar = getAvatarProcessFunction(fileStrategy);
const { processAvatar } = getStrategyFunctions(fileStrategy);
const url = await processAvatar({ buffer: resizedBuffer, userId, manual });
res.json({ url });

View File

@@ -283,7 +283,10 @@ router.post('/', async (req, res) => {
message += ': ' + error.message;
}
if (error.message?.includes('Invalid file format')) {
if (
error.message?.includes('Invalid file format') ||
error.message?.includes('No OCR result')
) {
message = error.message;
}

View File

@@ -1,3 +1,4 @@
const accessPermissions = require('./accessPermissions');
const assistants = require('./assistants');
const categories = require('./categories');
const tokenizer = require('./tokenizer');
@@ -9,7 +10,6 @@ const presets = require('./presets');
const prompts = require('./prompts');
const balance = require('./balance');
const plugins = require('./plugins');
const bedrock = require('./bedrock');
const actions = require('./actions');
const banner = require('./banner');
const search = require('./search');
@@ -26,10 +26,10 @@ const auth = require('./auth');
const edit = require('./edit');
const keys = require('./keys');
const user = require('./user');
const ask = require('./ask');
const mcp = require('./mcp');
module.exports = {
ask,
mcp,
edit,
auth,
keys,
@@ -45,7 +45,6 @@ module.exports = {
search,
config,
models,
bedrock,
prompts,
plugins,
actions,
@@ -58,4 +57,5 @@ module.exports = {
assistants,
categories,
staticRoute,
accessPermissions,
};

205
api/server/routes/mcp.js Normal file
View File

@@ -0,0 +1,205 @@
const { Router } = require('express');
const { MCPOAuthHandler } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { requireJwtAuth } = require('~/server/middleware');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const router = Router();
/**
* Initiate OAuth flow
* This endpoint is called when the user clicks the auth link in the UI
*/
router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
try {
const { serverName } = req.params;
const { userId, flowId } = req.query;
const user = req.user;
// Verify the userId matches the authenticated user
if (userId !== user.id) {
return res.status(403).json({ error: 'User mismatch' });
}
logger.debug('[MCP OAuth] Initiate request', { serverName, userId, flowId });
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
/** Flow state to retrieve OAuth config */
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
logger.error('[MCP OAuth] Flow state not found', { flowId });
return res.status(404).json({ error: 'Flow not found' });
}
const { serverUrl, oauth: oauthConfig } = flowState.metadata || {};
if (!serverUrl || !oauthConfig) {
logger.error('[MCP OAuth] Missing server URL or OAuth config in flow state');
return res.status(400).json({ error: 'Invalid flow state' });
}
const { authorizationUrl, flowId: oauthFlowId } = await MCPOAuthHandler.initiateOAuthFlow(
serverName,
serverUrl,
userId,
oauthConfig,
);
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
// Redirect user to the authorization URL
res.redirect(authorizationUrl);
} catch (error) {
logger.error('[MCP OAuth] Failed to initiate OAuth', error);
res.status(500).json({ error: 'Failed to initiate OAuth' });
}
});
/**
* OAuth callback handler
* This handles the OAuth callback after the user has authorized the application
*/
router.get('/:serverName/oauth/callback', async (req, res) => {
try {
const { serverName } = req.params;
const { code, state, error: oauthError } = req.query;
logger.debug('[MCP OAuth] Callback received', {
serverName,
code: code ? 'present' : 'missing',
state,
error: oauthError,
});
if (oauthError) {
logger.error('[MCP OAuth] OAuth error received', { error: oauthError });
return res.redirect(`/oauth/error?error=${encodeURIComponent(String(oauthError))}`);
}
if (!code || typeof code !== 'string') {
logger.error('[MCP OAuth] Missing or invalid code');
return res.redirect('/oauth/error?error=missing_code');
}
if (!state || typeof state !== 'string') {
logger.error('[MCP OAuth] Missing or invalid state');
return res.redirect('/oauth/error?error=missing_state');
}
// Extract flow ID from state
const flowId = state;
logger.debug('[MCP OAuth] Using flow ID from state', { flowId });
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
if (!flowState) {
logger.error('[MCP OAuth] Flow state not found for flowId:', flowId);
return res.redirect('/oauth/error?error=invalid_state');
}
logger.debug('[MCP OAuth] Flow state details', {
serverName: flowState.serverName,
userId: flowState.userId,
hasMetadata: !!flowState.metadata,
hasClientInfo: !!flowState.clientInfo,
hasCodeVerifier: !!flowState.codeVerifier,
});
// Complete the OAuth flow
logger.debug('[MCP OAuth] Completing OAuth flow');
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
// For system-level OAuth, we need to store the tokens and retry the connection
if (flowState.userId === 'system') {
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
}
/** ID of the flow that the tool/connection is waiting for */
const toolFlowId = flowState.metadata?.toolFlowId;
if (toolFlowId) {
logger.debug('[MCP OAuth] Completing tool flow', { toolFlowId });
await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
}
/** Redirect to success page with flowId and serverName */
const redirectUrl = `/oauth/success?serverName=${encodeURIComponent(serverName)}`;
res.redirect(redirectUrl);
} catch (error) {
logger.error('[MCP OAuth] OAuth callback error', error);
res.redirect('/oauth/error?error=callback_failed');
}
});
/**
* Get OAuth tokens for a completed flow
* This is primarily for user-level OAuth flows
*/
router.get('/oauth/tokens/:flowId', requireJwtAuth, async (req, res) => {
try {
const { flowId } = req.params;
const user = req.user;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
// Allow system flows or user-owned flows
if (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) {
return res.status(403).json({ error: 'Access denied' });
}
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
return res.status(404).json({ error: 'Flow not found' });
}
if (flowState.status !== 'COMPLETED') {
return res.status(400).json({ error: 'Flow not completed' });
}
res.json({ tokens: flowState.result });
} catch (error) {
logger.error('[MCP OAuth] Failed to get tokens', error);
res.status(500).json({ error: 'Failed to get tokens' });
}
});
/**
* Check OAuth flow status
* This endpoint can be used to poll the status of an OAuth flow
*/
router.get('/oauth/status/:flowId', async (req, res) => {
try {
const { flowId } = req.params;
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
return res.status(404).json({ error: 'Flow not found' });
}
res.json({
status: flowState.status,
completed: flowState.status === 'COMPLETED',
failed: flowState.status === 'FAILED',
error: flowState.error,
});
} catch (error) {
logger.error('[MCP OAuth] Failed to get flow status', error);
res.status(500).json({ error: 'Failed to get flow status' });
}
});
module.exports = router;

View File

@@ -1,37 +1,43 @@
const express = require('express');
const { Tokenizer } = require('@librechat/api');
const { Tokenizer, generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
getAllUserMemories,
toggleUserMemories,
createMemory,
setMemory,
deleteMemory,
setMemory,
} = require('~/models');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { requireJwtAuth } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
const checkMemoryRead = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.READ,
]);
const checkMemoryCreate = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.CREATE,
]);
const checkMemoryUpdate = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.UPDATE,
]);
const checkMemoryDelete = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.UPDATE,
]);
const checkMemoryOptOut = generateCheckAccess(PermissionTypes.MEMORIES, [
Permissions.USE,
Permissions.OPT_OUT,
]);
const checkMemoryRead = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.READ],
getRoleByName,
});
const checkMemoryCreate = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
const checkMemoryUpdate = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.UPDATE],
getRoleByName,
});
const checkMemoryDelete = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.UPDATE],
getRoleByName,
});
const checkMemoryOptOut = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.OPT_OUT],
getRoleByName,
});
router.use(requireJwtAuth);

View File

@@ -1,6 +1,7 @@
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
const express = require('express');
const passport = require('passport');
const { randomState } = require('openid-client');
const {
checkBan,
logHeaders,
@@ -8,7 +9,8 @@ const {
setBalanceConfig,
checkDomainAllowed,
} = require('~/server/middleware');
const { setAuthTokens, setOpenIDAuthTokens } = require('@librechat/auth');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
@@ -34,6 +36,7 @@ const oauthHandler = async (req, res) => {
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, res);
} else {
await setAuthTokens(req.user._id, res);
@@ -46,7 +49,9 @@ const oauthHandler = async (req, res) => {
router.get('/error', (req, res) => {
// A single error message is pushed by passport when authentication fails.
logger.error('Error in OAuth authentication:', { message: req.session.messages.pop() });
logger.error('Error in OAuth authentication:', {
message: req.session?.messages?.pop() || 'Unknown error',
});
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
res.redirect(`${domains.client}/login?redirect=false`);
@@ -103,8 +108,7 @@ router.get(
/**
* OpenID Routes
*/
router.get('/openid', async (req, res, next) => {
const { randomState } = await import('openid-client');
router.get('/openid', (req, res, next) => {
return passport.authenticate('openid', {
session: false,
state: randomState(),

View File

@@ -1,5 +1,7 @@
const express = require('express');
const { PermissionTypes, Permissions, SystemRoles } = require('librechat-data-provider');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const { Permissions, SystemRoles, PermissionTypes } = require('librechat-data-provider');
const {
getPrompt,
getPrompts,
@@ -14,24 +16,30 @@ const {
// updatePromptLabels,
makePromptProduction,
} = require('~/models/Prompt');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { logger } = require('~/config');
const { requireJwtAuth } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
const checkPromptAccess = generateCheckAccess(PermissionTypes.PROMPTS, [Permissions.USE]);
const checkPromptCreate = generateCheckAccess(PermissionTypes.PROMPTS, [
Permissions.USE,
Permissions.CREATE,
]);
const checkPromptAccess = generateCheckAccess({
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE],
getRoleByName,
});
const checkPromptCreate = generateCheckAccess({
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
const checkGlobalPromptShare = generateCheckAccess(
PermissionTypes.PROMPTS,
[Permissions.USE, Permissions.CREATE],
{
const checkGlobalPromptShare = generateCheckAccess({
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE, Permissions.CREATE],
bodyProps: {
[Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
},
);
getRoleByName,
});
router.use(requireJwtAuth);
router.use(checkPromptAccess);

View File

@@ -1,15 +1,15 @@
const express = require('express');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
getSharedLink,
getSharedMessages,
createSharedLink,
updateSharedLink,
getSharedLinks,
deleteSharedLink,
} = require('~/models/Share');
getSharedLinks,
getSharedLink,
} = require('~/models');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { isEnabled } = require('~/server/utils');
const router = express.Router();
/**
@@ -35,6 +35,7 @@ if (allowSharedLinks) {
res.status(404).end();
}
} catch (error) {
logger.error('Error getting shared messages:', error);
res.status(500).json({ message: 'Error getting shared messages' });
}
},
@@ -54,9 +55,7 @@ router.get('/', requireJwtAuth, async (req, res) => {
sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
? req.query.sortDirection
: 'desc',
search: req.query.search
? decodeURIComponent(req.query.search.trim())
: undefined,
search: req.query.search ? decodeURIComponent(req.query.search.trim()) : undefined,
};
const result = await getSharedLinks(
@@ -75,7 +74,7 @@ router.get('/', requireJwtAuth, async (req, res) => {
hasNextPage: result.hasNextPage,
});
} catch (error) {
console.error('Error getting shared links:', error);
logger.error('Error getting shared links:', error);
res.status(500).json({
message: 'Error getting shared links',
error: error.message,
@@ -93,6 +92,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
conversationId: req.params.conversationId,
});
} catch (error) {
logger.error('Error getting shared link:', error);
res.status(500).json({ message: 'Error getting shared link' });
}
});
@@ -106,6 +106,7 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => {
res.status(404).end();
}
} catch (error) {
logger.error('Error creating shared link:', error);
res.status(500).json({ message: 'Error creating shared link' });
}
});
@@ -119,6 +120,7 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => {
res.status(404).end();
}
} catch (error) {
logger.error('Error updating shared link:', error);
res.status(500).json({ message: 'Error updating shared link' });
}
});
@@ -133,7 +135,8 @@ router.delete('/:shareId', requireJwtAuth, async (req, res) => {
return res.status(200).json(result);
} catch (error) {
return res.status(400).json({ message: error.message });
logger.error('Error deleting shared link:', error);
return res.status(400).json({ message: 'Error deleting shared link' });
}
});

View File

@@ -1,18 +1,24 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
getConversationTags,
updateTagsForConversation,
updateConversationTag,
createConversationTag,
deleteConversationTag,
updateTagsForConversation,
getConversationTags,
} = require('~/models/ConversationTag');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { logger } = require('~/config');
const { requireJwtAuth } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
const checkBookmarkAccess = generateCheckAccess(PermissionTypes.BOOKMARKS, [Permissions.USE]);
const checkBookmarkAccess = generateCheckAccess({
permissionType: PermissionTypes.BOOKMARKS,
permissions: [Permissions.USE],
getRoleByName,
});
router.use(requireJwtAuth);
router.use(checkBookmarkAccess);

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