Compare commits

...

93 Commits

Author SHA1 Message Date
Ruben Talstra
f1a69e8b6b Merge branch 'main' into refactor/openid-strategy 2025-05-14 21:14:39 +02:00
github-actions[bot]
5b402a755e 🌍 i18n: Update translation.json with latest translations (#7375)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-14 13:09:52 -04:00
Ruben Talstra
b0405be9ea 🌍 i18n: Add Danish and Czech and Catalan localization support (#7373)
* 🌍 i18n: Add Danish and Czech localization support

* 🌍 i18n: Correct Czech language code from 'sc-CZ' to 'cs-CZ'

* 🌍 i18n: Add Catalan localization support
2025-05-14 13:08:06 -04:00
github-actions[bot]
3f4dd08589 📜 docs: Unreleased Changelog (#7321)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-14 13:07:21 -04:00
Ruben Talstra
7faff2c75f Merge branch 'main' into refactor/openid-strategy 2025-05-14 18:46:45 +02:00
Danny Avila
d5b399550e 📦 chore: Update API Package Dependencies (#7359)
* chore: temporarily remove @librechat/agents

* chore: bump @langchain/google-genai to v0.2.8

* chore: bump @langchain/google-vertexai to v0.2.8

* chore: bump @langchain/core to v0.3.55

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

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

* chore: update title for Unreleased Changelog PR to include documentation emoji

* chore: add workflow_dispatch trigger and update Pull Request title for changelog
2025-05-13 15:31:06 -04:00
Danny Avila
a5ff8253a4 🎏 feat: Add MCP support for Streamable HTTP Transport [2/2] (#7353)
- fixes type/packages issues not resolved in #7353
2025-05-13 13:26:37 -04:00
Ben Verhees
0b44142383 🎏 feat: Add MCP support for Streamable HTTP Transport (#7353) 2025-05-13 13:14:15 -04:00
matt burnett
502617db24 🔄 fix: update navigation logic in useFocusChatEffect to ensure correct search parameters are used (#7340) 2025-05-13 08:24:40 -04:00
Danny Avila
f2f285ca1e 🔑 fix: use apiKey instead of openAIApiKey in OpenAI-like Config (#7337) 2025-05-12 14:35:14 -04:00
Marco Beretta
6dd1b39886 💬 fix: update aria-label for accessibility in ConvoLink (#7320) 2025-05-12 08:12:51 -04:00
github-actions[bot]
5a43f87584 📜 docs: CHANGELOG for release v0.7.8 (#7290)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-12 08:10:58 -04:00
matt burnett
4af72aac9b feat: implement search parameter updates (#7151)
* feat: implement search parameter updates

* Update url params when values change

reset params on new chat

move logic to families.ts

revert unchanged files

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-05-09 13:03:33 -04:00
Danny Avila
f7777a2723 v0.7.8 (#7287)
*  v0.7.8

* chore: bump data-provider to v0.7.82

* chore: update CONFIG_VERSION to 1.2.5

* chore: bump librechat-mcp version to 1.2.2

* chore: bump @librechat/data-schemas version to 0.0.7
2025-05-08 13:28:40 -04:00
github-actions[bot]
e5b234bc72 📜 docs: Unreleased Changelog (#7214)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-08 13:09:30 -04:00
Josh Nichols
4f2ed46450 🐋 feat: Add python to Dockerfile for increased MCP compatibility (#7270)
Without this, it's not possible to run any MCPs that use python, only node.

So, add these to enable using things that use `uvx` similar to what
the documentation already talks about for `npx`.
2025-05-08 12:32:12 -04:00
Danny Avila
66093b1eb3 💬 refactor: MCP Chat Visibility Option, Google Rates, Remove OpenAPI Plugins (#7286)
* fix: Update Gemini 2.5 Pro Preview Model Name in Token Values

* refactor: Update DeleteButton to close menu when deletion is successful

* refactor: Add unmountOnHide prop to DropdownPopup in multiple components

* chore: linting

* chore: linting

* feat: Add `chatMenu` option for MCP Servers to control visibility in MCPSelect dropdown

* refactor: Update loadManifestTools to return combined tool manifest with MCP tools first

* chore: remove deprecated openapi plugins

* chore: linting

* chore(AgentClient): linting, remove unnecessary `checkVisionRequest` logger

* refactor(AuthService): change logoutUser logging from error to debug level

* chore: new Gemini models token values and rates

* chore(AskController): linting
2025-05-08 12:12:36 -04:00
Danny Avila
d7390d24ec 🔄 fix: Ollama Think Tag Edge Case with Tools (#7275) 2025-05-07 17:49:42 -04:00
Danny Avila
71105cd49c 🔄 fix: Assistants Endpoint & Minor Issues (#7274)
* 🔄 fix: Include usage in stream options for OpenAI and Azure endpoints

* fix: Agents support for Azure serverless endpoints

* fix: Refactor condition for assistants and azureAssistants endpoint handling

* AWS Titan via Bedrock: model doesn't support system messages, Closes #6456

* fix: Add EndpointSchemaKey type to endpoint parameters in buildDefaultConvo and ensure assistantId is always defined

* fix: Handle new conversation state for assistants endpoint in finalHandler

* fix: Add spec and iconURL parameters to `saveAssistantMessage` to persist modelSpec fields

* fix: Handle assistant unlinking even if no valid files to delete

* chore: move type definitions from callbacks.js to typedefs.js

* chore: Add StandardGraph typedef to typedefs.js

* chore: Update parameter type for graph in ModelEndHandler to StandardGraph

---------

Co-authored-by: Andres Restrepo <andres@enric.ai>
2025-05-07 17:11:33 -04:00
Marlon
3606349a0f 📝 docs: Update .env.example Google models (#7254)
This pull request updates the GOOGLE_MODELS and GOOGLE_TITLE_MODEL examples in the .env.example file to reflect the currently available models on Google AI Studio (Gemini API) and Vertex AI.
Many of the models previously listed in the example file have since been deprecated or are no longer the primary recommended versions. This discrepancy could lead to confusion for new users setting up the project, potentially causing them to select non-functional or outdated model identifiers, resulting in errors or suboptimal performance.
The changes in this PR ensure that:
- The model lists for both Gemini API (AI Studio) and Vertex AI are synchronized with the current offerings.
- New users have a more accurate and reliable starting point when configuring their environment.
- The likelihood of encountering issues due to deprecated model names during initial setup is significantly reduced.
2025-05-07 11:19:06 -04:00
glowforge-opensource
e3e796293c 🔍 feat: Additional Tavily API Tool Parameters (#7232)
* feat: expose additional Tavily API parameters for tool

The following parameters are part of Tavily API but were previously not exposed for agents to use via the tool. Now they are. The source documentation is here: https://docs.tavily.com/documentation/api-reference/endpoint/search

include_raw_content - returns the full text of found web pages (default is false)
include_domains - limit search to this list of domains (default is none)
exclude_domains - exclude this list of domains form search (default is none)
topic - enum of "general", "news", or "finance" (default is "general")
time_range - enum of "day", "week", "month", or "year" (default unlimited)
days - number of days to search (default is 7, but only applicable to topic == "news")
include_image_descriptions - include a description of the image in the search results (default is false)

It is a little odd that they have both time_range and days, but there it is.

I have noticed that this change requires a little bit of care in prompting to make sure that it doesn't use "news" when you wanted "general". I've attemtped to hint that in the tool description.

* correct lint error

* more lint

---------

Co-authored-by: Michael Natkin <michaeln@glowforge.com>
2025-05-06 22:50:11 -04:00
Danny Avila
7c4c3a8796 🔄 fix: URL Param Race Condition and File Draft Persistence (#7257)
* chore(useAutoSave): linting

* fix: files attached during streaming disappear when stream finishes

* fix(useQueryParams): query parameter processing race condition with submission handling, add JSDocs to all functions/hooks

* test(useQueryParams): add comprehensive tests for query parameter handling and submission logic
2025-05-06 22:49:12 -04:00
andresgit
20c9f1a783 🎨 style: Improve KaTeX Rendering for LaTeX Equations (#7223) 2025-05-06 10:50:09 -04:00
Danny Avila
8e1012c5aa 🛡️ fix: Deep Clone MCPOptions for User MCP Connections (#7247)
* Fix: Prevent side effects in `processMCPEnv` by deep cloning MCPOptions

The `processMCPEnv` function was modifying the original `MCPOptions` object, leading to unintended side effects where `LIBRECHAT_USER_ID` could be incorrectly shared across different users. This commit addresses this issue by performing a deep clone of the `MCPOptions` object before processing, ensuring that modifications are isolated and do not affect other users.

* ci: Add tests for processMCPEnv to ensure deep cloning, user ID isolation and environment variable processing

---------

Co-authored-by: Alex C <viennadd@users.noreply.github.com>
2025-05-06 10:29:05 -04:00
Danny Avila
7c92cef2b7 🔖 fix: Custom Headers for Initial MCP SSE Connection (#7246)
* refactor: add custom  to  as workaround to include custom headers to the initial connection request

* chore: bump MCP client version to 1.2.1 in package-lock and package.json for librechat-mcp
2025-05-06 10:14:17 -04:00
Danny Avila
4fbb81c774 🔄 fix: o-Series Model Regex for System Messages (#7245)
* fix: no system message only for o1-preview and o1-mini

* chore(OpenAIClient): linting

* fix: update regex to include o1-preview and o1-mini in noSystemModelRegex

* refactor: rename variable for consistency with AgentClient

---------

Co-authored-by: Andres <9771158+andresgit@users.noreply.github.com>
2025-05-06 08:40:00 -04:00
Marco Beretta
fc6e14efe2 feat: Enhance form submission for touch screens (#7198)
*  feat: Enhance form submission for touch screens

* chore: add comment

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

* chore: add comment

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

* chore: linting in AnthropicClient

* chore: Add anthropic model outputs for Claude 3.7

* refactor: Simplify touch-screen detection in message submission

* fix: Correct button rendering order for chat collapse/expand icons

* Revert "refactor: Simplify touch-screen detection in message submission"

This reverts commit 8638442a4c.

* refactor: Improve touchscreen detection for focus handling in ChatForm and useFocusChatEffect

* chore: EditMessage linting

* refactor: Reorder dropdown items in ExportAndShareMenu

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-05-05 09:23:38 -04:00
Danny Avila
6e663b2480 🛠️ fix: Conversation Navigation State (#7210)
* refactor: Enhance initial conversation query condition for better state management and prevent unused network requests

* ifx: Add Prettier plugin to ESLint configuration

* chore: linting and typing in convos.spec.ts

* fix: add back fresh data fetching and improve error handling for  conversation navigation

* fix: set conversation only with  conversation state change intent, to prevent double queries for messages
2025-05-04 10:44:40 -04:00
matt burnett
ddb2141eac 🧰 chore: ESLint configuration to enforce Prettier formatting rules (#7186) 2025-05-02 15:13:31 -04:00
Danny Avila
37b50736bc 🔧 fix: Google Gemma Support & OpenAI Reasoning Instructions (#7196)
* 🔄 chore: Update @langchain/google-vertexai to version 0.2.5 in package.json and package-lock.json

* chore: temp remove agents

* 🔄 chore: Update @langchain/google-genai to version 0.2.5 in package.json and package-lock.json

* 🔄 chore: Update @langchain/community to version 0.3.42 in package.json and package-lock.json

* 🔄 chore: Add license information for @langchain/textsplitters in package-lock.json

* 🔄 chore: Update @langchain/core to version 0.3.51 in package.json and package-lock.json

* 🔄 chore: Update openai dependency to version 4.96.2 in package.json and package-lock.json

* chore: @librechat/agents to v2.4.30

* fix: streaming condition in ModelEndHandler to account for boundModel `disableStreaming` setting

* fix: update regex for noSystemModel and refactor message handling in AgentClient

* feat: Google Gemma models

* chore: remove unnecessary empty JSX fragment in PopoverButtons component
2025-05-02 15:11:50 -04:00
Danny Avila
5d6d13efe8 🌿 refactor: Unmount Fork Popover on Hide for Performance (#7189) 2025-05-02 02:43:59 -04:00
Danny Avila
5efad8f646 📦 chore: Bump Package Security (#7183)
* 🔄 chore: bump supertest to 7.1.0, resolves CVE-2025-46653

* 🔄 chore: update vite to version 6.3.4 and add fdir, picomatch, and tinyglobby as dev dependencies

* 🔄 chore: npm audit fix: remove unused dependencies fdir, picomatch, and tinyglobby from package-lock.json
2025-05-01 15:02:51 -04:00
Danny Avila
9a7f763714 🔄 refactor: Artifact Visibility Management (#7181)
* fix: Reset artifacts on unmount and remove useIdChangeEffect hook

* feat: Replace SVG icons with Lucide icons for improved consistency

* fix: Refactor artifact reset logic on unmount and conversation change

* refactor: Rename artifactsVisible to artifactsVisibility for consistency

* feat: Replace custom SVG icons with Lucide icons for improved consistency

* feat: Add visibleArtifacts atom for managing visibility state

* feat: Implement debounced visibility state management for artifacts

* refactor: Add useIdChangeEffect hook to reset visible artifacts on conversation ID change

* refactor: Remove unnecessary dependency from useMemo in TextPart component

* refactor: Enhance artifact visibility management by incorporating location checks for search path

* refactor: Improve transition effects for artifact visibility in Artifacts component

* chore: Remove preprocessCodeArtifacts function and related tests

* fix: Update regex for detecting enclosed artifacts in latest message

* refactor: Update artifact visibility checks to be more generic (not just search)

* chore: Enhance artifact visibility logging

* refactor: Extract closeArtifacts function to improve button click handling

* refactor: remove nested logic from use artifacts effect

* refactor: Update regex for detecting enclosed artifacts to handle new line variations
2025-05-01 14:40:39 -04:00
github-actions[bot]
e6e7935fd8 📜 docs: CHANGELOG for release v0.7.8-rc1 (#7153)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-30 08:54:43 -04:00
github-actions[bot]
18dc3f8686 📜 docs: Unreleased changelog (#6265)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-29 17:59:11 -04:00
Danny Avila
fe512005fc v0.7.8-rc1 (#7149)
*  v0.7.8-rc1

* chore: Enable manual triggering of the Generate Unreleased Changelog workflow
2025-04-29 17:55:25 -04:00
github-actions[bot]
da131b6c59 🌍 i18n: Update translation.json with latest translations (#7148)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-29 17:40:50 -04:00
Danny Avila
dd23559d1f 👐 a11y: Improve Fork and SplitText Accessibility (#7147)
* refactor: Replace Popover with Ariakit components for improved accessibility and UX

* wip: first pass, fork a11y

* feat(i18n): Add localization for fork options and related UI elements

* fix: Ensure Dropdown component has correct z-index for proper layering

* style: Update Fork PopoverButton styles and remove unused sideOffset prop

* style: Update text colors and spacing in Fork component for improved readability

* style: Enhance Fork component's UI by adding select-none class to prevent text selection

* chore: Remove unused Checkbox import from Fork component

* fix: Add sr-only span for accessibility in SplitText component

* chore: Reorder imports in Fork component for better organization
2025-04-29 17:39:12 -04:00
Peter
a6f0a8244f 🐙 fix: Add Redis Ping Interval to Prevent Connection Drops (#7127)
Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
2025-04-29 10:02:38 -04:00
github-actions[bot]
f04f8f53be 🌍 i18n: Update translation.json with latest translations (#7126)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-29 09:58:48 -04:00
Danny Avila
a89a3f4146 🐋 fix: Improve Deepseek Compatbility (#7132)
* refactor: Update schema conversion to allow nullable optional fields

* feat: Add support for 'Deepseek' model in response sender logic

* fix: Normalize endpoint case for legacy content handling in AgentClient (fixes `deepseek-chat` followup issues)
2025-04-29 09:55:43 -04:00
Danny Avila
55f5f2d11a 🗓️ feat: Add Special Variables for Prompts & Agents, Prompt UI Improvements (#7123)
* wip: Add Instructions component for agent configuration

*  feat: Implement DropdownPopup for variable insertion in instructions

* refactor: Enhance variable handling by exporting specialVariables and updating Markdown components

* feat: Add special variable support for current date and user in Instructions component

* refactor: Update handleAddVariable to include localized label

* feat: replace special variables in instructions presets

* chore: update parameter type for user in getListAgents function

* refactor: integrate dayjs for date handling and move replaceSpecialVars function to data-provider

* feat: enhance replaceSpecialVars to include day number in current date format

* feat: integrate replaceSpecialVars for processing agent instructions

* feat: add support for current date & time in replaceSpecialVars function

* feat: add iso_datetime support in replaceSpecialVars function

* fix: enforce text parameter to be a required field in replaceSpecialVars function

* feat: add ISO datetime support in translation file

* fix: disable eslint warning for autoFocus in TextareaAutosize component

* feat: add VariablesDropdown component and integrate it into CreatePromptForm and PromptEditor; update translation for special variables

* fix: CategorySelector and related localizations

* fix: add z-index class to LanguageSTTDropdown for proper stacking context

* fix: add max-height and overflow styles to OGDialogContent in VariableDialog and PreviewPrompt components

* fix: update variable detection logic to exclude special variables and improve regex matching

* fix: improve accessibility text for actions menu in ChatGroupItem component

* fix: adjust max-width and height styles for dialog components and improve markdown rendering for light vs. dark, height/widths, etc.

* fix: remove commented-out code for better readability in PromptVariableGfm component

* fix: handle undefined input parameter in setParams function call

* fix: update variable label types to use TSpecialVarLabel for consistency

* fix: remove outdated information from special variables description in translation file

* fix: enhance unused i18next keys detection for special variable keys

* fix: update color classes for consistency/a11y in category and prompt variable components

* fix: update PromptVariableGfm component and special variable styles for consistency

* fix: improve variable highlighting logic in VariableForm component

* fix: update background color classes for consistency in VariableForm component

* fix: add missing ref parameter to Dialog component in OriginalDialog

* refactor: move navigate call for new conversation to after setConversation update

* refactor: move message query hook to client workspace; fix: handle edge case for navigation from finalHandler creating race condition for response message DB save

* chore: bump librechat-data-provider to 0.7.793

* ci: add unit tests for replaceSpecialVars function

* fix: implement getToolkitKey function for image_gen_oai toolkit filtering/including

* ci: enhance dayjs mock for consistent date/time values in tests

* fix: MCP stdio server fail to start when passing env property

* fix: use optional chaining for clientRef dereferencing in AskController and EditController
feat: add context to saveMessage call in streamResponse utility

* fix: only save error messages if the userMessageId was initialized

* refactor: add isNotAppendable check to disable inputs in ChatForm and useTextarea

* feat: enhance error handling in useEventHandlers and update conversation state in useNewConvo

* refactor: prepend underscore to conversationId in newConversation template

* feat: log aborted conversations with minimal messages and use consistent conversationId generation

---------

Co-authored-by: Olivier Schiavo <olivier.schiavo@wengo.com>
Co-authored-by: aka012 <aka012@neowiz.com>
Co-authored-by: jiasheng <jiashengguo@outlook.com>
2025-04-29 03:49:02 -04:00
Danny Avila
0e8041bcac 🔃 refactor: Streamline Navigation, Message Loading UX (#7118)
* chore: fix logging for illegal target endpoints in getEndpointFromSetup

* fix: prevent querying agent by ID for ephemeral agents

* refactor: reorder variable declarations in MessagesView for clarity

* fix: localize 'nothing found' message in MessagesView

* refactor: streamline navigation logic and enhance loading spinner component in ChatView

* refactor: simplify loading spinner logic in ChatView component

* fix: ensure message queries are invalidated after new conversation creation in HeaderNewChat, MobileNav, and NewChat components

* 🐛 First run dev mode will have error occur.

🐛 First run dev mode will have error occur.

* fix font-size localstorage presist bug

* Don't ping meilisearch if the search is disabled via env var

* simplify logic in search/enable endpoint

* refactor: simplify enable endpoint condition check

* feat: add useIdChangeEffect hook and integrate it into ChatRoute

---------

Co-authored-by: Ne0 <20765145+zeeklog@users.noreply.github.com>
Co-authored-by: TinyTin <garychangcn@hotmail.com>
Co-authored-by: Denis Palnitsky <denis.palnitsky@zendesk.com>
2025-04-28 18:18:13 -04:00
Danny Avila
fc30482f65 🪶 refactor: Chat Input Focus for Conversation Navigations & ChatForm Optimizations (#7100)
* refactor: improve ChatView layout by keeping ChatForm mounted

* feat: implement focusChat functionality for new conversations and navigations

* refactor: reset artifacts when navigating to prevent any from rendering in a conversation when none exist; edge case, artifacts get created by search route (TODO: use a different artifact renderer for Search markdown)
2025-04-27 18:28:28 -04:00
Danny Avila
6826c0ed43 🙌 a11y: Searchbar/Conversations List Focus (#7096)
* chore: remove redundancy of useSetRecoilState and useRecoilValue with useRecoilState in SearchBar

* refactor: remove unnecessary focus effect on text area in ChatForm

* refactor: improve searchbar and clear search button accessibility

* fix: add tabIndex to Conversations component for improved accessibility, moves focus directly conversation items

* style: adjust margin in Header component for improved layout symmetry with Nav

* chore: imports order
2025-04-27 15:13:19 -04:00
Danny Avila
550c7cc68a 🧭 refactor: Modernize Nav/Header (#7094)
* refactor: streamline model preset handling in conversation setup

* refactor: integrate navigation and location hooks in chat functions and event handlers, prevent cache from fetching on final event handling

* fix: prevent adding code interpreter non-image output to file list on message attachment event, fix all unhandled edge cases when this is done (treating the file download as an image attachment, undefined fields, message tokenCount issues, use of `startsWith` on undefined "text") although it is now prevent altogether

* chore: remove unused jailbreak prop from MinimalIcon component in EndpointIcon

* feat: add new SVG icons (MobileSidebar, Sidebar, XAIcon), fix: xAI styling in dark vs. light modes, adjust styling of Landing icons

* fix: open conversation in new tab on navigation with ctrl/meta key

* refactor: update Nav & Header to use close/open sidebar buttons, as well as redesign "New Chat"/"Bookmarks" buttons to the top of the Nav, matching the latest design of ChatGPT for simplicity and to free up space

* chore: remove unused isToggleHovering state and simplify opacity logic in Nav component

* style: match mobile nav to mobile header
2025-04-27 14:03:25 -04:00
Danny Avila
c0ebb434a6 🎨 feat: OpenAI Image Tools (GPT-Image-1) (#7079)
* wip: OpenAI Image Generation Tool with customizable options

* WIP: First pass OpenAI Image Generation Tool and integrate into existing tools

* 🔀 fix: Comment out unused validation for image generation tool parameters

* 🔀 refactor: Update primeResources function parameters for better destructuring

* feat: Add image_edit resource to EToolResources and update AgentToolResources interface

* feat: Enhance file retrieval with tool resource filtering for image editing

* refactor: add OpenAI Image Tools for generation and editing, refactor related components, pass current request image attachments as tool resources for editing

* refactor: Remove commented-out code and clean up API key retrieval in createOpenAIImageTools function

* fix: show message attachments in shared links

* fix: Correct parent message retrieval logic for regenerated messages in useChatFunctions

* fix: Update primeResources to utilize requestFileSet for image file processing

* refactor: Improve description for image generation tool and clarify usage conditions, only provide edit tool if there are images available to edit

* chore: Update OpenAI Image Tools icon to use local asset

* refactor: Update image generation tool description and logic to prioritize editing tool when files are uploaded

* refactor: Enhance image tool descriptions to clarify usage conditions and note potential unavailability of uploaded images

* refactor: Update useAttachmentHandler to accept queryClient to update query cache with newly created file

* refactor: Add customizable descriptions and prompts for OpenAI image generation and editing tools

* chore: Update comments to use JSDoc style for better clarity and consistency

* refactor: Rename config variable to clientConfig for clarity and update signal handling in image generation

* refactor: Update axios request configuration to include derived signal and baseURL for improved request handling

* refactor: Update baseURL environment variable for OpenAI image generation tool configuration

* refactor: Enhance axios request configuration with conditional headers and improved clientConfig setup

* chore: Update comments for clarity and remove unnecessary lines in OpenAI image tools

* refactor: Update description for image generation without files to clarify user instructions

* refactor: Simplify target parent message logic for regeneration and resubmission cases

* chore: Remove backticks from error messages in image generation and editing functions

* refactor: Rename toolResources to toolResourceSet for clarity in file retrieval functions

* chore: Remove redundant comments and clean up TODOs in OpenAI image tools

* refactor: Rename fileStrategy to appFileStrategy for clarity and improve error handling in image processing

* chore: Update react-resizable-panels to version 2.1.8 in package.json and package-lock.json

* chore: Ensure required validation for logs and Code of Conduct agreement in bug report template

* fix: Update ArtifactPreview to use startupConfig and currentCode from memoized props to prevent unnecessary re-renders

* fix: improve robustness of `save & submit` when used from a user-message with existing attachments

* fix: add null check for artifact index in CodeEditor to prevent errors, trigger re-render on artifact ID change

* fix: standardize default values for artifact properties in Artifact component, avoiding prematurely setting an "empty/default" artifact

* fix: reset current artifact ID before setting a new one in ArtifactButton to ensure correct state management

* chore: rename `setArtifactId` variable to `setCurrentArtifactId`  for consistency

* chore: update type annotations in File and S3 CRUD functions for consistency

* refactor: improve image handling in OpenAI tools by using image_id references and enhance tool context for image editing

* fix: update image_ids schema in image_edit_oai to enforce presence and provide clear guidelines for usage

* fix: enhance file fetching logic to ensure user-specific and dimension-validated results

* chore: add details on image generation and editing capabilities with various models
2025-04-26 04:30:58 -04:00
github-actions[bot]
0ee1dcc479 🌍 i18n: Update translation.json with latest translations (#6667)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-25 09:21:56 -04:00
Daniel (dB.) Doubrovkine
e467fbebfa 📙 docs: CONTRIBUTING.md (#6831) 2025-04-25 09:16:46 -04:00
Danny Avila
7f1d01c35a 🔀 fix: MCP Improvements, Auto-Save Drafts, Artifact Markup (#7040)
* feat: Update MCP tool creation to use lowercase provider name

* refactor: handle MCP image output edge cases where tool outputs must contain string responses

* feat: Drop 'anyOf' and 'oneOf' fields from JSON schema conversion

* feat: Transform 'oneOf' and 'anyOf' fields to Zod union in JSON schema conversion

* fix: artifactPlugin to replace textDirective with expected text, closes #7029

* fix: auto-save functionality to handle conversation transitions from pending drafts, closes #7027

* refactor: improve async handling during user disconnection process

* fix: use correct user ID variable for MCP tool calling

* fix: improve handling of pending drafts in auto-save functionality

* fix: add support for additional model names in getValueKey function

* fix: reset form values on agent deletion when no agents remain
2025-04-23 18:56:06 -04:00
Marco Beretta
150116eefe 🎨 style: standardize dropdown styling & fix z-Index layering (#6939)
* fix: Dropdown settings

* refactor: classname cleanup

* refactor: export modal

* fix: Export dropdown
2025-04-18 11:36:59 -04:00
Danny Avila
52f146dd97 🤖 feat: Support o4-mini and o3 Models (#6928)
* feat: Add support for new OpenAI models (o4-mini, o3) and update related logic

* 🔧 fix: Rename 'resubmitFiles' to 'isResubmission' for consistency across types and hooks

* 🔧 fix: Replace hardcoded 'pending_req' with CacheKeys.PENDING_REQ for consistency in cache handling

* 🔧 fix: Update cache handling to use Time.ONE_MINUTE instead of hardcoded TTL and streamline imports

* 🔧 fix: Enhance message handling logic to correctly identify parent messages and streamline imports in useSSE
2025-04-17 00:40:26 -04:00
Marco Beretta
88f4ad7c47 🔍 refactor: Search & Message Retrieval (#6903)
* refactor: conversation search fetch

* refactor: Message and Convo fetch with paramters and search

* refactor: update search states and cleanup old store states

* refactor: re-enable search API; fix: search conversation

* fix: message's convo fetch

* fix: redirect when searching

* chore: use logger instead of console

* fix: search message loading

* feat: small optimizations

* feat(Message): remove cache for search path

* fix: handle delete of all archivedConversation and sharedLinks

* chore: cleanup

* fix: search messages

* style: update ConvoOptions styles

* refactor(SearchButtons): streamline conversation fetching and remove unused state

* fix: ensure messages are invalidated after fetching conversation data

* fix: add iconURL to conversation query selection

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-04-16 21:07:43 -04:00
Danny Avila
851938e7a6 🔧 fix: Agent Resource Form, Convo Menu Style, Ensure Draft Clears on Submission (#6925)
*  style: Adjust z-index for popover UI and update className in ConvoOptions

*  feat: Add 'spec' field to conversation query selection

* 🛠️ fix: add back conversationId to use Constants.PENDING_CONVO in useSSE hook on submission to allow text drafts to clear

*  chore: add .clineignore to .gitignore for Cline configuration

*  refactor: memoize FileSearchCheckbox component for performance optimization

* fix: agent resource management by adding tool_resource to agent's tools if missing
2025-04-16 18:14:34 -04:00
Peter
6edd93f99e 🗺️ feat: Add Parameter Location Mapping for OpenAPI actions (#6858)
* fix: action parameters are assigned to the correct location (query, parameter, header, body)

* removed copy/paste error

* added unit tests, only add contenttype if specified

---------

Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
2025-04-16 18:11:03 -04:00
Danny Avila
16aa5ed466 🛠️ fix: Improve Accessibility and Display of Conversation Menu (#6913)
* 📦 chore: update @ariakit/react-core to version 0.4.17 in package.json and package-lock.json

* refactor: add additional ariakit menu props and unmount menu if state changes

* fix: accessibility issues and incompatibility issues due to non-portaled menu

* fix: improve visibility and accessibility of conversation options, making sure to expand dynamically when becoming active

* fix: adjust max width for conversation options popover to improve visibility
2025-04-16 04:28:46 -04:00
Marco Beretta
000f3a3733 📢 fix: Invalid engineTTS and Conversation State on Navigation (#6904)
* fix: handle invalid engineTTS values and prevent VoiceDropdown render errors

* refactor: add verbose developer logging for debugging conversation state issues

* refactor: remove unnecessary effect for conversationId changes

* chore: imports

* fix: include model and entity IDs in conversation query selection

* feat: add fetchFreshData function to retrieve conversation data on navigation

* fix: remove unnecessary comment in fetchFreshData function

* chore: reorder imports in useNavigateToConvo for consistency

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-04-15 21:00:06 -04:00
Danny Avila
d32f34e5d7 📝 fix: Mistral OCR Image Support and Azure Agent Titles (#6901)
* fix: azure title model

* refactor: typing for uploadMistralOCR

* fix: update conversation ID handling in useSSE for better state management, only use PENDING_CONVO for new conversations

* fix: streamline conversation ID handling in useSSE for simplicity, only needs state update to prevent draft from applying

* fix: update performOCR and tests to support document and image URLs with appropriate types
2025-04-15 18:03:56 -04:00
Marco Beretta
650e9b4f6c 📜 refactor: Optimize Conversation History Nav with Cursor Pagination (#5785)
*  feat: improve Nav/Conversations/Convo/NewChat component performance

*  feat: implement cursor-based pagination for conversations API

* 🔧 refactor: remove createdAt from conversation selection in API and type definitions

* 🔧 refactor: include createdAt in conversation selection and update related types

*  fix: search functionality and bugs with loadMoreConversations

* feat: move ArchivedChats to cursor and DataTable standard

* 🔧 refactor: add InfiniteQueryObserverResult type import in Nav component

* feat: enhance conversation listing with pagination, sorting, and search capabilities

* 🔧 refactor: remove unnecessary comment regarding lodash/debounce in ArchivedChatsTable

* 🔧 refactor: remove unused translation keys for archived chats and search results

* 🔧 fix: Archived Chats, Delete Convo, Duplicate Convo

* 🔧 refactor: improve conversation components with layout adjustments and new translations

* 🔧 refactor: simplify archive conversation mutation and improve unarchive handling; fix: update fork mutation

* 🔧 refactor: decode search query parameter in conversation route; improve error handling in unarchive mutation; clean up DataTable component styles

* 🔧 refactor: remove unused translation key for empty archived chats

* 🚀 fix: `archivedConversation` query key not updated correctly while archiving

* 🧠 feat: Bedrock Anthropic Reasoning & Update Endpoint Handling (#6163)

* feat: Add thinking and thinkingBudget parameters for Bedrock Anthropic models

* chore: Update @librechat/agents to version 2.1.8

* refactor: change region order in params

* refactor: Add maxTokens parameter to conversation preset schema

* refactor: Update agent client to use bedrockInputSchema and improve error handling for model parameters

* refactor: streamline/optimize llmConfig initialization and saving for bedrock

* fix: ensure config titleModel is used for all endpoints

* refactor: enhance OpenAIClient and agent initialization to support endpoint checks for OpenRouter

* chore: bump @google/generative-ai

*  feat: improve Nav/Conversations/Convo/NewChat component performance

* 🔧 refactor: remove unnecessary comment regarding lodash/debounce in ArchivedChatsTable

* 🔧 refactor: update translation keys for clarity; simplify conversation query parameters and improve sorting functionality in SharedLinks component

* 🔧 refactor: optimize conversation loading logic and improve search handling in Nav component

* fix: package-lock

* fix: package-lock 2

* fix: package lock 3

* refactor: remove unused utility files and exports to clean up the codebase

* refactor: remove i18n and useAuthRedirect modules to streamline codebase

* refactor: optimize Conversations component and remove unused ToggleContext

* refactor(Convo): add RenameForm and ConvoLink components; enhance Conversations component with responsive design

* fix: add missing @azure/storage-blob dependency in package.json

* refactor(Search): add error handling with toast notification for search errors

* refactor: make createdAt and updatedAt fields of tConvoUpdateSchema less restrictive if timestamps are missing

* chore: update @azure/storage-blob dependency to version 12.27.0, ensure package-lock is correct

* refactor(Search): improve conversation handling server side

* fix: eslint warning and errors

* refactor(Search): improved search loading state and overall UX

* Refactors conversation cache management

Centralizes conversation mutation logic into dedicated utility functions for adding, updating, and removing conversations from query caches.

Improves reliability and maintainability by:
- Consolidating duplicate cache manipulation code
- Adding type safety for infinite query data structures
- Implementing consistent cache update patterns across all conversation operations
- Removing obsolete conversation helper functions in favor of standardized utilities

* fix: conversation handling and SSE event processing

- Optimizes conversation state management with useMemo and proper hook ordering
- Improves SSE event handler documentation and error handling
- Adds reset guard flag for conversation changes
- Removes redundant navigation call
- Cleans up cursor handling logic and document structure

Improves code maintainability and prevents potential race conditions in conversation state updates

* refactor: add type for SearchBar `onChange`

* fix: type tags

* style: rounded to xl all Header buttons

* fix: activeConvo in Convo not working

* style(Bookmarks): improved UI

* a11y(AccountSettings): fixed hover style not visible when using light theme

* style(SettingsTabs): improved tab switchers and dropdowns

* feat: add translations keys for Speech

* chore: fix package-lock

* fix(mutations): legacy import after rebase

* feat: refactor conversation navigation for accessibility

* fix(search): convo and message create/update date not returned

* fix(search): show correct iconURL and endpoint for searched messages

* fix: small UI improvements

* chore: console.log cleanup

* chore: fix tests

* fix(ChatForm): improve conversation ID handling and clean up useMemo dependencies

* chore: improve typing

* chore: improve typing

* fix(useSSE): clear conversation ID on submission to prevent draft restoration

* refactor(OpenAIClient): clean up abort handler

* refactor(abortMiddleware): change handleAbort to use function expression

* feat: add PENDING_CONVO constant and update conversation ID checks

* fix: final event handling on abort

* fix: improve title sync and query cache sync on final event

* fix: prevent overwriting cached conversation data if it already exists

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-04-15 04:04:00 -04:00
Marco Beretta
77a21719fd ⌨️ a11y: enhance accessibility & visual consistency (#6866)
* a11y: TemporaryChat label

* style: ChatForm badges update
2025-04-14 22:40:07 -04:00
Marco Beretta
d0332c6e07 style: Dynamic text sizing for greeting and name display (#6833)
*  feat: Implement dynamic text sizing for greeting and name display

* refactor: simplified text-size logic
2025-04-14 22:39:35 -04:00
Marco Beretta
5d56f48879 👋 feat: remove Edge TTS (#6885)
* feat: remove Edge TTS

* remove the remaining edge code

* chore: cleanup

* chore: cleanup package-lock
2025-04-14 22:39:01 -04:00
Danny Avila
c49f883e1a 🔁 refactor: Token Event Handler and Standardize maxTokens Key (#6886)
* refactor: agent token handling to use createHandleLLMNewToken for improved closure

* refactor: update llmConfig to use maxTokens instead of max_tokens for consistency
2025-04-14 22:38:35 -04:00
Danny Avila
52b3ed54ca 🤖 feat: GPT-4.1 (#6880)
* fix: Agent Builder setting not applying in useSideNavLinks

* fix: Remove unused type imports in useSideNavLinks

* feat: gpt-4.1

* fix: Update getCacheMultiplier and getMultiplier tests to use dynamic token values

* feat: Add gpt-4.1 to the list of vision models

* chore: Bump version of librechat-data-provider to 0.7.792
2025-04-14 14:55:59 -04:00
Danny Avila
64bd373bc8 🔧 fix: Keyv and Proxy Issues, and More Memory Optimizations (#6867)
* chore: update @librechat/agents dependency to version 2.4.15

* refactor: Prevent memory leaks by nullifying boundModel.client in disposeClient function

* fix: use of proxy, use undici

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

* Revert "fix: use of proxy, use undici"

This reverts commit 83153cd582.

* fix: ensure fetch is imported for HTTP requests

* fix: replace direct OpenAI import with CustomOpenAIClient from @librechat/agents

* fix: update keyv peer dependency to version 5.3.2

* fix: update keyv dependency to version 5.3.2

* refactor: replace KeyvMongo with custom implementation and update flow state manager usage

* fix: update @librechat/agents dependency to version 2.4.17

* ci: update OpenAIClient tests to use CustomOpenAIClient from @librechat/agents

* refactor: remove KeyvMongo mock and related dependencies
2025-04-13 23:01:55 -04:00
Danny Avila
339882eea4 💾 refactor: Enhance Memory In Image Encodings & Client Disposal (#6852)
* 💾 chore: Clear Additional Properties in `disposeClient`

* refactor: stream handling and base64 conversion in encode.js to better free memory
2025-04-12 20:53:38 -04:00
Danny Avila
37964975c1 🤖 refactor: Improve Agents Memory Usage, Bump Keyv, Grok 3 (#6850)
* chore: remove unused redis file

* chore: bump keyv dependencies, and update related imports

* refactor: Implement IoRedis client for rate limiting across middleware, as node-redis via keyv not compatible

* fix: Set max listeners to expected amount

* WIP: memory improvements

* refactor: Simplify getAbortData assignment in createAbortController

* refactor: Update getAbortData to use WeakRef for content management

* WIP: memory improvements in agent chat requests

* refactor: Enhance memory management with finalization registry and cleanup functions

* refactor: Simplify domainParser calls by removing unnecessary request parameter

* refactor: Update parameter types for action tools and agent loading functions to use minimal configs

* refactor: Simplify domainParser tests by removing unnecessary request parameter

* refactor: Simplify domainParser call by removing unnecessary request parameter

* refactor: Enhance client disposal by nullifying additional properties to improve memory management

* refactor: Improve title generation by adding abort controller and timeout handling, consolidate request cleanup

* refactor: Update checkIdleConnections to skip current user when checking for idle connections if passed

* refactor: Update createMCPTool to derive userId from config and handle abort signals

* refactor: Introduce createTokenCounter function and update tokenCounter usage; enhance disposeClient to reset Graph values

* refactor: Update getMCPManager to accept userId parameter for improved idle connection handling

* refactor: Extract logToolError function for improved error handling in AgentClient

* refactor: Update disposeClient to clear handlerRegistry and graphRunnable references in client.run

* refactor: Extract createHandleNewToken function to streamline token handling in initializeClient

* chore: bump @librechat/agents

* refactor: Improve timeout handling in addTitle function for better error management

* refactor: Introduce createFetch instead of using class method

* refactor: Enhance client disposal and request data handling in AskController and EditController

* refactor: Update import statements for AnthropicClient and OpenAIClient to use specific paths

* refactor: Use WeakRef for response handling in SplitStreamHandler to prevent memory leaks

* refactor: Simplify client disposal and rename getReqData to processReqData in AskController and EditController

* refactor: Improve logging structure and parameter handling in OpenAIClient

* refactor: Remove unused GraphEvents and improve stream event handling in AnthropicClient and OpenAIClient

* refactor: Simplify client initialization in AskController and EditController

* refactor: Remove unused mock functions and implement in-memory store for KeyvMongo

* chore: Update dependencies in package-lock.json to latest versions

* refactor: Await token usage recording in OpenAIClient to ensure proper async handling

* refactor: Remove handleAbort route from multiple endpoints and enhance client disposal logic

* refactor: Enhance abort controller logic by managing abortKey more effectively

* refactor: Add newConversation handling in useEventHandlers for improved conversation management

* fix: dropparams

* refactor: Use optional chaining for safer access to request properties in BaseClient

* refactor: Move client disposal and request data processing logic to cleanup module for better organization

* refactor: Remove aborted request check from addTitle function for cleaner logic

* feat: Add Grok 3 model pricing and update tests for new models

* chore: Remove trace warnings and inspect flags from backend start script used for debugging

* refactor: Replace user identifier handling with userId for consistency across controllers, use UserId in clientRegistry

* refactor: Enhance client disposal logic to prevent memory leaks by clearing additional references

* chore: Update @librechat/agents to version 2.4.14 in package.json and package-lock.json
2025-04-12 18:46:36 -04:00
Danny Avila
1e6b1b9554 🐳 feat: Add Jemalloc and UV to Docker Builds (#6836)
* feat: Add `uv` for extended MCP support in Dockerfiles

* feat: Install jemalloc and set environment variable to use it
2025-04-11 00:42:32 -04:00
Danny Avila
12f4dbb8c5 feat: Self-hosted Artifacts Static Bundler URL (#6827)
* v0.7.791

* feat: configuration via `SANDPACK_STATIC_BUNDLER_URL` env var and update bundlerURL logic in Artifact components

* fix: update minimum length requirement for auth fields from 10 to 1 character
2025-04-10 15:37:23 -04:00
Ruben Talstra
083710d4c9 Merge branch 'main' into refactor/openid-strategy 2025-04-10 19:17:05 +02:00
Ruben Talstra
c9b04ef1b4 🔧 chore: Bump version to 0.7.790 in package.json and package-lock.json 2025-04-10 19:07:34 +02:00
Danny Avila
e16a6190a5 💾 chore: Enhance Local Storage Handling and Update MCP SDK (#6809)
* feat: Update MCP package version and dependencies; refactor ToolContentPart type

* refactor: Change module type to commonjs and update rollup configuration, remove unused dev dependency

* refactor: Change async calls to synchronous for MCP and FlowStateManager retrieval

* chore: Add eslint disable comment for i18next rule in DropdownPopup component

* fix: improve statefulness of mcp servers selected if some were removed since last session

* feat: implement conversation storage cleanup functions and integrate them into mutation success handlers

* feat: enhance storage condition logic in useLocalStorageAlt to prevent unnecessary local storage writes

* refactor: streamline local storage update logic in useLocalStorageAlt
2025-04-09 18:38:48 -04:00
Danny Avila
24c0433dcf 🖥️ feat: Code Interpreter API for Non-Agent Endpoints (#6803)
* fix: Prevent parsing 'undefined' string in useLocalStorage initialization

* feat: first pass, code interpreter badge

* feat: Integrate API key authentication and default checked value in Code Interpreter Badge

* refactor: Rename showMCPServers to showEphemeralBadges and update related components, memoize values in useChatBadges

* refactor: Enhance AttachFileChat to support ephemeral agents in file attachment logic

* fix: Add baseURL configuration option to legacy function call

* refactor: Update dependency array in useDragHelpers to include handleFiles

* refactor: Update isEphemeralAgent function to accept optional endpoint parameter

* refactor: Update file handling to support ephemeral agents in AttachFileMenu and useDragHelpers

* fix: improve compatibility issues with OpenAI usage field handling in createRun function

* refactor: usage field compatibility

* fix: ensure mcp servers are no longer "selected" if mcp servers are now unavailable
2025-04-09 16:11:16 -04:00
Danny Avila
5d668748f9 🗃️ feat: Code Interpreter File Persistence between Sessions (#6790)
* refactor: Enhance FileContainer with customizable button and container styles, onClick button handling, and type override

* refactor: Update file type handling to support partial file objects

* refactor: Extract download handling into a custom hook for improved reusability

* refactor: Replace LogContent with Stdout component and enhance Attachment rendering for added visibility

* feat: Update @librechat/agents to version 2.4.1 for referencing generated files in subsequent code interpreter uses

* feat: Add support for tab-separated values (TSV) in mime type handling and improve error logging for regex patterns

* chore: Update @librechat/agents to version 2.4.11 for better `session_id` instructions when wanting to persist files between executions

* chore: Update @librechat/agents to version 2.4.12 for improved functionality

* fix: Enhance argument parsing in useParseArgs to support JSON input and improve code extraction

* refactor: Update input handling in useAutoSave to require more than one character before saving to local storage
2025-04-08 23:18:50 -04:00
Danny Avila
910c73359b 🔦 feat: MCP Support for Non-Agent Endpoints (#6775)
* wip: mcp select

* refactor: Update useAvailableToolsQuery to support generic data types

* feat: Enhance MCPSelect to dynamically load server options and improve MultiSelect component styling

* WIP: ephemeral agents

* wip: Add null check for MCPSelect and improve MultiSelect focus handling

* feat: Pass conversationId prop to MCPSelect in BadgeRow to optimize badge rendering

* feat: useApplyNewAgentTemplate hook to manage ephemeral agent upon conversation creation

* WIP: eph. agent payload

* refactor(OpenAIClient): streamline message processing by replacing content handling with parseTextParts function

* feat: enhance applyAgentTemplate function to accept source conversation ID for improved template application

* feat(parsers): add skipReasoning parameter to parseTextParts for conditional reasoning handling

* WIP: first pass, ephemeral agent backend processing

* chore: import order

* feat: update loadEphemeralAgent and loadAgent functions to accept model_parameters for enhanced agent configuration

* feat: add showMCPServers prop to BadgeRow for conditional rendering of MCPSelect, fix react rule violation

* feat: enhance MCPSelect with localized placeholder and custom icon, add renderSelectedValues callback

* feat: simplify message processing in AnthropicClient by replacing content handling with parseTextParts function

* feat: implement useLocalStorage hook for managing MCP values and update MCPSelect to utilize it

* chore: remove chatGPTBrowserSchema from endpoint schemas and update types for improved schema management

* chore: remove compactChatGPTSchema from endpoint schemas and update types for better schema management

* refactor: rename schemas for clarity and improve schema management

* feat: extend model detection to include 'codestral' alongside 'mistral'

* feat: add endpointType parameter to buildOptions and initializeClient functions

* fix: update condition for handling completion in BaseClient to include agents client

* refactor: simplify payload parsing logic in AgentClient and remove unused providerParsers

* refactor: change useSetRecoilState to useRecoilState for better state management in MCPSelect component

* refactor: streamline chat route handlers by consolidating middleware and improving endpoint structure

* style: update MCPSelect and MultiSelect components for improved layout in mobile view

* v0.7.790

* feat: add getMessageMapMethod to process message text and content in GoogleClient

* chore: include LAST_MCP_ key prefix in clearLocalStorage function for proper teardown on logout
2025-04-07 19:16:56 -04:00
Marco Beretta
018143b5cc 🗨️ fix: Show ModelSpec Greeting (#6770) 2025-04-07 15:57:49 -04:00
Danny Avila
4afab52fc5 🪺 fix: Update Role Handling due to New Schema Shape (#6774)
* 📝 fix: Update translation for shared agent message in English locale

* 🪺 fix: Migrate role schema to new nested structure and update permissions handling where missed
2025-04-07 14:48:11 -04:00
dependabot[bot]
175cfe8ffb 📦 chore: bump vite from 6.2.3 to 6.2.5 (#6745)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.2.3 to 6.2.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.5/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.2.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-06 15:17:23 -04:00
Danny Avila
9b0678da16 ⚙️ refactor: OAuth Flow Signal, Type Safety, Tool Progress & Updated Packages (#6752)
* chore: bump @librechat/agents and related packages

* refactor: update message state for tool calls run step, in case no tool call chunks are received

* fix: avoid combining finalized args createContentAggregator for tool calls

* chore: bump @librechat/agents to version 2.3.99

* feat: add support for aborting flows with AbortSignal in createFlow methods

* fix: improve handling of tool call arguments in useStepHandler

* chore: bump @librechat/agents to version 2.4.0

* fix: update flow identifier format for OAuth login in createActionTool to allow uniqueness per run

* fix: improve error message handling for aborted flows in FlowStateManager

* refactor: allow possible multi-agent cross-over for oauth login

* fix: add type safety for Sandpack files in ArtifactCodeEditor
2025-04-06 03:28:05 -04:00
Ruben Talstra
81fe64da05 🔧 feat: Enhance role extraction logic in OpenID strategy to support multiple sources and improve error handling 2025-04-05 14:49:25 +02:00
Ruben Talstra
b0ebc265a3 🔧 docs: Add comments for supported algorithms in openidStrategy.js 2025-04-05 14:31:28 +02:00
Ruben Talstra
e5743a0b10 🔧 refactor: Clean up imports in openidStrategy.js for improved readability 2025-04-05 14:16:08 +02:00
Ruben Talstra
ec5c9fef48 🔧 feat: Add support for PKCE in OpenID strategy configuration 2025-04-05 14:14:36 +02:00
Ruben Talstra
f74b9a3018 🔧 test: Add fallback test for userinfo roles with invalid id_token 2025-04-05 14:04:40 +02:00
Ruben Talstra
1083014464 🔧 feat: Enhance OpenID strategy with improved error handling, role extraction, and user creation logic 2025-04-05 14:04:15 +02:00
Ruben Talstra
124533f09f 🔧 test: Update OpenID strategy tests with simulated JWT tokens and improved assertions 2025-04-05 13:36:42 +02:00
Ruben Talstra
c77d13d269 🔧 feat: Enhance OpenID role extraction and validation logic 2025-04-05 13:30:31 +02:00
Ruben Talstra
ac35b8490c 📦 chore: Update caniuse-lite dependency to version 1.0.30001706 (#6482)
* 🔧 chore: Update caniuse-lite dependency to version 1.0.30001706 in package.json and package-lock.json

* 🔧 chore: Remove caniuse-lite dependency from package.json and package-lock.json
2025-04-04 19:54:57 -04:00
Ruben Talstra
0551a562d8 🪺 refactor: Nest Permission fields for Roles (#6487)
* 🏗️ feat: Add Group model and schema with GroupType enum

* 🏗️ feat: Introduce Permissions module and refactor role-based access control

* 🏗️ feat: Refactor permissions handling and consolidate permission schemas

* 🏗️ feat: Refactor role permissions handling and improve role initialization logic

* 🏗️ feat: Update Role.spec.js to improve imports and enhance test structure

* 🏗️ feat: Update access control logic to ensure proper permission checks in role handling

* 🏗️ chore: Bump versions for librechat-data-provider to 0.7.75 and @librechat/data-schemas to 0.0.6

* 🏗️ feat: Improve role permissions handling by ensuring defaults are applied correctly

* 🏗️ feat: Update role permissions schema to comment out unused SHARE permission

* 🏗️ chore: Bump version of librechat-data-provider to 0.7.77 and remove unused groups field from IUser interface

* 🏗️ chore: Downgrade version of librechat-data-provider to 0.7.76

* 🔧 chore: Bump versions for librechat-data-provider to 0.7.77 and data-schemas to 0.0.6

* 🏗️ chore: Update version of librechat-data-provider to 0.7.789

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-04-04 19:47:14 -04:00
Marco Beretta
710fde6a6f 🔄 fix: Improve audio MIME type detection and handling in Speech to Text hook (#6707) 2025-04-04 11:56:47 -04:00
RedwindA
93e679e173 🪙 chore: Update Gemini Pricing (#6731) 2025-04-04 11:55:07 -04:00
Danny Avila
cff392e578 🔧 fix: Agent Capability Checks & DocumentDB Compatibility for Agent Resource Removal (#6726)
* fix: tool capability checks in loadAgentTools function

* fix: enhance atomicity in removing agent resource files and add concurrency tests, improve documentdb compatibility
2025-04-04 10:33:53 -04:00
Danny Avila
953e9732d9 🔧 fix: Chat Middleware, Zod Conversion, Auto-Save and S3 URL Refresh (#6720)
* 🔧 feat: Add configurable S3 URL refresh expiry time

* fix: Set default width and height for URLIcon component in case container style results in NaN

* refactor: Enhance auto-save functionality with debounced restore methods

* feat: Add support for additionalProperties in JSON schema conversion to Zod

* test: Add tests for additionalProperties handling in JSON schema to Zod conversion

* chore: Reorder import statements for better readability in ask route

* fix: Handle additional successful response status code (200) in SSE error handler

* fix: add missing rate limiting middleware for bedrock and agent chat routes

* fix: update moderation middleware to check feature flag before processing requests

* fix: add moderation middleware to chat routes for text moderation

* Revert "refactor: Enhance auto-save functionality with debounced restore methods"

This reverts commit d2e4134d1f.

* refactor: Move base64 encoding/decoding functions to top-level scope and optimize input handling
2025-04-03 20:42:56 -04:00
401 changed files with 19080 additions and 8364 deletions

View File

@@ -20,8 +20,8 @@ DOMAIN_CLIENT=http://localhost:3080
DOMAIN_SERVER=http://localhost:3080
NO_INDEX=true
# Use the address that is at most n number of hops away from the Express application.
# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left.
# Use the address that is at most n number of hops away from the Express application.
# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left.
# A value of 0 means that the first untrusted address would be req.socket.remoteAddress, i.e. there is no reverse proxy.
# Defaulted to 1.
TRUST_PROXY=1
@@ -142,12 +142,12 @@ GOOGLE_KEY=user_provided
# GOOGLE_AUTH_HEADER=true
# Gemini API (AI Studio)
# GOOGLE_MODELS=gemini-2.5-pro-exp-03-25,gemini-2.0-flash-exp,gemini-2.0-flash-thinking-exp-1219,gemini-exp-1121,gemini-exp-1114,gemini-1.5-flash-latest,gemini-1.0-pro,gemini-1.0-pro-001,gemini-1.0-pro-latest,gemini-1.0-pro-vision-latest,gemini-1.5-pro-latest,gemini-pro,gemini-pro-vision
# 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
# Vertex AI
# GOOGLE_MODELS=gemini-1.5-flash-preview-0514,gemini-1.5-pro-preview-0514,gemini-1.0-pro-vision-001,gemini-1.0-pro-002,gemini-1.0-pro-001,gemini-pro-vision,gemini-1.0-pro
# 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_TITLE_MODEL=gemini-pro
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
# GOOGLE_LOC=us-central1
@@ -231,6 +231,14 @@ AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE=
AZURE_AI_SEARCH_SEARCH_OPTION_TOP=
AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
# OpenAI Image Tools Customization
#----------------
# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present
# IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present
# IMAGE_EDIT_OAI_DESCRIPTION=Custom description for image editing tool
# IMAGE_GEN_OAI_PROMPT_DESCRIPTION=Custom prompt description for image generation tool
# IMAGE_EDIT_OAI_PROMPT_DESCRIPTION=Custom prompt description for image editing tool
# DALL·E
#----------------
# DALLE_API_KEY=
@@ -420,9 +428,12 @@ OPENID_CLIENT_ID=
OPENID_CLIENT_SECRET=
OPENID_ISSUER=
OPENID_SESSION_SECRET=
# OPENID_USE_PKCE=
OPENID_SCOPE="openid profile email"
OPENID_CALLBACK_URL=/oauth/openid/callback
OPENID_REQUIRED_ROLE=
# Set to 'userinfo' or 'token' to determine witch role source to use, Default is 'token'
OPENID_REQUIRED_ROLE_SOURCE=
OPENID_REQUIRED_ROLE_TOKEN_KIND=
OPENID_REQUIRED_ROLE_PARAMETER_PATH=
# Set to determine which user info property returned from OpenID Provider to store as the User's username

View File

@@ -24,22 +24,40 @@ Project maintainers have the right and responsibility to remove, edit, or reject
## To contribute to this project, please adhere to the following guidelines:
## 1. Development notes
## 1. Development Setup
1. Before starting work, make sure your main branch has the latest commits with `npm run update`
2. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning.
1. Use Node.JS 20.x.
2. Install typescript globally: `npm i -g typescript`.
3. Run `npm ci` to install dependencies.
4. Build the data provider: `npm run build:data-provider`.
5. Build MCP: `npm run build:mcp`.
6. Build data schemas: `npm run build:data-schemas`.
7. Setup and run unit tests:
- Copy `.env.test`: `cp api/test/.env.test.example api/test/.env.test`.
- Run backend unit tests: `npm run test:api`.
- Run frontend unit tests: `npm run test:client`.
8. Setup and run integration tests:
- Build client: `cd client && npm run build`.
- Create `.env`: `cp .env.example .env`.
- Install [MongoDB Community Edition](https://www.mongodb.com/docs/manual/administration/install-community/), ensure that `mongosh` connects to your local instance.
- Run: `npx install playwright`, then `npx playwright install`.
- Copy `config.local`: `cp e2e/config.local.example.ts e2e/config.local.ts`.
- Copy `librechat.yaml`: `cp librechat.example.yaml librechat.yaml`.
- Run: `npm run e2e`.
## 2. Development Notes
1. Before starting work, make sure your main branch has the latest commits with `npm run update`.
3. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning.
3. After your changes, reinstall packages in your current branch using `npm run reinstall` and ensure everything still works.
- Restart the ESLint server ("ESLint: Restart ESLint Server" in VS Code command bar) and your IDE after reinstalling or updating.
4. Clear web app localStorage and cookies before and after changes.
5. For frontend changes:
- Install typescript globally: `npm i -g typescript`.
- Compile typescript before and after changes to check for introduced errors: `cd client && tsc --noEmit`.
6. Run tests locally:
- Backend unit tests: `npm run test:api`
- Frontend unit tests: `npm run test:client`
- Integration tests: `npm run e2e` (requires playwright installed, `npx install playwright`)
5. For frontend changes, compile typescript before and after changes to check for introduced errors: `cd client && npm run build`.
6. Run backend unit tests: `npm run test:api`.
7. Run frontend unit tests: `npm run test:client`.
8. Run integration tests: `npm run e2e`.
## 2. Git Workflow
## 3. Git Workflow
We utilize a GitFlow workflow to manage changes to this project's codebase. Follow these general steps when contributing code:
@@ -49,7 +67,7 @@ We utilize a GitFlow workflow to manage changes to this project's codebase. Foll
4. Submit a pull request with a clear and concise description of your changes and the reasons behind them.
5. We will review your pull request, provide feedback as needed, and eventually merge the approved changes into the main branch.
## 3. Commit Message Format
## 4. Commit Message Format
We follow the [semantic format](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716) for commit messages.
@@ -76,7 +94,7 @@ feat: add hat wobble
```
## 4. Pull Request Process
## 5. Pull Request Process
When submitting a pull request, please follow these guidelines:
@@ -91,7 +109,7 @@ Ensure that your changes meet the following criteria:
- The commit history is clean and easy to follow. You can use `git rebase` or `git merge --squash` to clean your commit history before submitting the pull request.
- The pull request description clearly outlines the changes and the reasons behind them. Be sure to include the steps to test the pull request.
## 5. Naming Conventions
## 6. Naming Conventions
Apply the following naming conventions to branches, labels, and other Git-related entities:
@@ -100,7 +118,7 @@ Apply the following naming conventions to branches, labels, and other Git-relate
- **JS/TS:** Directories and file names: Descriptive and camelCase. First letter uppercased for React files (e.g., `helperFunction.ts, ReactComponent.tsx`).
- **Docs:** Directories and file names: Descriptive and snake_case (e.g., `config_files.md`).
## 6. TypeScript Conversion
## 7. TypeScript Conversion
1. **Original State**: The project was initially developed entirely in JavaScript (JS).
@@ -126,7 +144,7 @@ Apply the following naming conventions to branches, labels, and other Git-relate
- **Current Stance**: At present, this backend transition is of lower priority and might not be pursued.
## 7. Module Import Conventions
## 8. Module Import Conventions
- `npm` packages first,
- from shortest line (top) to longest (bottom)

View File

@@ -79,6 +79,8 @@ body:
For UI-related issues, browser console logs can be very helpful. You can provide these as screenshots or paste the text here.
render: shell
validations:
required: true
- type: textarea
id: screenshots
attributes:

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
jobs:
generate-release-changelog-pr:
@@ -88,7 +89,7 @@ jobs:
base: main
branch: "changelog/${{ github.ref_name }}"
reviewers: danny-avila
title: "chore: update CHANGELOG for release ${{ github.ref_name }}"
title: "📜 docs: Changelog for release ${{ github.ref_name }}"
body: |
**Description**:
- This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${{ github.ref_name }} above previous releases.
- This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${{ github.ref_name }} above previous releases.

View File

@@ -3,6 +3,7 @@ name: Generate Unreleased Changelog PR
on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at 00:00 UTC
workflow_dispatch:
jobs:
generate-unreleased-changelog-pr:
@@ -98,9 +99,9 @@ jobs:
branch: "changelog/unreleased-update"
sign-commits: true
commit-message: "action: update Unreleased changelog"
title: "action: update Unreleased changelog"
title: "📜 docs: Unreleased Changelog"
body: |
**Description**:
- This PR updates the Unreleased section in CHANGELOG.md.
- It compares the current main branch with the latest version tag (determined as ${{ steps.get_latest_tag.outputs.tag }}),
regenerates the Unreleased changelog, removes any old Unreleased block, and inserts the new content.
regenerates the Unreleased changelog, removes any old Unreleased block, and inserts the new content.

View File

@@ -39,12 +39,35 @@ jobs:
# Check if each key is used in the source code
for KEY in $KEYS; do
FOUND=false
for DIR in "${SOURCE_DIRS[@]}"; do
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
FOUND=true
break
# Special case for dynamically constructed special variable keys
if [[ "$KEY" == com_ui_special_var_* ]]; then
# Check if TSpecialVarLabel is used in the codebase
for DIR in "${SOURCE_DIRS[@]}"; do
if grep -r --include=\*.{js,jsx,ts,tsx} -q "TSpecialVarLabel" "$DIR"; then
FOUND=true
break
fi
done
# Also check if the key is directly used somewhere
if [[ "$FOUND" == false ]]; then
for DIR in "${SOURCE_DIRS[@]}"; do
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
FOUND=true
break
fi
done
fi
done
else
# Regular check for other keys
for DIR in "${SOURCE_DIRS[@]}"; do
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
FOUND=true
break
fi
done
fi
if [[ "$FOUND" == false ]]; then
UNUSED_KEYS+=("$KEY")
@@ -90,4 +113,4 @@ jobs:
- name: Fail workflow if unused keys found
if: env.unused_keys != '[]'
run: exit 1
run: exit 1

3
.gitignore vendored
View File

@@ -52,6 +52,9 @@ bower_components/
*.d.ts
!vite-env.d.ts
# Cline
.clineignore
# Floobits
.floo
.floobit

View File

@@ -2,15 +2,212 @@
All notable changes to this project will be documented in this file.
## [Unreleased]
### ✨ New Features
- 🪄 feat: Agent Artifacts by **@danny-avila** in [#5804](https://github.com/danny-avila/LibreChat/pull/5804)
- feat: implement search parameter updates by **@mawburn** in [#7151](https://github.com/danny-avila/LibreChat/pull/7151)
- 🎏 feat: Add MCP support for Streamable HTTP Transport by **@benverhees** in [#7353](https://github.com/danny-avila/LibreChat/pull/7353)
### 🔧 Fixes
- 💬 fix: update aria-label for accessibility in ConvoLink component by **@berry-13** in [#7320](https://github.com/danny-avila/LibreChat/pull/7320)
- 🔑 fix: use `apiKey` instead of `openAIApiKey` in OpenAI-like Config by **@danny-avila** in [#7337](https://github.com/danny-avila/LibreChat/pull/7337)
- 🔄 fix: update navigation logic in `useFocusChatEffect` to ensure correct search parameters are used by **@mawburn** in [#7340](https://github.com/danny-avila/LibreChat/pull/7340)
### ⚙️ Other Changes
- 🔄 chore: Enforce 18next Language Keys by **@rubentalstra** in [#5803](https://github.com/danny-avila/LibreChat/pull/5803)
- 🔃 refactor: Parent Message ID Handling on Error, Update Translations, Bump Agents by **@danny-avila** in [#5833](https://github.com/danny-avila/LibreChat/pull/5833)
- 📜 docs: CHANGELOG for release v0.7.8 by **@github-actions[bot]** in [#7290](https://github.com/danny-avila/LibreChat/pull/7290)
- 📦 chore: Update API Package Dependencies by **@danny-avila** in [#7359](https://github.com/danny-avila/LibreChat/pull/7359)
---
## [v0.7.8] -
Changes from v0.7.8-rc1 to v0.7.8.
### ✨ New Features
- ✨ feat: Enhance form submission for touch screens by **@berry-13** in [#7198](https://github.com/danny-avila/LibreChat/pull/7198)
- 🔍 feat: Additional Tavily API Tool Parameters by **@glowforge-opensource** in [#7232](https://github.com/danny-avila/LibreChat/pull/7232)
- 🐋 feat: Add python to Dockerfile for increased MCP compatibility by **@technicalpickles** in [#7270](https://github.com/danny-avila/LibreChat/pull/7270)
### 🔧 Fixes
- 🔧 fix: Google Gemma Support & OpenAI Reasoning Instructions by **@danny-avila** in [#7196](https://github.com/danny-avila/LibreChat/pull/7196)
- 🛠️ fix: Conversation Navigation State by **@danny-avila** in [#7210](https://github.com/danny-avila/LibreChat/pull/7210)
- 🔄 fix: o-Series Model Regex for System Messages by **@danny-avila** in [#7245](https://github.com/danny-avila/LibreChat/pull/7245)
- 🔖 fix: Custom Headers for Initial MCP SSE Connection by **@danny-avila** in [#7246](https://github.com/danny-avila/LibreChat/pull/7246)
- 🛡️ fix: Deep Clone `MCPOptions` for User MCP Connections by **@danny-avila** in [#7247](https://github.com/danny-avila/LibreChat/pull/7247)
- 🔄 fix: URL Param Race Condition and File Draft Persistence by **@danny-avila** in [#7257](https://github.com/danny-avila/LibreChat/pull/7257)
- 🔄 fix: Assistants Endpoint & Minor Issues by **@danny-avila** in [#7274](https://github.com/danny-avila/LibreChat/pull/7274)
- 🔄 fix: Ollama Think Tag Edge Case with Tools by **@danny-avila** in [#7275](https://github.com/danny-avila/LibreChat/pull/7275)
### ⚙️ Other Changes
- 📜 docs: CHANGELOG for release v0.7.8-rc1 by **@github-actions[bot]** in [#7153](https://github.com/danny-avila/LibreChat/pull/7153)
- 🔄 refactor: Artifact Visibility Management by **@danny-avila** in [#7181](https://github.com/danny-avila/LibreChat/pull/7181)
- 📦 chore: Bump Package Security by **@danny-avila** in [#7183](https://github.com/danny-avila/LibreChat/pull/7183)
- 🌿 refactor: Unmount Fork Popover on Hide for Better Performance by **@danny-avila** in [#7189](https://github.com/danny-avila/LibreChat/pull/7189)
- 🧰 chore: ESLint configuration to enforce Prettier formatting rules by **@mawburn** in [#7186](https://github.com/danny-avila/LibreChat/pull/7186)
- 🎨 style: Improve KaTeX Rendering for LaTeX Equations by **@andresgit** in [#7223](https://github.com/danny-avila/LibreChat/pull/7223)
- 📝 docs: Update `.env.example` Google models by **@marlonka** in [#7254](https://github.com/danny-avila/LibreChat/pull/7254)
- 💬 refactor: MCP Chat Visibility Option, Google Rates, Remove OpenAPI Plugins by **@danny-avila** in [#7286](https://github.com/danny-avila/LibreChat/pull/7286)
- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7214](https://github.com/danny-avila/LibreChat/pull/7214)
[See full release details][release-v0.7.8]
[release-v0.7.8]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8
---
## [v0.7.8-rc1] -
## [v0.7.8-rc1] -
Changes from v0.7.7 to v0.7.8-rc1.
### ✨ New Features
- 🔍 feat: Mistral OCR API / Upload Files as Text by **@danny-avila** in [#6274](https://github.com/danny-avila/LibreChat/pull/6274)
- 🤖 feat: Support OpenAI Web Search models by **@danny-avila** in [#6313](https://github.com/danny-avila/LibreChat/pull/6313)
- 🔗 feat: Agent Chain (Mixture-of-Agents) by **@danny-avila** in [#6374](https://github.com/danny-avila/LibreChat/pull/6374)
- ⌛ feat: `initTimeout` for Slow Starting MCP Servers by **@perweij** in [#6383](https://github.com/danny-avila/LibreChat/pull/6383)
- 🚀 feat: `S3` Integration for File handling and Image uploads by **@rubentalstra** in [#6142](https://github.com/danny-avila/LibreChat/pull/6142)
- 🔒feat: Enable OpenID Auto-Redirect by **@leondape** in [#6066](https://github.com/danny-avila/LibreChat/pull/6066)
- 🚀 feat: Integrate `Azure Blob Storage` for file handling and image uploads by **@rubentalstra** in [#6153](https://github.com/danny-avila/LibreChat/pull/6153)
- 🚀 feat: Add support for custom `AWS` endpoint in `S3` by **@rubentalstra** in [#6431](https://github.com/danny-avila/LibreChat/pull/6431)
- 🚀 feat: Add support for LDAP STARTTLS in LDAP authentication by **@rubentalstra** in [#6438](https://github.com/danny-avila/LibreChat/pull/6438)
- 🚀 feat: Refactor schema exports and update package version to 0.0.4 by **@rubentalstra** in [#6455](https://github.com/danny-avila/LibreChat/pull/6455)
- 🔼 feat: Add Auto Submit For URL Query Params by **@mjaverto** in [#6440](https://github.com/danny-avila/LibreChat/pull/6440)
- 🛠 feat: Enhance Redis Integration, Rate Limiters & Log Headers by **@danny-avila** in [#6462](https://github.com/danny-avila/LibreChat/pull/6462)
- 💵 feat: Add Automatic Balance Refill by **@rubentalstra** in [#6452](https://github.com/danny-avila/LibreChat/pull/6452)
- 🗣️ feat: add support for gpt-4o-transcribe models by **@berry-13** in [#6483](https://github.com/danny-avila/LibreChat/pull/6483)
- 🎨 feat: UI Refresh for Enhanced UX by **@berry-13** in [#6346](https://github.com/danny-avila/LibreChat/pull/6346)
- 🌍 feat: Add support for Hungarian language localization by **@rubentalstra** in [#6508](https://github.com/danny-avila/LibreChat/pull/6508)
- 🚀 feat: Add Gemini 2.5 Token/Context Values, Increase Max Possible Output to 64k by **@danny-avila** in [#6563](https://github.com/danny-avila/LibreChat/pull/6563)
- 🚀 feat: Enhance MCP Connections For Multi-User Support by **@danny-avila** in [#6610](https://github.com/danny-avila/LibreChat/pull/6610)
- 🚀 feat: Enhance S3 URL Expiry with Refresh; fix: S3 File Deletion by **@danny-avila** in [#6647](https://github.com/danny-avila/LibreChat/pull/6647)
- 🚀 feat: enhance UI components and refactor settings by **@berry-13** in [#6625](https://github.com/danny-avila/LibreChat/pull/6625)
- 💬 feat: move TemporaryChat to the Header by **@berry-13** in [#6646](https://github.com/danny-avila/LibreChat/pull/6646)
- 🚀 feat: Use Model Specs + Specific Endpoints, Limit Providers for Agents by **@danny-avila** in [#6650](https://github.com/danny-avila/LibreChat/pull/6650)
- 🪙 feat: Sync Balance Config on Login by **@danny-avila** in [#6671](https://github.com/danny-avila/LibreChat/pull/6671)
- 🔦 feat: MCP Support for Non-Agent Endpoints by **@danny-avila** in [#6775](https://github.com/danny-avila/LibreChat/pull/6775)
- 🗃️ feat: Code Interpreter File Persistence between Sessions by **@danny-avila** in [#6790](https://github.com/danny-avila/LibreChat/pull/6790)
- 🖥️ feat: Code Interpreter API for Non-Agent Endpoints by **@danny-avila** in [#6803](https://github.com/danny-avila/LibreChat/pull/6803)
- ⚡ feat: Self-hosted Artifacts Static Bundler URL by **@danny-avila** in [#6827](https://github.com/danny-avila/LibreChat/pull/6827)
- 🐳 feat: Add Jemalloc and UV to Docker Builds by **@danny-avila** in [#6836](https://github.com/danny-avila/LibreChat/pull/6836)
- 🤖 feat: GPT-4.1 by **@danny-avila** in [#6880](https://github.com/danny-avila/LibreChat/pull/6880)
- 👋 feat: remove Edge TTS by **@berry-13** in [#6885](https://github.com/danny-avila/LibreChat/pull/6885)
- feat: nav optimization by **@berry-13** in [#5785](https://github.com/danny-avila/LibreChat/pull/5785)
- 🗺️ feat: Add Parameter Location Mapping for OpenAPI actions by **@peeeteeer** in [#6858](https://github.com/danny-avila/LibreChat/pull/6858)
- 🤖 feat: Support `o4-mini` and `o3` Models by **@danny-avila** in [#6928](https://github.com/danny-avila/LibreChat/pull/6928)
- 🎨 feat: OpenAI Image Tools (GPT-Image-1) by **@danny-avila** in [#7079](https://github.com/danny-avila/LibreChat/pull/7079)
- 🗓️ feat: Add Special Variables for Prompts & Agents, Prompt UI Improvements by **@danny-avila** in [#7123](https://github.com/danny-avila/LibreChat/pull/7123)
### 🌍 Internationalization
- 🌍 i18n: Add Thai Language Support and Update Translations by **@rubentalstra** in [#6219](https://github.com/danny-avila/LibreChat/pull/6219)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6220](https://github.com/danny-avila/LibreChat/pull/6220)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6240](https://github.com/danny-avila/LibreChat/pull/6240)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6241](https://github.com/danny-avila/LibreChat/pull/6241)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6277](https://github.com/danny-avila/LibreChat/pull/6277)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6414](https://github.com/danny-avila/LibreChat/pull/6414)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6505](https://github.com/danny-avila/LibreChat/pull/6505)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6530](https://github.com/danny-avila/LibreChat/pull/6530)
- 🌍 i18n: Add Persian Localization Support by **@rubentalstra** in [#6669](https://github.com/danny-avila/LibreChat/pull/6669)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6667](https://github.com/danny-avila/LibreChat/pull/6667)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7126](https://github.com/danny-avila/LibreChat/pull/7126)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7148](https://github.com/danny-avila/LibreChat/pull/7148)
### 👐 Accessibility
- 🎨 a11y: Update Model Spec Description Text by **@berry-13** in [#6294](https://github.com/danny-avila/LibreChat/pull/6294)
- 🗑️ a11y: Add Accessible Name to Button for File Attachment Removal by **@kangabell** in [#6709](https://github.com/danny-avila/LibreChat/pull/6709)
- ⌨️ a11y: enhance accessibility & visual consistency by **@berry-13** in [#6866](https://github.com/danny-avila/LibreChat/pull/6866)
- 🙌 a11y: Searchbar/Conversations List Focus by **@danny-avila** in [#7096](https://github.com/danny-avila/LibreChat/pull/7096)
- 👐 a11y: Improve Fork and SplitText Accessibility by **@danny-avila** in [#7147](https://github.com/danny-avila/LibreChat/pull/7147)
### 🔧 Fixes
- 🐛 fix: Avatar Type Definitions in Agent/Assistant Schemas by **@danny-avila** in [#6235](https://github.com/danny-avila/LibreChat/pull/6235)
- 🔧 fix: MeiliSearch Field Error and Patch Incorrect Import by #6210 by **@rubentalstra** in [#6245](https://github.com/danny-avila/LibreChat/pull/6245)
- 🔏 fix: Enhance Two-Factor Authentication by **@rubentalstra** in [#6247](https://github.com/danny-avila/LibreChat/pull/6247)
- 🐛 fix: Await saveMessage in abortMiddleware to ensure proper execution by **@sh4shii** in [#6248](https://github.com/danny-avila/LibreChat/pull/6248)
- 🔧 fix: Axios Proxy Usage And Bump `mongoose` by **@danny-avila** in [#6298](https://github.com/danny-avila/LibreChat/pull/6298)
- 🔧 fix: comment out MCP servers to resolve service run issues by **@KunalScriptz** in [#6316](https://github.com/danny-avila/LibreChat/pull/6316)
- 🔧 fix: Update Token Calculations and Mapping, MCP `env` Initialization by **@danny-avila** in [#6406](https://github.com/danny-avila/LibreChat/pull/6406)
- 🐞 fix: Agent "Resend" Message Attachments + Source Icon Styling by **@danny-avila** in [#6408](https://github.com/danny-avila/LibreChat/pull/6408)
- 🐛 fix: Prevent Crash on Duplicate Message ID by **@Odrec** in [#6392](https://github.com/danny-avila/LibreChat/pull/6392)
- 🔐 fix: Invalid Key Length in 2FA Encryption by **@rubentalstra** in [#6432](https://github.com/danny-avila/LibreChat/pull/6432)
- 🏗️ fix: Fix Agents Token Spend Race Conditions, Expand Test Coverage by **@danny-avila** in [#6480](https://github.com/danny-avila/LibreChat/pull/6480)
- 🔃 fix: Draft Clearing, Claude Titles, Remove Default Vision Max Tokens by **@danny-avila** in [#6501](https://github.com/danny-avila/LibreChat/pull/6501)
- 🔧 fix: Update username reference to use user.name in greeting display by **@rubentalstra** in [#6534](https://github.com/danny-avila/LibreChat/pull/6534)
- 🔧 fix: S3 Download Stream with Key Extraction and Blob Storage Encoding for Vision by **@danny-avila** in [#6557](https://github.com/danny-avila/LibreChat/pull/6557)
- 🔧 fix: Mistral type strictness for `usage` & update token values/windows by **@danny-avila** in [#6562](https://github.com/danny-avila/LibreChat/pull/6562)
- 🔧 fix: Consolidate Text Parsing and TTS Edge Initialization by **@danny-avila** in [#6582](https://github.com/danny-avila/LibreChat/pull/6582)
- 🔧 fix: Ensure continuation in image processing on base64 encoding from Blob Storage by **@danny-avila** in [#6619](https://github.com/danny-avila/LibreChat/pull/6619)
- ✉️ fix: Fallback For User Name In Email Templates by **@danny-avila** in [#6620](https://github.com/danny-avila/LibreChat/pull/6620)
- 🔧 fix: Azure Blob Integration and File Source References by **@rubentalstra** in [#6575](https://github.com/danny-avila/LibreChat/pull/6575)
- 🐛 fix: Safeguard against undefined addedEndpoints by **@wipash** in [#6654](https://github.com/danny-avila/LibreChat/pull/6654)
- 🤖 fix: Gemini 2.5 Vision Support by **@danny-avila** in [#6663](https://github.com/danny-avila/LibreChat/pull/6663)
- 🔄 fix: Avatar & Error Handling Enhancements by **@danny-avila** in [#6687](https://github.com/danny-avila/LibreChat/pull/6687)
- 🔧 fix: Chat Middleware, Zod Conversion, Auto-Save and S3 URL Refresh by **@danny-avila** in [#6720](https://github.com/danny-avila/LibreChat/pull/6720)
- 🔧 fix: Agent Capability Checks & DocumentDB Compatibility for Agent Resource Removal by **@danny-avila** in [#6726](https://github.com/danny-avila/LibreChat/pull/6726)
- 🔄 fix: Improve audio MIME type detection and handling by **@berry-13** in [#6707](https://github.com/danny-avila/LibreChat/pull/6707)
- 🪺 fix: Update Role Handling due to New Schema Shape by **@danny-avila** in [#6774](https://github.com/danny-avila/LibreChat/pull/6774)
- 🗨️ fix: Show ModelSpec Greeting by **@berry-13** in [#6770](https://github.com/danny-avila/LibreChat/pull/6770)
- 🔧 fix: Keyv and Proxy Issues, and More Memory Optimizations by **@danny-avila** in [#6867](https://github.com/danny-avila/LibreChat/pull/6867)
- ✨ fix: Implement dynamic text sizing for greeting and name display by **@berry-13** in [#6833](https://github.com/danny-avila/LibreChat/pull/6833)
- 📝 fix: Mistral OCR Image Support and Azure Agent Titles by **@danny-avila** in [#6901](https://github.com/danny-avila/LibreChat/pull/6901)
- 📢 fix: Invalid `engineTTS` and Conversation State on Navigation by **@berry-13** in [#6904](https://github.com/danny-avila/LibreChat/pull/6904)
- 🛠️ fix: Improve Accessibility and Display of Conversation Menu by **@danny-avila** in [#6913](https://github.com/danny-avila/LibreChat/pull/6913)
- 🔧 fix: Agent Resource Form, Convo Menu Style, Ensure Draft Clears on Submission by **@danny-avila** in [#6925](https://github.com/danny-avila/LibreChat/pull/6925)
- 🔀 fix: MCP Improvements, Auto-Save Drafts, Artifact Markup by **@danny-avila** in [#7040](https://github.com/danny-avila/LibreChat/pull/7040)
- 🐋 fix: Improve Deepseek Compatbility by **@danny-avila** in [#7132](https://github.com/danny-avila/LibreChat/pull/7132)
- 🐙 fix: Add Redis Ping Interval to Prevent Connection Drops by **@peeeteeer** in [#7127](https://github.com/danny-avila/LibreChat/pull/7127)
### ⚙️ Other Changes
- 📦 refactor: Move DB Models to `@librechat/data-schemas` by **@rubentalstra** in [#6210](https://github.com/danny-avila/LibreChat/pull/6210)
- 📦 chore: Patch `axios` to address CVE-2025-27152 by **@danny-avila** in [#6222](https://github.com/danny-avila/LibreChat/pull/6222)
- ⚠️ refactor: Use Error Content Part Instead Of Throwing Error for Agents by **@danny-avila** in [#6262](https://github.com/danny-avila/LibreChat/pull/6262)
- 🏃‍♂️ refactor: Improve Agent Run Context & Misc. Changes by **@danny-avila** in [#6448](https://github.com/danny-avila/LibreChat/pull/6448)
- 📝 docs: librechat.example.yaml by **@ineiti** in [#6442](https://github.com/danny-avila/LibreChat/pull/6442)
- 🏃‍♂️ refactor: More Agent Context Improvements during Run by **@danny-avila** in [#6477](https://github.com/danny-avila/LibreChat/pull/6477)
- 🔃 refactor: Allow streaming for `o1` models by **@danny-avila** in [#6509](https://github.com/danny-avila/LibreChat/pull/6509)
- 🔧 chore: `Vite` Plugin Upgrades & Config Optimizations by **@rubentalstra** in [#6547](https://github.com/danny-avila/LibreChat/pull/6547)
- 🔧 refactor: Consolidate Logging, Model Selection & Actions Optimizations, Minor Fixes by **@danny-avila** in [#6553](https://github.com/danny-avila/LibreChat/pull/6553)
- 🎨 style: Address Minor UI Refresh Issues by **@berry-13** in [#6552](https://github.com/danny-avila/LibreChat/pull/6552)
- 🔧 refactor: Enhance Model & Endpoint Configurations with Global Indicators 🌍 by **@berry-13** in [#6578](https://github.com/danny-avila/LibreChat/pull/6578)
- 💬 style: Chat UI, Greeting, and Message adjustments by **@berry-13** in [#6612](https://github.com/danny-avila/LibreChat/pull/6612)
- ⚡ refactor: DocumentDB Compatibility for Balance Updates by **@danny-avila** in [#6673](https://github.com/danny-avila/LibreChat/pull/6673)
- 🧹 chore: Update ESLint rules for React hooks by **@rubentalstra** in [#6685](https://github.com/danny-avila/LibreChat/pull/6685)
- 🪙 chore: Update Gemini Pricing by **@RedwindA** in [#6731](https://github.com/danny-avila/LibreChat/pull/6731)
- 🪺 refactor: Nest Permission fields for Roles by **@rubentalstra** in [#6487](https://github.com/danny-avila/LibreChat/pull/6487)
- 📦 chore: Update `caniuse-lite` dependency to version 1.0.30001706 by **@rubentalstra** in [#6482](https://github.com/danny-avila/LibreChat/pull/6482)
- ⚙️ refactor: OAuth Flow Signal, Type Safety, Tool Progress & Updated Packages by **@danny-avila** in [#6752](https://github.com/danny-avila/LibreChat/pull/6752)
- 📦 chore: bump vite from 6.2.3 to 6.2.5 by **@dependabot[bot]** in [#6745](https://github.com/danny-avila/LibreChat/pull/6745)
- 💾 chore: Enhance Local Storage Handling and Update MCP SDK by **@danny-avila** in [#6809](https://github.com/danny-avila/LibreChat/pull/6809)
- 🤖 refactor: Improve Agents Memory Usage, Bump Keyv, Grok 3 by **@danny-avila** in [#6850](https://github.com/danny-avila/LibreChat/pull/6850)
- 💾 refactor: Enhance Memory In Image Encodings & Client Disposal by **@danny-avila** in [#6852](https://github.com/danny-avila/LibreChat/pull/6852)
- 🔁 refactor: Token Event Handler and Standardize `maxTokens` Key by **@danny-avila** in [#6886](https://github.com/danny-avila/LibreChat/pull/6886)
- 🔍 refactor: Search & Message Retrieval by **@berry-13** in [#6903](https://github.com/danny-avila/LibreChat/pull/6903)
- 🎨 style: standardize dropdown styling & fix z-Index layering by **@berry-13** in [#6939](https://github.com/danny-avila/LibreChat/pull/6939)
- 📙 docs: CONTRIBUTING.md by **@dblock** in [#6831](https://github.com/danny-avila/LibreChat/pull/6831)
- 🧭 refactor: Modernize Nav/Header by **@danny-avila** in [#7094](https://github.com/danny-avila/LibreChat/pull/7094)
- 🪶 refactor: Chat Input Focus for Conversation Navigations & ChatForm Optimizations by **@danny-avila** in [#7100](https://github.com/danny-avila/LibreChat/pull/7100)
- 🔃 refactor: Streamline Navigation, Message Loading UX by **@danny-avila** in [#7118](https://github.com/danny-avila/LibreChat/pull/7118)
- 📜 docs: Unreleased changelog by **@github-actions[bot]** in [#6265](https://github.com/danny-avila/LibreChat/pull/6265)
[See full release details][release-v0.7.8-rc1]
[release-v0.7.8-rc1]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8-rc1
---

View File

@@ -1,9 +1,18 @@
# v0.7.7
# v0.7.8
# Base node image
FROM node:20-alpine AS node
RUN apk --no-cache add curl
# Install jemalloc
RUN apk add --no-cache jemalloc
RUN apk add --no-cache python3 py3-pip uv
# Set environment variable to use jemalloc
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
# Add `uv` for extended MCP support
COPY --from=ghcr.io/astral-sh/uv:0.6.13 /uv /uvx /bin/
RUN uv --version
RUN mkdir -p /app && chown node:node /app
WORKDIR /app
@@ -38,4 +47,4 @@ CMD ["npm", "run", "backend"]
# WORKDIR /usr/share/nginx/html
# COPY --from=node /app/client/dist /usr/share/nginx/html
# COPY client/nginx.conf /etc/nginx/conf.d/default.conf
# ENTRYPOINT ["nginx", "-g", "daemon off;"]
# ENTRYPOINT ["nginx", "-g", "daemon off;"]

View File

@@ -1,8 +1,12 @@
# Dockerfile.multi
# v0.7.7
# v0.7.8
# Base for all builds
FROM node:20-alpine AS base-min
# Install jemalloc
RUN apk add --no-cache jemalloc
# Set environment variable to use jemalloc
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
WORKDIR /app
RUN apk --no-cache add curl
RUN npm config set fetch-retry-maxtimeout 600000 && \
@@ -50,6 +54,9 @@ RUN npm run build
# API setup (including client dist)
FROM base-min AS api-build
# Add `uv` for extended MCP support
COPY --from=ghcr.io/astral-sh/uv:0.6.13 /uv /uvx /bin/
RUN uv --version
WORKDIR /app
# Install only production deps
RUN npm ci --omit=dev

View File

@@ -74,6 +74,11 @@
- 🪄 **Generative UI with Code Artifacts**:
- [Code Artifacts](https://youtu.be/GfTj7O4gmd0?si=WJbdnemZpJzBrJo3) allow creation of React, HTML, and Mermaid diagrams directly in chat
- 🎨 **Image Generation & Editing**
- Text-to-image and image-to-image with [GPT-Image-1](https://www.librechat.ai/docs/features/image_gen#1--openai-image-tools-recommended)
- Text-to-image with [DALL-E (3/2)](https://www.librechat.ai/docs/features/image_gen#2--dalle-legacy), [Stable Diffusion](https://www.librechat.ai/docs/features/image_gen#3--stable-diffusion-local), [Flux](https://www.librechat.ai/docs/features/image_gen#4--flux), or any [MCP server](https://www.librechat.ai/docs/features/image_gen#5--model-context-protocol-mcp)
- Produce stunning visuals from prompts or refine existing images with a single instruction
- 💾 **Presets & Context Management**:
- Create, Save, & Share Custom Presets
- Switch between AI Endpoints and Presets mid-chat

View File

@@ -4,11 +4,12 @@ const {
Constants,
ErrorTypes,
EModelEndpoint,
parseTextParts,
anthropicSettings,
getResponseSender,
validateVisionModel,
} = require('librechat-data-provider');
const { SplitStreamHandler: _Handler, GraphEvents } = require('@librechat/agents');
const { SplitStreamHandler: _Handler } = require('@librechat/agents');
const {
truncateText,
formatMessage,
@@ -25,10 +26,11 @@ const {
const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { createFetch, createStreamEventHandlers } = require('./generators');
const Tokenizer = require('~/server/services/Tokenizer');
const { logger, sendEvent } = require('~/config');
const { sleep } = require('~/server/utils');
const BaseClient = require('./BaseClient');
const { logger } = require('~/config');
const HUMAN_PROMPT = '\n\nHuman:';
const AI_PROMPT = '\n\nAssistant:';
@@ -183,7 +185,10 @@ class AnthropicClient extends BaseClient {
getClient(requestOptions) {
/** @type {Anthropic.ClientOptions} */
const options = {
fetch: this.fetch,
fetch: createFetch({
directEndpoint: this.options.directEndpoint,
reverseProxyUrl: this.options.reverseProxyUrl,
}),
apiKey: this.apiKey,
};
@@ -391,13 +396,13 @@ class AnthropicClient extends BaseClient {
const formattedMessages = orderedMessages.map((message, i) => {
const formattedMessage = this.useMessages
? formatMessage({
message,
endpoint: EModelEndpoint.anthropic,
})
message,
endpoint: EModelEndpoint.anthropic,
})
: {
author: message.isCreatedByUser ? this.userLabel : this.assistantLabel,
content: message?.content ?? message.text,
};
author: message.isCreatedByUser ? this.userLabel : this.assistantLabel,
content: message?.content ?? message.text,
};
const needsTokenCount = this.contextStrategy && !orderedMessages[i].tokenCount;
/* If tokens were never counted, or, is a Vision request and the message has files, count again */
@@ -413,6 +418,9 @@ class AnthropicClient extends BaseClient {
this.contextHandlers?.processFile(file);
continue;
}
if (file.metadata?.fileIdentifier) {
continue;
}
orderedMessages[i].tokenCount += this.calculateImageTokenCost({
width: file.width,
@@ -672,7 +680,7 @@ class AnthropicClient extends BaseClient {
}
getCompletion() {
logger.debug('AnthropicClient doesn\'t use getCompletion (all handled in sendCompletion)');
logger.debug("AnthropicClient doesn't use getCompletion (all handled in sendCompletion)");
}
/**
@@ -696,15 +704,8 @@ class AnthropicClient extends BaseClient {
if (msg.text != null && msg.text && msg.text.startsWith(':::thinking')) {
msg.text = msg.text.replace(/:::thinking.*?:::/gs, '').trim();
} else if (msg.content != null) {
/** @type {import('@librechat/agents').MessageContentComplex} */
const newContent = [];
for (let part of msg.content) {
if (part.think != null) {
continue;
}
newContent.push(part);
}
msg.content = newContent;
msg.text = parseTextParts(msg.content, true);
delete msg.content;
}
return msg;
@@ -801,14 +802,11 @@ class AnthropicClient extends BaseClient {
}
logger.debug('[AnthropicClient]', { ...requestOptions });
const handlers = createStreamEventHandlers(this.options.res);
this.streamHandler = new SplitStreamHandler({
accumulate: true,
runId: this.responseMessageId,
handlers: {
[GraphEvents.ON_RUN_STEP]: (event) => sendEvent(this.options.res, event),
[GraphEvents.ON_MESSAGE_DELTA]: (event) => sendEvent(this.options.res, event),
[GraphEvents.ON_REASONING_DELTA]: (event) => sendEvent(this.options.res, event),
},
handlers,
});
let intermediateReply = this.streamHandler.tokens;
@@ -890,7 +888,7 @@ class AnthropicClient extends BaseClient {
}
getBuildMessagesOptions() {
logger.debug('AnthropicClient doesn\'t use getBuildMessagesOptions');
logger.debug("AnthropicClient doesn't use getBuildMessagesOptions");
}
getEncoding() {

View File

@@ -28,15 +28,10 @@ class BaseClient {
month: 'long',
day: 'numeric',
});
this.fetch = this.fetch.bind(this);
/** @type {boolean} */
this.skipSaveConvo = false;
/** @type {boolean} */
this.skipSaveUserMessage = false;
/** @type {ClientDatabaseSavePromise} */
this.userMessagePromise;
/** @type {ClientDatabaseSavePromise} */
this.responsePromise;
/** @type {string} */
this.user;
/** @type {string} */
@@ -68,15 +63,15 @@ class BaseClient {
}
setOptions() {
throw new Error('Method \'setOptions\' must be implemented.');
throw new Error("Method 'setOptions' must be implemented.");
}
async getCompletion() {
throw new Error('Method \'getCompletion\' must be implemented.');
throw new Error("Method 'getCompletion' must be implemented.");
}
async sendCompletion() {
throw new Error('Method \'sendCompletion\' must be implemented.');
throw new Error("Method 'sendCompletion' must be implemented.");
}
getSaveOptions() {
@@ -242,11 +237,11 @@ class BaseClient {
const userMessage = opts.isEdited
? this.currentMessages[this.currentMessages.length - 2]
: this.createUserMessage({
messageId: userMessageId,
parentMessageId,
conversationId,
text: message,
});
messageId: userMessageId,
parentMessageId,
conversationId,
text: message,
});
if (typeof opts?.getReqData === 'function') {
opts.getReqData({
@@ -564,6 +559,8 @@ class BaseClient {
}
async sendMessage(message, opts = {}) {
/** @type {Promise<TMessage>} */
let userMessagePromise;
const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } =
await this.handleStartMethods(message, opts);
@@ -625,11 +622,11 @@ class BaseClient {
}
if (!isEdited && !this.skipSaveUserMessage) {
this.userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
this.savedMessageIds.add(userMessage.messageId);
if (typeof opts?.getReqData === 'function') {
opts.getReqData({
userMessagePromise: this.userMessagePromise,
userMessagePromise,
});
}
}
@@ -655,7 +652,9 @@ class BaseClient {
/** @type {string|string[]|undefined} */
const completion = await this.sendCompletion(payload, opts);
this.abortController.requestCompleted = true;
if (this.abortController) {
this.abortController.requestCompleted = true;
}
/** @type {TMessage} */
const responseMessage = {
@@ -676,7 +675,8 @@ class BaseClient {
responseMessage.text = addSpaceIfNeeded(generation) + completion;
} else if (
Array.isArray(completion) &&
isParamEndpoint(this.options.endpoint, this.options.endpointType)
(this.clientName === EModelEndpoint.agents ||
isParamEndpoint(this.options.endpoint, this.options.endpointType))
) {
responseMessage.text = '';
responseMessage.content = completion;
@@ -702,7 +702,13 @@ class BaseClient {
if (usage != null && Number(usage[this.outputTokensKey]) > 0) {
responseMessage.tokenCount = usage[this.outputTokensKey];
completionTokens = responseMessage.tokenCount;
await this.updateUserMessageTokenCount({ usage, tokenCountMap, userMessage, opts });
await this.updateUserMessageTokenCount({
usage,
tokenCountMap,
userMessage,
userMessagePromise,
opts,
});
} else {
responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
completionTokens = responseMessage.tokenCount;
@@ -711,8 +717,8 @@ class BaseClient {
await this.recordTokenUsage({ promptTokens, completionTokens, usage });
}
if (this.userMessagePromise) {
await this.userMessagePromise;
if (userMessagePromise) {
await userMessagePromise;
}
if (this.artifactPromises) {
@@ -727,7 +733,11 @@ class BaseClient {
}
}
this.responsePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
responseMessage.databasePromise = this.saveMessageToDatabase(
responseMessage,
saveOptions,
user,
);
this.savedMessageIds.add(responseMessage.messageId);
delete responseMessage.tokenCount;
return responseMessage;
@@ -748,9 +758,16 @@ class BaseClient {
* @param {StreamUsage} params.usage
* @param {Record<string, number>} params.tokenCountMap
* @param {TMessage} params.userMessage
* @param {Promise<TMessage>} params.userMessagePromise
* @param {object} params.opts
*/
async updateUserMessageTokenCount({ usage, tokenCountMap, userMessage, opts }) {
async updateUserMessageTokenCount({
usage,
tokenCountMap,
userMessage,
userMessagePromise,
opts,
}) {
/** @type {boolean} */
const shouldUpdateCount =
this.calculateCurrentTokenCount != null &&
@@ -786,7 +803,7 @@ 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
*/
await this.userMessagePromise;
await userMessagePromise;
await this.updateMessageInDatabase({
messageId: userMessage.messageId,
tokenCount: userMessageTokenCount,
@@ -852,7 +869,7 @@ class BaseClient {
}
const savedMessage = await saveMessage(
this.options.req,
this.options?.req,
{
...message,
endpoint: this.options.endpoint,
@@ -876,7 +893,7 @@ class BaseClient {
const existingConvo =
this.fetchedConvo === true
? null
: await getConvo(this.options.req?.user?.id, message.conversationId);
: await getConvo(this.options?.req?.user?.id, message.conversationId);
const unsetFields = {};
const exceptions = new Set(['spec', 'iconURL']);
@@ -896,7 +913,7 @@ class BaseClient {
}
}
const conversation = await saveConvo(this.options.req, fieldsToKeep, {
const conversation = await saveConvo(this.options?.req, fieldsToKeep, {
context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo',
unsetFields,
});

View File

@@ -1,4 +1,4 @@
const Keyv = require('keyv');
const { Keyv } = require('keyv');
const crypto = require('crypto');
const { CohereClient } = require('cohere-ai');
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source');
@@ -339,7 +339,7 @@ class ChatGPTClient extends BaseClient {
opts.body = JSON.stringify(modelOptions);
if (modelOptions.stream) {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
try {
let done = false;

View File

@@ -9,6 +9,7 @@ const {
validateVisionModel,
getResponseSender,
endpointSettings,
parseTextParts,
EModelEndpoint,
ContentTypes,
VisionModes,
@@ -139,8 +140,7 @@ class GoogleClient extends BaseClient {
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
/** @type {boolean} Whether using a "GenerativeAI" Model */
this.isGenerativeModel =
this.modelOptions.model.includes('gemini') || this.modelOptions.model.includes('learnlm');
this.isGenerativeModel = /gemini|learnlm|gemma/.test(this.modelOptions.model);
this.maxContextTokens =
this.options.maxContextTokens ??
@@ -317,6 +317,9 @@ class GoogleClient extends BaseClient {
this.contextHandlers?.processFile(file);
continue;
}
if (file.metadata?.fileIdentifier) {
continue;
}
}
this.augmentedPrompt = await this.contextHandlers.createContext();
@@ -774,6 +777,22 @@ class GoogleClient extends BaseClient {
return this.usage;
}
getMessageMapMethod() {
/**
* @param {TMessage} msg
*/
return (msg) => {
if (msg.text != null && msg.text && msg.text.startsWith(':::thinking')) {
msg.text = msg.text.replace(/:::thinking.*?:::/gs, '').trim();
} else if (msg.content != null) {
msg.text = parseTextParts(msg.content, true);
delete msg.content;
}
return msg;
};
}
/**
* Calculates the correct token count for the current user message based on the token count map and API usage.
* Edge case: If the calculation results in a negative value, it returns the original estimate.

View File

@@ -1,11 +1,11 @@
const OpenAI = require('openai');
const { OllamaClient } = require('./OllamaClient');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { SplitStreamHandler, GraphEvents } = require('@librechat/agents');
const { SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents');
const {
Constants,
ImageDetail,
ContentTypes,
parseTextParts,
EModelEndpoint,
resolveHeaders,
KnownEndpoints,
@@ -31,17 +31,18 @@ const {
createContextHandlers,
} = require('./prompts');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { createFetch, createStreamEventHandlers } = require('./generators');
const { addSpaceIfNeeded, isEnabled, sleep } = require('~/server/utils');
const Tokenizer = require('~/server/services/Tokenizer');
const { spendTokens } = require('~/models/spendTokens');
const { handleOpenAIErrors } = require('./tools/util');
const { createLLM, RunManager } = require('./llm');
const { logger, sendEvent } = require('~/config');
const ChatGPTClient = require('./ChatGPTClient');
const { summaryBuffer } = require('./memory');
const { runTitleChain } = require('./chains');
const { tokenSplit } = require('./document');
const BaseClient = require('./BaseClient');
const { logger } = require('~/config');
class OpenAIClient extends BaseClient {
constructor(apiKey, options = {}) {
@@ -107,7 +108,7 @@ class OpenAIClient extends BaseClient {
this.checkVisionRequest(this.options.attachments);
}
const omniPattern = /\b(o1|o3)\b/i;
const omniPattern = /\b(o\d)\b/i;
this.isOmni = omniPattern.test(this.modelOptions.model);
const { OPENAI_FORCE_PROMPT } = process.env ?? {};
@@ -454,6 +455,9 @@ class OpenAIClient extends BaseClient {
this.contextHandlers?.processFile(file);
continue;
}
if (file.metadata?.fileIdentifier) {
continue;
}
orderedMessages[i].tokenCount += this.calculateImageTokenCost({
width: file.width,
@@ -471,7 +475,9 @@ class OpenAIClient extends BaseClient {
promptPrefix = this.augmentedPrompt + promptPrefix;
}
if (promptPrefix && this.isOmni !== true) {
const noSystemModelRegex = /\b(o1-preview|o1-mini)\b/i.test(this.modelOptions.model);
if (promptPrefix && !noSystemModelRegex) {
promptPrefix = `Instructions:\n${promptPrefix.trim()}`;
instructions = {
role: 'system',
@@ -499,7 +505,7 @@ class OpenAIClient extends BaseClient {
};
/** EXPERIMENTAL */
if (promptPrefix && this.isOmni === true) {
if (promptPrefix && noSystemModelRegex) {
const lastUserMessageIndex = payload.findLastIndex((message) => message.role === 'user');
if (lastUserMessageIndex !== -1) {
if (Array.isArray(payload[lastUserMessageIndex].content)) {
@@ -608,7 +614,7 @@ class OpenAIClient extends BaseClient {
return result.trim();
}
logger.debug('[OpenAIClient] sendCompletion: result', result);
logger.debug('[OpenAIClient] sendCompletion: result', { ...result });
if (this.isChatCompletion) {
reply = result.choices[0].message.content;
@@ -817,7 +823,7 @@ ${convo}
const completionTokens = this.getTokenCount(title);
this.recordTokenUsage({ promptTokens, completionTokens, context: 'title' });
await this.recordTokenUsage({ promptTokens, completionTokens, context: 'title' });
} catch (e) {
logger.error(
'[OpenAIClient] There was an issue generating the title with the completion method',
@@ -1121,15 +1127,8 @@ ${convo}
if (msg.text != null && msg.text && msg.text.startsWith(':::thinking')) {
msg.text = msg.text.replace(/:::thinking.*?:::/gs, '').trim();
} else if (msg.content != null) {
/** @type {import('@librechat/agents').MessageContentComplex} */
const newContent = [];
for (let part of msg.content) {
if (part.think != null) {
continue;
}
newContent.push(part);
}
msg.content = newContent;
msg.text = parseTextParts(msg.content, true);
delete msg.content;
}
return msg;
@@ -1230,9 +1229,9 @@ ${convo}
opts.baseURL = this.langchainProxy
? constructAzureURL({
baseURL: this.langchainProxy,
azureOptions: this.azure,
})
baseURL: this.langchainProxy,
azureOptions: this.azure,
})
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion };
@@ -1243,6 +1242,9 @@ ${convo}
modelOptions.max_completion_tokens = modelOptions.max_tokens;
delete modelOptions.max_tokens;
}
if (this.isOmni === true && modelOptions.temperature != null) {
delete modelOptions.temperature;
}
if (process.env.OPENAI_ORGANIZATION) {
opts.organization = process.env.OPENAI_ORGANIZATION;
@@ -1251,7 +1253,10 @@ ${convo}
let chatCompletion;
/** @type {OpenAI} */
const openai = new OpenAI({
fetch: this.fetch,
fetch: createFetch({
directEndpoint: this.options.directEndpoint,
reverseProxyUrl: this.options.reverseProxyUrl,
}),
apiKey: this.apiKey,
...opts,
});
@@ -1280,13 +1285,22 @@ ${convo}
modelOptions.messages[0].role = 'user';
}
if (
(this.options.endpoint === EModelEndpoint.openAI ||
this.options.endpoint === EModelEndpoint.azureOpenAI) &&
modelOptions.stream === true
) {
modelOptions.stream_options = { include_usage: true };
}
if (this.options.addParams && typeof this.options.addParams === 'object') {
const addParams = { ...this.options.addParams };
modelOptions = {
...modelOptions,
...this.options.addParams,
...addParams,
};
logger.debug('[OpenAIClient] chatCompletion: added params', {
addParams: this.options.addParams,
addParams: addParams,
modelOptions,
});
}
@@ -1315,11 +1329,12 @@ ${convo}
}
if (this.options.dropParams && Array.isArray(this.options.dropParams)) {
this.options.dropParams.forEach((param) => {
const dropParams = [...this.options.dropParams];
dropParams.forEach((param) => {
delete modelOptions[param];
});
logger.debug('[OpenAIClient] chatCompletion: dropped params', {
dropParams: this.options.dropParams,
dropParams: dropParams,
modelOptions,
});
}
@@ -1361,15 +1376,12 @@ ${convo}
delete modelOptions.reasoning_effort;
}
const handlers = createStreamEventHandlers(this.options.res);
this.streamHandler = new SplitStreamHandler({
reasoningKey,
accumulate: true,
runId: this.responseMessageId,
handlers: {
[GraphEvents.ON_RUN_STEP]: (event) => sendEvent(this.options.res, event),
[GraphEvents.ON_MESSAGE_DELTA]: (event) => sendEvent(this.options.res, event),
[GraphEvents.ON_REASONING_DELTA]: (event) => sendEvent(this.options.res, event),
},
handlers,
});
intermediateReply = this.streamHandler.tokens;
@@ -1383,12 +1395,6 @@ ${convo}
...modelOptions,
stream: true,
};
if (
this.options.endpoint === EModelEndpoint.openAI ||
this.options.endpoint === EModelEndpoint.azureOpenAI
) {
params.stream_options = { include_usage: true };
}
const stream = await openai.beta.chat.completions
.stream(params)
.on('abort', () => {
@@ -1473,6 +1479,11 @@ ${convo}
});
}
if (openai.abortHandler && abortController.signal) {
abortController.signal.removeEventListener('abort', openai.abortHandler);
openai.abortHandler = undefined;
}
if (!chatCompletion && UnexpectedRoleError) {
throw new Error(
'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant',

View File

@@ -252,12 +252,14 @@ class PluginsClient extends OpenAIClient {
await this.recordTokenUsage(responseMessage);
}
this.responsePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
const databasePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
delete responseMessage.tokenCount;
return { ...responseMessage, ...result };
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;
@@ -327,10 +329,10 @@ class PluginsClient extends OpenAIClient {
}
if (!this.skipSaveUserMessage) {
this.userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user);
if (typeof opts?.getReqData === 'function') {
opts.getReqData({
userMessagePromise: this.userMessagePromise,
userMessagePromise,
});
}
}

View File

@@ -0,0 +1,71 @@
const fetch = require('node-fetch');
const { GraphEvents } = require('@librechat/agents');
const { logger, sendEvent } = require('~/config');
const { sleep } = require('~/server/utils');
/**
* Makes a function to make HTTP request and logs the process.
* @param {Object} params
* @param {boolean} [params.directEndpoint] - Whether to use a direct endpoint.
* @param {string} [params.reverseProxyUrl] - The reverse proxy URL to use for the request.
* @returns {Promise<Response>} - A promise that resolves to the response of the fetch request.
*/
function createFetch({ directEndpoint = false, reverseProxyUrl = '' }) {
/**
* Makes an HTTP request and logs the process.
* @param {RequestInfo} url - The URL to make the request to. Can be a string or a Request object.
* @param {RequestInit} [init] - Optional init options for the request.
* @returns {Promise<Response>} - A promise that resolves to the response of the fetch request.
*/
return async (_url, init) => {
let url = _url;
if (directEndpoint) {
url = reverseProxyUrl;
}
logger.debug(`Making request to ${url}`);
if (typeof Bun !== 'undefined') {
return await fetch(url, init);
}
return await fetch(url, init);
};
}
// Add this at the module level outside the class
/**
* Creates event handlers for stream events that don't capture client references
* @param {Object} res - The response object to send events to
* @returns {Object} Object containing handler functions
*/
function createStreamEventHandlers(res) {
return {
[GraphEvents.ON_RUN_STEP]: (event) => {
if (res) {
sendEvent(res, event);
}
},
[GraphEvents.ON_MESSAGE_DELTA]: (event) => {
if (res) {
sendEvent(res, event);
}
},
[GraphEvents.ON_REASONING_DELTA]: (event) => {
if (res) {
sendEvent(res, event);
}
},
};
}
function createHandleLLMNewToken(streamRate) {
return async () => {
if (streamRate) {
await sleep(streamRate);
}
};
}
module.exports = {
createFetch,
createHandleLLMNewToken,
createStreamEventHandlers,
};

View File

@@ -34,6 +34,7 @@ function createLLM({
let credentials = { openAIApiKey };
let configuration = {
apiKey: openAIApiKey,
...(configOptions.basePath && { baseURL: configOptions.basePath }),
};
/** @type {AzureOptions} */

View File

@@ -32,7 +32,7 @@ jest.mock('~/models', () => ({
const { getConvo, saveConvo } = require('~/models');
jest.mock('@langchain/openai', () => {
jest.mock('@librechat/agents', () => {
return {
ChatOpenAI: jest.fn().mockImplementation(() => {
return {};

View File

@@ -1,9 +1,7 @@
jest.mock('~/cache/getLogStores');
require('dotenv').config();
const OpenAI = require('openai');
const getLogStores = require('~/cache/getLogStores');
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source');
const { genAzureChatCompletion } = require('~/utils/azureUtils');
const getLogStores = require('~/cache/getLogStores');
const OpenAIClient = require('../OpenAIClient');
jest.mock('meilisearch');
@@ -36,19 +34,21 @@ jest.mock('~/models', () => ({
updateFileUsage: jest.fn(),
}));
jest.mock('@langchain/openai', () => {
return {
ChatOpenAI: jest.fn().mockImplementation(() => {
return {};
}),
};
// Import the actual module but mock specific parts
const agents = jest.requireActual('@librechat/agents');
const { CustomOpenAIClient } = agents;
// Also mock ChatOpenAI to prevent real API calls
agents.ChatOpenAI = jest.fn().mockImplementation(() => {
return {};
});
agents.AzureChatOpenAI = jest.fn().mockImplementation(() => {
return {};
});
jest.mock('openai');
jest.spyOn(OpenAI, 'constructor').mockImplementation(function (...options) {
// We can add additional logic here if needed
return new OpenAI(...options);
// Mock only the CustomOpenAIClient constructor
jest.spyOn(CustomOpenAIClient, 'constructor').mockImplementation(function (...options) {
return new CustomOpenAIClient(...options);
});
const finalChatCompletion = jest.fn().mockResolvedValue({
@@ -120,7 +120,13 @@ const create = jest.fn().mockResolvedValue({
],
});
OpenAI.mockImplementation(() => ({
// Mock the implementation of CustomOpenAIClient instances
jest.spyOn(CustomOpenAIClient.prototype, 'constructor').mockImplementation(function () {
return this;
});
// Create a mock for the CustomOpenAIClient class
const mockCustomOpenAIClient = jest.fn().mockImplementation(() => ({
beta: {
chat: {
completions: {
@@ -135,6 +141,8 @@ OpenAI.mockImplementation(() => ({
},
}));
CustomOpenAIClient.mockImplementation = mockCustomOpenAIClient;
describe('OpenAIClient', () => {
beforeEach(() => {
const mockCache = {
@@ -559,41 +567,6 @@ describe('OpenAIClient', () => {
expect(requestBody).toHaveProperty('model');
expect(requestBody.model).toBe(model);
});
it('[Azure OpenAI] should call chatCompletion and OpenAI.stream with correct args', async () => {
// Set a default model
process.env.AZURE_OPENAI_DEFAULT_MODEL = 'gpt4-turbo';
const onProgress = jest.fn().mockImplementation(() => ({}));
client.azure = defaultAzureOptions;
const chatCompletion = jest.spyOn(client, 'chatCompletion');
await client.sendMessage('Hi mom!', {
replaceOptions: true,
...defaultOptions,
modelOptions: { model: 'gpt4-turbo', stream: true },
onProgress,
azure: defaultAzureOptions,
});
expect(chatCompletion).toHaveBeenCalled();
expect(chatCompletion.mock.calls.length).toBe(1);
const chatCompletionArgs = chatCompletion.mock.calls[0][0];
const { payload } = chatCompletionArgs;
expect(payload[0].role).toBe('user');
expect(payload[0].content).toBe('Hi mom!');
// Azure OpenAI does not use the model property, and will error if it's passed
// This check ensures the model property is not present
const streamArgs = stream.mock.calls[0][0];
expect(streamArgs).not.toHaveProperty('model');
// Check if the baseURL is correct
const constructorArgs = OpenAI.mock.calls[0][0];
const expectedURL = genAzureChatCompletion(defaultAzureOptions).split('/chat')[0];
expect(constructorArgs.baseURL).toBe(expectedURL);
});
});
describe('checkVisionRequest functionality', () => {

View File

@@ -10,6 +10,7 @@ const StructuredACS = require('./structured/AzureAISearch');
const StructuredSD = require('./structured/StableDiffusion');
const GoogleSearchAPI = require('./structured/GoogleSearch');
const TraversaalSearch = require('./structured/TraversaalSearch');
const createOpenAIImageTools = require('./structured/OpenAIImageTools');
const TavilySearchResults = require('./structured/TavilySearchResults');
/** @type {Record<string, TPlugin | undefined>} */
@@ -40,4 +41,5 @@ module.exports = {
StructuredWolfram,
createYouTubeTools,
TavilySearchResults,
createOpenAIImageTools,
};

View File

@@ -44,6 +44,20 @@
}
]
},
{
"name": "OpenAI Image Tools",
"pluginKey": "image_gen_oai",
"toolkit": true,
"description": "Image Generation and Editing using OpenAI's latest state-of-the-art models",
"icon": "/assets/image_gen_oai.png",
"authConfig": [
{
"authField": "IMAGE_GEN_OAI_API_KEY",
"label": "OpenAI Image Tools API Key",
"description": "Your OpenAI API Key for Image Generation and Editing"
}
]
},
{
"name": "Wolfram",
"pluginKey": "wolfram",

View File

@@ -0,0 +1,518 @@
const { z } = require('zod');
const axios = require('axios');
const { v4 } = require('uuid');
const OpenAI = require('openai');
const FormData = require('form-data');
const { tool } = require('@langchain/core/tools');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { logAxiosError, extractBaseURL } = require('~/utils');
const { getFiles } = require('~/models/File');
const { logger } = require('~/config');
/** Default descriptions for image generation tool */
const DEFAULT_IMAGE_GEN_DESCRIPTION = `
Generates high-quality, original images based solely on text, not using any uploaded reference images.
When to use \`image_gen_oai\`:
- To create entirely new images from detailed text descriptions that do NOT reference any image files.
When NOT to use \`image_gen_oai\`:
- If the user has uploaded any images and requests modifications, enhancements, or remixing based on those uploads → use \`image_edit_oai\` instead.
Generated image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`.
`.trim();
/** Default description for image editing tool */
const DEFAULT_IMAGE_EDIT_DESCRIPTION =
`Generates high-quality, original images based on text and one or more uploaded/referenced images.
When to use \`image_edit_oai\`:
- The user wants to modify, extend, or remix one **or more** uploaded images, either:
- Previously generated, or in the current request (both to be included in the \`image_ids\` array).
- Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements.
- Any current or existing images are to be used as visual guides.
- If there are any files in the current request, they are more likely than not expected as references for image edit requests.
When NOT to use \`image_edit_oai\`:
- Brand-new generations that do not rely on an existing image → use \`image_gen_oai\` instead.
Both generated and referenced image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`.
`.trim();
/** Default prompt descriptions */
const DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION = `Describe the image you want in detail.
Be highly specific—break your idea into layers:
(1) main concept and subject,
(2) composition and position,
(3) lighting and mood,
(4) style, medium, or camera details,
(5) important features (age, expression, clothing, etc.),
(6) background.
Use positive, descriptive language and specify what should be included, not what to avoid.
List number and characteristics of people/objects, and mention style/technical requirements (e.g., "DSLR photo, 85mm lens, golden hour").
Do not reference any uploaded images—use for new image creation from text only.`;
const DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION = `Describe the changes, enhancements, or new ideas to apply to the uploaded image(s).
Be highly specific—break your request into layers:
(1) main concept or transformation,
(2) specific edits/replacements or composition guidance,
(3) desired style, mood, or technique,
(4) features/items to keep, change, or add (such as objects, people, clothing, lighting, etc.).
Use positive, descriptive language and clarify what should be included or changed, not what to avoid.
Always base this prompt on the most recently uploaded reference images.`;
const displayMessage =
'The tool displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.';
/**
* Replaces unwanted characters from the input string
* @param {string} inputString - The input string to process
* @returns {string} - The processed string
*/
function replaceUnwantedChars(inputString) {
return inputString
.replace(/\r\n|\r|\n/g, ' ')
.replace(/"/g, '')
.trim();
}
function returnValue(value) {
if (typeof value === 'string') {
return [value, {}];
} else if (typeof value === 'object') {
if (Array.isArray(value)) {
return value;
}
return [displayMessage, value];
}
return value;
}
const getImageGenDescription = () => {
return process.env.IMAGE_GEN_OAI_DESCRIPTION || DEFAULT_IMAGE_GEN_DESCRIPTION;
};
const getImageEditDescription = () => {
return process.env.IMAGE_EDIT_OAI_DESCRIPTION || DEFAULT_IMAGE_EDIT_DESCRIPTION;
};
const getImageGenPromptDescription = () => {
return process.env.IMAGE_GEN_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION;
};
const getImageEditPromptDescription = () => {
return process.env.IMAGE_EDIT_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION;
};
/**
* Creates OpenAI Image tools (generation and editing)
* @param {Object} fields - Configuration fields
* @param {ServerRequest} fields.req - Whether the tool is being used in an agent context
* @param {boolean} fields.isAgent - Whether the tool is being used in an agent context
* @param {string} fields.IMAGE_GEN_OAI_API_KEY - The OpenAI API key
* @param {boolean} [fields.override] - Whether to override the API key check, necessary for app initialization
* @param {MongoFile[]} [fields.imageFiles] - The images to be used for editing
* @returns {Array} - Array of image tools
*/
function createOpenAIImageTools(fields = {}) {
/** @type {boolean} Used to initialize the Tool without necessary variables. */
const override = fields.override ?? false;
/** @type {boolean} */
if (!override && !fields.isAgent) {
throw new Error('This tool is only available for agents.');
}
const { req } = fields;
const imageOutputType = req?.app.locals.imageOutputType || EImageOutputType.PNG;
const appFileStrategy = req?.app.locals.fileStrategy;
const getApiKey = () => {
const apiKey = process.env.IMAGE_GEN_OAI_API_KEY ?? '';
if (!apiKey && !override) {
throw new Error('Missing IMAGE_GEN_OAI_API_KEY environment variable.');
}
return apiKey;
};
let apiKey = fields.IMAGE_GEN_OAI_API_KEY ?? getApiKey();
const closureConfig = { apiKey };
let baseURL = 'https://api.openai.com/v1/';
if (!override && process.env.IMAGE_GEN_OAI_BASEURL) {
baseURL = extractBaseURL(process.env.IMAGE_GEN_OAI_BASEURL);
closureConfig.baseURL = baseURL;
}
// Note: Azure may not yet support the latest image generation models
if (
!override &&
process.env.IMAGE_GEN_OAI_AZURE_API_VERSION &&
process.env.IMAGE_GEN_OAI_BASEURL
) {
baseURL = process.env.IMAGE_GEN_OAI_BASEURL;
closureConfig.baseURL = baseURL;
closureConfig.defaultQuery = { 'api-version': process.env.IMAGE_GEN_OAI_AZURE_API_VERSION };
closureConfig.defaultHeaders = {
'api-key': process.env.IMAGE_GEN_OAI_API_KEY,
'Content-Type': 'application/json',
};
closureConfig.apiKey = process.env.IMAGE_GEN_OAI_API_KEY;
}
const imageFiles = fields.imageFiles ?? [];
/**
* Image Generation Tool
*/
const imageGenTool = tool(
async (
{
prompt,
background = 'auto',
n = 1,
output_compression = 100,
quality = 'auto',
size = 'auto',
},
runnableConfig,
) => {
if (!prompt) {
throw new Error('Missing required field: prompt');
}
const clientConfig = { ...closureConfig };
if (process.env.PROXY) {
clientConfig.httpAgent = new HttpsProxyAgent(process.env.PROXY);
}
/** @type {OpenAI} */
const openai = new OpenAI(clientConfig);
let output_format = imageOutputType;
if (
background === 'transparent' &&
output_format !== EImageOutputType.PNG &&
output_format !== EImageOutputType.WEBP
) {
logger.warn(
'[ImageGenOAI] Transparent background requires PNG or WebP format, defaulting to PNG',
);
output_format = EImageOutputType.PNG;
}
let resp;
try {
const derivedSignal = runnableConfig?.signal
? AbortSignal.any([runnableConfig.signal])
: undefined;
resp = await openai.images.generate(
{
model: 'gpt-image-1',
prompt: replaceUnwantedChars(prompt),
n: Math.min(Math.max(1, n), 10),
background,
output_format,
output_compression:
output_format === EImageOutputType.WEBP || output_format === EImageOutputType.JPEG
? output_compression
: undefined,
quality,
size,
},
{
signal: derivedSignal,
},
);
} catch (error) {
const message = '[image_gen_oai] Problem generating the image:';
logAxiosError({ error, message });
return returnValue(`Something went wrong when trying to generate the image. The OpenAI API may be unavailable:
Error Message: ${error.message}`);
}
if (!resp) {
return returnValue(
'Something went wrong when trying to generate the image. The OpenAI API may be unavailable',
);
}
// For gpt-image-1, the response contains base64-encoded images
// TODO: handle cost in `resp.usage`
const base64Image = resp.data[0].b64_json;
if (!base64Image) {
return returnValue(
'No image data returned from OpenAI API. There may be a problem with the API or your configuration.',
);
}
const content = [
{
type: ContentTypes.IMAGE_URL,
image_url: {
url: `data:image/${output_format};base64,${base64Image}`,
},
},
];
const file_ids = [v4()];
const response = [
{
type: ContentTypes.TEXT,
text: displayMessage + `\n\ngenerated_image_id: "${file_ids[0]}"`,
},
];
return [response, { content, file_ids }];
},
{
name: 'image_gen_oai',
description: getImageGenDescription(),
schema: z.object({
prompt: z.string().max(32000).describe(getImageGenPromptDescription()),
background: z
.enum(['transparent', 'opaque', 'auto'])
.optional()
.describe(
'Sets transparency for the background. Must be one of transparent, opaque or auto (default). When transparent, the output format should be png or webp.',
),
/*
n: z
.number()
.int()
.min(1)
.max(10)
.optional()
.describe('The number of images to generate. Must be between 1 and 10.'),
output_compression: z
.number()
.int()
.min(0)
.max(100)
.optional()
.describe('The compression level (0-100%) for webp or jpeg formats. Defaults to 100.'),
*/
quality: z
.enum(['auto', 'high', 'medium', 'low'])
.optional()
.describe('The quality of the image. One of auto (default), high, medium, or low.'),
size: z
.enum(['auto', '1024x1024', '1536x1024', '1024x1536'])
.optional()
.describe(
'The size of the generated image. One of 1024x1024, 1536x1024 (landscape), 1024x1536 (portrait), or auto (default).',
),
}),
responseFormat: 'content_and_artifact',
},
);
/**
* Image Editing Tool
*/
const imageEditTool = tool(
async ({ prompt, image_ids, quality = 'auto', size = 'auto' }, runnableConfig) => {
if (!prompt) {
throw new Error('Missing required field: prompt');
}
const clientConfig = { ...closureConfig };
if (process.env.PROXY) {
clientConfig.httpAgent = new HttpsProxyAgent(process.env.PROXY);
}
const formData = new FormData();
formData.append('model', 'gpt-image-1');
formData.append('prompt', replaceUnwantedChars(prompt));
// TODO: `mask` support
// TODO: more than 1 image support
// formData.append('n', n.toString());
formData.append('quality', quality);
formData.append('size', size);
/** @type {Record<FileSources, undefined | NodeStreamDownloader<File>>} */
const streamMethods = {};
const requestFilesMap = Object.fromEntries(imageFiles.map((f) => [f.file_id, { ...f }]));
const orderedFiles = new Array(image_ids.length);
const idsToFetch = [];
const indexOfMissing = Object.create(null);
for (let i = 0; i < image_ids.length; i++) {
const id = image_ids[i];
const file = requestFilesMap[id];
if (file) {
orderedFiles[i] = file;
} else {
idsToFetch.push(id);
indexOfMissing[id] = i;
}
}
if (idsToFetch.length) {
const fetchedFiles = await getFiles(
{
user: req.user.id,
file_id: { $in: idsToFetch },
height: { $exists: true },
width: { $exists: true },
},
{},
{},
);
for (const file of fetchedFiles) {
requestFilesMap[file.file_id] = file;
orderedFiles[indexOfMissing[file.file_id]] = file;
}
}
for (const imageFile of orderedFiles) {
if (!imageFile) {
continue;
}
/** @type {NodeStream<File>} */
let stream;
/** @type {NodeStreamDownloader<File>} */
let getDownloadStream;
const source = imageFile.source || appFileStrategy;
if (!source) {
throw new Error('No source found for image file');
}
if (streamMethods[source]) {
getDownloadStream = streamMethods[source];
} else {
({ getDownloadStream } = getStrategyFunctions(source));
streamMethods[source] = getDownloadStream;
}
if (!getDownloadStream) {
throw new Error(`No download stream method found for source: ${source}`);
}
stream = await getDownloadStream(req, imageFile.filepath);
if (!stream) {
throw new Error('Failed to get download stream for image file');
}
formData.append('image[]', stream, {
filename: imageFile.filename,
contentType: imageFile.type,
});
}
/** @type {import('axios').RawAxiosHeaders} */
let headers = {
...formData.getHeaders(),
};
if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {
headers['api-key'] = apiKey;
} else {
headers['Authorization'] = `Bearer ${apiKey}`;
}
try {
const derivedSignal = runnableConfig?.signal
? AbortSignal.any([runnableConfig.signal])
: undefined;
/** @type {import('axios').AxiosRequestConfig} */
const axiosConfig = {
headers,
...clientConfig,
signal: derivedSignal,
baseURL,
};
if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {
axiosConfig.params = {
'api-version': process.env.IMAGE_GEN_OAI_AZURE_API_VERSION,
...axiosConfig.params,
};
}
const response = await axios.post('/images/edits', formData, axiosConfig);
if (!response.data || !response.data.data || !response.data.data.length) {
return returnValue(
'No image data returned from OpenAI API. There may be a problem with the API or your configuration.',
);
}
const base64Image = response.data.data[0].b64_json;
if (!base64Image) {
return returnValue(
'No image data returned from OpenAI API. There may be a problem with the API or your configuration.',
);
}
const content = [
{
type: ContentTypes.IMAGE_URL,
image_url: {
url: `data:image/${imageOutputType};base64,${base64Image}`,
},
},
];
const file_ids = [v4()];
const textResponse = [
{
type: ContentTypes.TEXT,
text:
displayMessage +
`\n\ngenerated_image_id: "${file_ids[0]}"\nreferenced_image_ids: ["${image_ids.join('", "')}"]`,
},
];
return [textResponse, { content, file_ids }];
} catch (error) {
const message = '[image_edit_oai] Problem editing the image:';
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'}`);
}
},
{
name: 'image_edit_oai',
description: getImageEditDescription(),
schema: z.object({
image_ids: z
.array(z.string())
.min(1)
.describe(
`
IDs (image ID strings) of previously generated or uploaded images that should guide the edit.
Guidelines:
- If the user's request depends on any prior image(s), copy their image IDs into the \`image_ids\` array (in the same order the user refers to them).
- Never invent or hallucinate IDs; only use IDs that are still visible in the conversation context.
- If no earlier image is relevant, omit the field entirely.
`.trim(),
),
prompt: z.string().max(32000).describe(getImageEditPromptDescription()),
/*
n: z
.number()
.int()
.min(1)
.max(10)
.optional()
.describe('The number of images to generate. Must be between 1 and 10. Defaults to 1.'),
*/
quality: z
.enum(['auto', 'high', 'medium', 'low'])
.optional()
.describe(
'The quality of the image. One of auto (default), high, medium, or low. High/medium/low only supported for gpt-image-1.',
),
size: z
.enum(['auto', '1024x1024', '1536x1024', '1024x1536', '256x256', '512x512'])
.optional()
.describe(
'The size of the generated images. For gpt-image-1: auto (default), 1024x1024, 1536x1024, 1024x1536. For dall-e-2: 256x256, 512x512, 1024x1024.',
),
}),
responseFormat: 'content_and_artifact',
},
);
return [imageGenTool, imageEditTool];
}
module.exports = createOpenAIImageTools;

View File

@@ -43,9 +43,39 @@ class TavilySearchResults extends Tool {
.boolean()
.optional()
.describe('Whether to include answers in the search results. Default is False.'),
// include_raw_content: z.boolean().optional().describe('Whether to include raw content in the search results. Default is False.'),
// include_domains: z.array(z.string()).optional().describe('A list of domains to specifically include in the search results.'),
// exclude_domains: z.array(z.string()).optional().describe('A list of domains to specifically exclude from the search results.'),
include_raw_content: z
.boolean()
.optional()
.describe('Whether to include raw content in the search results. Default is False.'),
include_domains: z
.array(z.string())
.optional()
.describe('A list of domains to specifically include in the search results.'),
exclude_domains: z
.array(z.string())
.optional()
.describe('A list of domains to specifically exclude from the search results.'),
topic: z
.enum(['general', 'news', 'finance'])
.optional()
.describe(
'The category of the search. Use news ONLY if query SPECIFCALLY mentions the word "news".',
),
time_range: z
.enum(['day', 'week', 'month', 'year', 'd', 'w', 'm', 'y'])
.optional()
.describe('The time range back from the current date to filter results.'),
days: z
.number()
.min(1)
.optional()
.describe('Number of days back from the current date to include. Only if topic is news.'),
include_image_descriptions: z
.boolean()
.optional()
.describe(
'When include_images is true, also add a descriptive text for each image. Default is false.',
),
});
}

View File

@@ -1,30 +0,0 @@
const { loadSpecs } = require('./loadSpecs');
function transformSpec(input) {
return {
name: input.name_for_human,
pluginKey: input.name_for_model,
description: input.description_for_human,
icon: input?.logo_url ?? 'https://placehold.co/70x70.png',
// TODO: add support for authentication
isAuthRequired: 'false',
authConfig: [],
};
}
async function addOpenAPISpecs(availableTools) {
try {
const specs = (await loadSpecs({})).map(transformSpec);
if (specs.length > 0) {
return [...specs, ...availableTools];
}
return availableTools;
} catch (error) {
return availableTools;
}
}
module.exports = {
transformSpec,
addOpenAPISpecs,
};

View File

@@ -1,76 +0,0 @@
const { addOpenAPISpecs, transformSpec } = require('./addOpenAPISpecs');
const { loadSpecs } = require('./loadSpecs');
const { createOpenAPIPlugin } = require('../dynamic/OpenAPIPlugin');
jest.mock('./loadSpecs');
jest.mock('../dynamic/OpenAPIPlugin');
describe('transformSpec', () => {
it('should transform input spec to a desired format', () => {
const input = {
name_for_human: 'Human Name',
name_for_model: 'Model Name',
description_for_human: 'Human Description',
logo_url: 'https://example.com/logo.png',
};
const expectedOutput = {
name: 'Human Name',
pluginKey: 'Model Name',
description: 'Human Description',
icon: 'https://example.com/logo.png',
isAuthRequired: 'false',
authConfig: [],
};
expect(transformSpec(input)).toEqual(expectedOutput);
});
it('should use default icon if logo_url is not provided', () => {
const input = {
name_for_human: 'Human Name',
name_for_model: 'Model Name',
description_for_human: 'Human Description',
};
const expectedOutput = {
name: 'Human Name',
pluginKey: 'Model Name',
description: 'Human Description',
icon: 'https://placehold.co/70x70.png',
isAuthRequired: 'false',
authConfig: [],
};
expect(transformSpec(input)).toEqual(expectedOutput);
});
});
describe('addOpenAPISpecs', () => {
it('should add specs to available tools', async () => {
const availableTools = ['Tool1', 'Tool2'];
const specs = [
{
name_for_human: 'Human Name',
name_for_model: 'Model Name',
description_for_human: 'Human Description',
logo_url: 'https://example.com/logo.png',
},
];
loadSpecs.mockResolvedValue(specs);
createOpenAPIPlugin.mockReturnValue('Plugin');
const result = await addOpenAPISpecs(availableTools);
expect(result).toEqual([...specs.map(transformSpec), ...availableTools]);
});
it('should return available tools if specs loading fails', async () => {
const availableTools = ['Tool1', 'Tool2'];
loadSpecs.mockRejectedValue(new Error('Failed to load specs'));
const result = await addOpenAPISpecs(availableTools);
expect(result).toEqual(availableTools);
});
});

View File

@@ -1,7 +1,7 @@
const { Tools, Constants } = require('librechat-data-provider');
const { SerpAPI } = require('@langchain/community/tools/serpapi');
const { Calculator } = require('@langchain/community/tools/calculator');
const { createCodeExecutionTool, EnvVar } = require('@librechat/agents');
const { Tools, Constants, EToolResources } = require('librechat-data-provider');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const {
availableTools,
@@ -18,12 +18,12 @@ const {
StructuredWolfram,
createYouTubeTools,
TavilySearchResults,
createOpenAIImageTools,
} = require('../');
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { createMCPTool } = require('~/server/services/MCP');
const { loadSpecs } = require('./loadSpecs');
const { logger } = require('~/config');
const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
@@ -123,7 +123,7 @@ const getAuthFields = (toolKey) => {
*
* @param {object} object
* @param {string} object.user
* @param {Agent} [object.agent]
* @param {Pick<Agent, 'id' | 'provider' | 'model'>} [object.agent]
* @param {string} [object.model]
* @param {EModelEndpoint} [object.endpoint]
* @param {LoadToolOptions} [object.options]
@@ -157,7 +157,7 @@ const loadTools = async ({
};
const customConstructors = {
serpapi: async () => {
serpapi: async (_toolContextMap) => {
const authFields = getAuthFields('serpapi');
let envVar = authFields[0] ?? '';
let apiKey = process.env[envVar];
@@ -170,11 +170,40 @@ const loadTools = async ({
gl: 'us',
});
},
youtube: async () => {
youtube: async (_toolContextMap) => {
const authFields = getAuthFields('youtube');
const authValues = await loadAuthValues({ userId: user, authFields });
return createYouTubeTools(authValues);
},
image_gen_oai: async (toolContextMap) => {
const authFields = getAuthFields('image_gen_oai');
const authValues = await loadAuthValues({ userId: user, authFields });
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
let toolContext = '';
for (let i = 0; i < imageFiles.length; i++) {
const file = imageFiles[i];
if (!file) {
continue;
}
if (i === 0) {
toolContext =
'Image files provided in this request (their image IDs listed in order of appearance) available for image editing:';
}
toolContext += `\n\t- ${file.file_id}`;
if (i === imageFiles.length - 1) {
toolContext += `\n\nInclude any you need in the \`image_ids\` array when calling \`${EToolResources.image_edit}_oai\`. You may also include previously referenced or generated image IDs.`;
}
}
if (toolContext) {
toolContextMap.image_edit_oai = toolContext;
}
return createOpenAIImageTools({
...authValues,
isAgent: !!agent,
req: options.req,
imageFiles,
});
},
};
const requestedTools = {};
@@ -200,8 +229,8 @@ const loadTools = async ({
serpapi: { location: 'Austin,Texas,United States', hl: 'en', gl: 'us' },
};
/** @type {Record<string, string>} */
const toolContextMap = {};
const remainingTools = [];
const appTools = options.req?.app?.locals?.availableTools ?? {};
for (const tool of tools) {
@@ -246,7 +275,7 @@ const loadTools = async ({
}
if (customConstructors[tool]) {
requestedTools[tool] = customConstructors[tool];
requestedTools[tool] = async () => customConstructors[tool](toolContextMap);
continue;
}
@@ -261,30 +290,6 @@ const loadTools = async ({
requestedTools[tool] = toolInstance;
continue;
}
if (functions === true) {
remainingTools.push(tool);
}
}
let specs = null;
if (useSpecs === true && functions === true && remainingTools.length > 0) {
specs = await loadSpecs({
llm: model,
user,
message: options.message,
memory: options.memory,
signal: options.signal,
tools: remainingTools,
map: true,
verbose: false,
});
}
for (const tool of remainingTools) {
if (specs && specs[tool]) {
requestedTools[tool] = specs[tool];
}
}
if (returnMap) {

View File

@@ -1,117 +0,0 @@
const fs = require('fs');
const path = require('path');
const { z } = require('zod');
const { logger } = require('~/config');
const { createOpenAPIPlugin } = require('~/app/clients/tools/dynamic/OpenAPIPlugin');
// The minimum Manifest definition
const ManifestDefinition = z.object({
schema_version: z.string().optional(),
name_for_human: z.string(),
name_for_model: z.string(),
description_for_human: z.string(),
description_for_model: z.string(),
auth: z.object({}).optional(),
api: z.object({
// Spec URL or can be the filename of the OpenAPI spec yaml file,
// located in api\app\clients\tools\.well-known\openapi
url: z.string(),
type: z.string().optional(),
is_user_authenticated: z.boolean().nullable().optional(),
has_user_authentication: z.boolean().nullable().optional(),
}),
// use to override any params that the LLM will consistently get wrong
params: z.object({}).optional(),
logo_url: z.string().optional(),
contact_email: z.string().optional(),
legal_info_url: z.string().optional(),
});
function validateJson(json) {
try {
return ManifestDefinition.parse(json);
} catch (error) {
logger.debug('[validateJson] manifest parsing error', error);
return false;
}
}
// omit the LLM to return the well known jsons as objects
async function loadSpecs({ llm, user, message, tools = [], map = false, memory, signal }) {
const directoryPath = path.join(__dirname, '..', '.well-known');
let files = [];
for (let i = 0; i < tools.length; i++) {
const filePath = path.join(directoryPath, tools[i] + '.json');
try {
// If the access Promise is resolved, it means that the file exists
// Then we can add it to the files array
await fs.promises.access(filePath, fs.constants.F_OK);
files.push(tools[i] + '.json');
} catch (err) {
logger.error(`[loadSpecs] File ${tools[i] + '.json'} does not exist`, err);
}
}
if (files.length === 0) {
files = (await fs.promises.readdir(directoryPath)).filter(
(file) => path.extname(file) === '.json',
);
}
const validJsons = [];
const constructorMap = {};
logger.debug('[validateJson] files', files);
for (const file of files) {
if (path.extname(file) === '.json') {
const filePath = path.join(directoryPath, file);
const fileContent = await fs.promises.readFile(filePath, 'utf8');
const json = JSON.parse(fileContent);
if (!validateJson(json)) {
logger.debug('[validateJson] Invalid json', json);
continue;
}
if (llm && map) {
constructorMap[json.name_for_model] = async () =>
await createOpenAPIPlugin({
data: json,
llm,
message,
memory,
signal,
user,
});
continue;
}
if (llm) {
validJsons.push(createOpenAPIPlugin({ data: json, llm }));
continue;
}
validJsons.push(json);
}
}
if (map) {
return constructorMap;
}
const plugins = (await Promise.all(validJsons)).filter((plugin) => plugin);
// logger.debug('[validateJson] plugins', plugins);
// logger.debug(plugins[0].name);
return plugins;
}
module.exports = {
loadSpecs,
validateJson,
ManifestDefinition,
};

View File

@@ -1,101 +0,0 @@
const fs = require('fs');
const { validateJson, loadSpecs, ManifestDefinition } = require('./loadSpecs');
const { createOpenAPIPlugin } = require('../dynamic/OpenAPIPlugin');
jest.mock('../dynamic/OpenAPIPlugin');
describe('ManifestDefinition', () => {
it('should validate correct json', () => {
const json = {
name_for_human: 'Test',
name_for_model: 'Test',
description_for_human: 'Test',
description_for_model: 'Test',
api: {
url: 'http://test.com',
},
};
expect(() => ManifestDefinition.parse(json)).not.toThrow();
});
it('should not validate incorrect json', () => {
const json = {
name_for_human: 'Test',
name_for_model: 'Test',
description_for_human: 'Test',
description_for_model: 'Test',
api: {
url: 123, // incorrect type
},
};
expect(() => ManifestDefinition.parse(json)).toThrow();
});
});
describe('validateJson', () => {
it('should return parsed json if valid', () => {
const json = {
name_for_human: 'Test',
name_for_model: 'Test',
description_for_human: 'Test',
description_for_model: 'Test',
api: {
url: 'http://test.com',
},
};
expect(validateJson(json)).toEqual(json);
});
it('should return false if json is not valid', () => {
const json = {
name_for_human: 'Test',
name_for_model: 'Test',
description_for_human: 'Test',
description_for_model: 'Test',
api: {
url: 123, // incorrect type
},
};
expect(validateJson(json)).toEqual(false);
});
});
describe('loadSpecs', () => {
beforeEach(() => {
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['test.json']);
jest.spyOn(fs.promises, 'readFile').mockResolvedValue(
JSON.stringify({
name_for_human: 'Test',
name_for_model: 'Test',
description_for_human: 'Test',
description_for_model: 'Test',
api: {
url: 'http://test.com',
},
}),
);
createOpenAPIPlugin.mockResolvedValue({});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should return plugins', async () => {
const plugins = await loadSpecs({ llm: true, verbose: false });
expect(plugins).toHaveLength(1);
expect(createOpenAPIPlugin).toHaveBeenCalledTimes(1);
});
it('should return constructorMap if map is true', async () => {
const plugins = await loadSpecs({ llm: {}, map: true, verbose: false });
expect(plugins).toHaveProperty('Test');
expect(createOpenAPIPlugin).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,8 @@
const { Time, CacheKeys } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const getLogStores = require('./getLogStores');
const { isEnabled } = require('../server/utils');
const { USE_REDIS, LIMIT_CONCURRENT_MESSAGES } = process.env ?? {};
const ttl = 1000 * 60 * 1;
/**
* Clear or decrement pending requests from the cache.
@@ -28,7 +29,7 @@ const clearPendingReq = async ({ userId, cache: _cache }) => {
return;
}
const namespace = 'pending_req';
const namespace = CacheKeys.PENDING_REQ;
const cache = _cache ?? getLogStores(namespace);
if (!cache) {
@@ -39,7 +40,7 @@ const clearPendingReq = async ({ userId, cache: _cache }) => {
const currentReq = +((await cache.get(key)) ?? 0);
if (currentReq && currentReq >= 1) {
await cache.set(key, currentReq - 1, ttl);
await cache.set(key, currentReq - 1, Time.ONE_MINUTE);
} else {
await cache.delete(key);
}

View File

@@ -1,4 +1,4 @@
const Keyv = require('keyv');
const { Keyv } = require('keyv');
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
const { logFile, violationFile } = require('./keyvFiles');
const { math, isEnabled } = require('~/server/utils');
@@ -19,7 +19,7 @@ const createViolationInstance = (namespace) => {
// Serve cache from memory so no need to clear it on startup/exit
const pending_req = isRedisEnabled
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: 'pending_req' });
: new Keyv({ namespace: CacheKeys.PENDING_REQ });
const config = isRedisEnabled
? new Keyv({ store: keyvRedis })
@@ -64,7 +64,7 @@ const abortKeys = isRedisEnabled
const namespaces = {
[CacheKeys.ROLES]: roles,
[CacheKeys.CONFIG_STORE]: config,
pending_req,
[CacheKeys.PENDING_REQ]: pending_req,
[ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }),
[CacheKeys.ENCODED_DOMAINS]: new Keyv({
store: keyvMongo,

92
api/cache/ioredisClient.js vendored Normal file
View File

@@ -0,0 +1,92 @@
const fs = require('fs');
const Redis = require('ioredis');
const { isEnabled } = require('~/server/utils');
const logger = require('~/config/winston');
const { REDIS_URI, USE_REDIS, USE_REDIS_CLUSTER, REDIS_CA, REDIS_MAX_LISTENERS } = process.env;
/** @type {import('ioredis').Redis | import('ioredis').Cluster} */
let ioredisClient;
const redis_max_listeners = Number(REDIS_MAX_LISTENERS) || 40;
function mapURI(uri) {
const regex =
/^(?:(?<scheme>\w+):\/\/)?(?:(?<user>[^:@]+)(?::(?<password>[^@]+))?@)?(?<host>[\w.-]+)(?::(?<port>\d{1,5}))?$/;
const match = uri.match(regex);
if (match) {
const { scheme, user, password, host, port } = match.groups;
return {
scheme: scheme || 'none',
user: user || null,
password: password || null,
host: host || null,
port: port || null,
};
} else {
const parts = uri.split(':');
if (parts.length === 2) {
return {
scheme: 'none',
user: null,
password: null,
host: parts[0],
port: parts[1],
};
}
return {
scheme: 'none',
user: null,
password: null,
host: uri,
port: null,
};
}
}
if (REDIS_URI && isEnabled(USE_REDIS)) {
let redisOptions = null;
if (REDIS_CA) {
const ca = fs.readFileSync(REDIS_CA);
redisOptions = { tls: { ca } };
}
if (isEnabled(USE_REDIS_CLUSTER)) {
const hosts = REDIS_URI.split(',').map((item) => {
var value = mapURI(item);
return {
host: value.host,
port: value.port,
};
});
ioredisClient = new Redis.Cluster(hosts, { redisOptions });
} else {
ioredisClient = new Redis(REDIS_URI, redisOptions);
}
ioredisClient.on('ready', () => {
logger.info('IoRedis connection ready');
});
ioredisClient.on('reconnecting', () => {
logger.info('IoRedis connection reconnecting');
});
ioredisClient.on('end', () => {
logger.info('IoRedis connection ended');
});
ioredisClient.on('close', () => {
logger.info('IoRedis connection closed');
});
ioredisClient.on('error', (err) => logger.error('IoRedis connection error:', err));
ioredisClient.setMaxListeners(redis_max_listeners);
logger.info(
'[Optional] IoRedis initialized for rate limiters. If you have issues, disable Redis or restart the server.',
);
} else {
logger.info('[Optional] IoRedis not initialized for rate limiters.');
}
module.exports = ioredisClient;

View File

@@ -1,11 +1,9 @@
const { KeyvFile } = require('keyv-file');
const logFile = new KeyvFile({ filename: './data/logs.json' });
const pendingReqFile = new KeyvFile({ filename: './data/pendingReqCache.json' });
const violationFile = new KeyvFile({ filename: './data/violations.json' });
const logFile = new KeyvFile({ filename: './data/logs.json' }).setMaxListeners(20);
const violationFile = new KeyvFile({ filename: './data/violations.json' }).setMaxListeners(20);
module.exports = {
logFile,
pendingReqFile,
violationFile,
};

269
api/cache/keyvMongo.js vendored
View File

@@ -1,9 +1,272 @@
const KeyvMongo = require('@keyv/mongo');
// api/cache/keyvMongo.js
const mongoose = require('mongoose');
const EventEmitter = require('events');
const { GridFSBucket } = require('mongodb');
const { logger } = require('~/config');
const { MONGO_URI } = process.env ?? {};
const storeMap = new Map();
class KeyvMongoCustom extends EventEmitter {
constructor(url, options = {}) {
super();
url = url || {};
if (typeof url === 'string') {
url = { url };
}
if (url.uri) {
url = { url: url.uri, ...url };
}
this.opts = {
url: 'mongodb://127.0.0.1:27017',
collection: 'keyv',
...url,
...options,
};
this.ttlSupport = false;
// Filter valid options
const keyvMongoKeys = new Set([
'url',
'collection',
'namespace',
'serialize',
'deserialize',
'uri',
'useGridFS',
'dialect',
]);
this.opts = Object.fromEntries(Object.entries(this.opts).filter(([k]) => keyvMongoKeys.has(k)));
}
// Helper to access the store WITHOUT storing a promise on the instance
_getClient() {
const storeKey = `${this.opts.collection}:${this.opts.useGridFS ? 'gridfs' : 'collection'}`;
// If we already have the store initialized, return it directly
if (storeMap.has(storeKey)) {
return Promise.resolve(storeMap.get(storeKey));
}
// Check mongoose connection state
if (mongoose.connection.readyState !== 1) {
return Promise.reject(
new Error('Mongoose connection not ready. Ensure connectDb() is called first.'),
);
}
try {
const db = mongoose.connection.db;
let client;
if (this.opts.useGridFS) {
const bucket = new GridFSBucket(db, {
readPreference: this.opts.readPreference,
bucketName: this.opts.collection,
});
const store = db.collection(`${this.opts.collection}.files`);
client = { bucket, store, db };
} else {
const collection = this.opts.collection || 'keyv';
const store = db.collection(collection);
client = { store, db };
}
storeMap.set(storeKey, client);
return Promise.resolve(client);
} catch (error) {
this.emit('error', error);
return Promise.reject(error);
}
}
async get(key) {
const client = await this._getClient();
if (this.opts.useGridFS) {
await client.store.updateOne(
{
filename: key,
},
{
$set: {
'metadata.lastAccessed': new Date(),
},
},
);
const stream = client.bucket.openDownloadStreamByName(key);
return new Promise((resolve) => {
const resp = [];
stream.on('error', () => {
resolve(undefined);
});
stream.on('end', () => {
const data = Buffer.concat(resp).toString('utf8');
resolve(data);
});
stream.on('data', (chunk) => {
resp.push(chunk);
});
});
}
const document = await client.store.findOne({ key: { $eq: key } });
if (!document) {
return undefined;
}
return document.value;
}
async getMany(keys) {
const client = await this._getClient();
if (this.opts.useGridFS) {
const promises = [];
for (const key of keys) {
promises.push(this.get(key));
}
const values = await Promise.allSettled(promises);
const data = [];
for (const value of values) {
data.push(value.value);
}
return data;
}
const values = await client.store
.find({ key: { $in: keys } })
.project({ _id: 0, value: 1, key: 1 })
.toArray();
const results = [...keys];
let i = 0;
for (const key of keys) {
const rowIndex = values.findIndex((row) => row.key === key);
results[i] = rowIndex > -1 ? values[rowIndex].value : undefined;
i++;
}
return results;
}
async set(key, value, ttl) {
const client = await this._getClient();
const expiresAt = typeof ttl === 'number' ? new Date(Date.now() + ttl) : null;
if (this.opts.useGridFS) {
const stream = client.bucket.openUploadStream(key, {
metadata: {
expiresAt,
lastAccessed: new Date(),
},
});
return new Promise((resolve) => {
stream.on('finish', () => {
resolve(stream);
});
stream.end(value);
});
}
await client.store.updateOne(
{ key: { $eq: key } },
{ $set: { key, value, expiresAt } },
{ upsert: true },
);
}
async delete(key) {
if (typeof key !== 'string') {
return false;
}
const client = await this._getClient();
if (this.opts.useGridFS) {
try {
const bucket = new GridFSBucket(client.db, {
bucketName: this.opts.collection,
});
const files = await bucket.find({ filename: key }).toArray();
await client.bucket.delete(files[0]._id);
return true;
} catch {
return false;
}
}
const object = await client.store.deleteOne({ key: { $eq: key } });
return object.deletedCount > 0;
}
async deleteMany(keys) {
const client = await this._getClient();
if (this.opts.useGridFS) {
const bucket = new GridFSBucket(client.db, {
bucketName: this.opts.collection,
});
const files = await bucket.find({ filename: { $in: keys } }).toArray();
if (files.length === 0) {
return false;
}
await Promise.all(files.map(async (file) => client.bucket.delete(file._id)));
return true;
}
const object = await client.store.deleteMany({ key: { $in: keys } });
return object.deletedCount > 0;
}
async clear() {
const client = await this._getClient();
if (this.opts.useGridFS) {
try {
await client.bucket.drop();
} catch (error) {
// Throw error if not "namespace not found" error
if (!(error.code === 26)) {
throw error;
}
}
}
await client.store.deleteMany({
key: { $regex: this.namespace ? `^${this.namespace}:*` : '' },
});
}
async has(key) {
const client = await this._getClient();
const filter = { [this.opts.useGridFS ? 'filename' : 'key']: { $eq: key } };
const document = await client.store.countDocuments(filter, { limit: 1 });
return document !== 0;
}
// No-op disconnect
async disconnect() {
// This is a no-op since we don't want to close the shared mongoose connection
return true;
}
}
const keyvMongo = new KeyvMongoCustom({
collection: 'logs',
});
const keyvMongo = new KeyvMongo(MONGO_URI, { collection: 'logs' });
keyvMongo.on('error', (err) => logger.error('KeyvMongo connection error:', err));
module.exports = keyvMongo;

View File

@@ -1,6 +1,6 @@
const fs = require('fs');
const ioredis = require('ioredis');
const KeyvRedis = require('@keyv/redis');
const KeyvRedis = require('@keyv/redis').default;
const { isEnabled } = require('~/server/utils');
const logger = require('~/config/winston');
@@ -50,6 +50,7 @@ function mapURI(uri) {
if (REDIS_URI && isEnabled(USE_REDIS)) {
let redisOptions = null;
/** @type {import('@keyv/redis').KeyvRedisOptions} */
let keyvOpts = {
useRedisSets: false,
keyPrefix: redis_prefix,
@@ -74,6 +75,25 @@ if (REDIS_URI && isEnabled(USE_REDIS)) {
} else {
keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts);
}
const pingInterval = setInterval(() => {
logger.debug('KeyvRedis ping');
keyvRedis.client.ping().catch(err => logger.error('Redis keep-alive ping failed:', err));
}, 5 * 60 * 1000);
keyvRedis.on('ready', () => {
logger.info('KeyvRedis connection ready');
});
keyvRedis.on('reconnecting', () => {
logger.info('KeyvRedis connection reconnecting');
});
keyvRedis.on('end', () => {
logger.info('KeyvRedis connection ended');
});
keyvRedis.on('close', () => {
clearInterval(pingInterval);
logger.info('KeyvRedis connection closed');
});
keyvRedis.on('error', (err) => logger.error('KeyvRedis connection error:', err));
keyvRedis.setMaxListeners(redis_max_listeners);
logger.info(

4
api/cache/redis.js vendored
View File

@@ -1,4 +0,0 @@
const Redis = require('ioredis');
const { REDIS_URI } = process.env ?? {};
const redis = new Redis.Cluster(REDIS_URI);
module.exports = redis;

View File

@@ -1,32 +1,35 @@
const axios = require('axios');
const { EventSource } = require('eventsource');
const { Time, CacheKeys } = require('librechat-data-provider');
const { MCPManager, FlowStateManager } = require('librechat-mcp');
const logger = require('./winston');
global.EventSource = EventSource;
/** @type {MCPManager} */
let mcpManager = null;
let flowManager = null;
/**
* @returns {Promise<MCPManager>}
* @param {string} [userId] - Optional user ID, to avoid disconnecting the current user.
* @returns {MCPManager}
*/
async function getMCPManager() {
function getMCPManager(userId) {
if (!mcpManager) {
const { MCPManager } = await import('librechat-mcp');
mcpManager = MCPManager.getInstance(logger);
} else {
mcpManager.checkIdleConnections(userId);
}
return mcpManager;
}
/**
* @param {(key: string) => Keyv} getLogStores
* @returns {Promise<FlowStateManager>}
* @param {Keyv} flowsCache
* @returns {FlowStateManager}
*/
async function getFlowStateManager(getLogStores) {
function getFlowStateManager(flowsCache) {
if (!flowManager) {
const { FlowStateManager } = await import('librechat-mcp');
flowManager = new FlowStateManager(getLogStores(CacheKeys.FLOWS), {
flowManager = new FlowStateManager(flowsCache, {
ttl: Time.ONE_MINUTE * 3,
logger,
});

View File

@@ -5,7 +5,6 @@ module.exports = {
coverageDirectory: 'coverage',
setupFiles: [
'./test/jestSetup.js',
'./test/__mocks__/KeyvMongo.js',
'./test/__mocks__/logger.js',
'./test/__mocks__/fetchEventSource.js',
],

View File

@@ -1,59 +0,0 @@
const mergeSort = require('./mergeSort');
const { cleanUpPrimaryKeyValue } = require('./misc');
function reduceMessages(hits) {
const counts = {};
for (const hit of hits) {
if (!counts[hit.conversationId]) {
counts[hit.conversationId] = 1;
} else {
counts[hit.conversationId]++;
}
}
const result = [];
for (const [conversationId, count] of Object.entries(counts)) {
result.push({
conversationId,
count,
});
}
return mergeSort(result, (a, b) => b.count - a.count);
}
function reduceHits(hits, titles = []) {
const counts = {};
const titleMap = {};
const convos = [...hits, ...titles];
for (const convo of convos) {
const currentId = cleanUpPrimaryKeyValue(convo.conversationId);
if (!counts[currentId]) {
counts[currentId] = 1;
} else {
counts[currentId]++;
}
if (convo.title) {
// titleMap[currentId] = convo._formatted.title;
titleMap[currentId] = convo.title;
}
}
const result = [];
for (const [conversationId, count] of Object.entries(counts)) {
result.push({
conversationId,
count,
title: titleMap[conversationId] ? titleMap[conversationId] : null,
});
}
return mergeSort(result, (a, b) => b.count - a.count);
}
module.exports = { reduceMessages, reduceHits };

View File

@@ -1,6 +1,8 @@
const mongoose = require('mongoose');
const { SystemRoles } = require('librechat-data-provider');
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
const { agentSchema } = require('@librechat/data-schemas');
const { SystemRoles, Tools } = 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;
const {
getProjectByName,
@@ -9,7 +11,6 @@ const {
removeAgentFromAllProjects,
} = require('./Project');
const getLogStores = require('~/cache/getLogStores');
const { agentSchema } = require('@librechat/data-schemas');
const Agent = mongoose.model('agent', agentSchema);
@@ -39,9 +40,61 @@ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter)
* @param {Object} params
* @param {ServerRequest} params.req
* @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.
*/
const loadEphemeralAgent = ({ req, agent_id, endpoint, model_parameters: _m }) => {
const { model, ...model_parameters } = _m;
/** @type {Record<string, FunctionTool>} */
const availableTools = req.app.locals.availableTools;
const mcpServers = new Set(req.body.ephemeralAgent?.mcp);
/** @type {string[]} */
const tools = [];
if (req.body.ephemeralAgent?.execute_code === true) {
tools.push(Tools.execute_code);
}
if (mcpServers.size > 0) {
for (const toolName of Object.keys(availableTools)) {
if (!toolName.includes(mcp_delimiter)) {
continue;
}
const mcpServer = toolName.split(mcp_delimiter)?.[1];
if (mcpServer && mcpServers.has(mcpServer)) {
tools.push(toolName);
}
}
}
const instructions = req.body.promptPrefix;
return {
id: agent_id,
instructions,
provider: endpoint,
model_parameters,
model,
tools,
};
};
/**
* Load an agent based on the provided ID
*
* @param {Object} params
* @param {ServerRequest} params.req
* @param {string} params.agent_id
* @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/
const loadAgent = async ({ req, agent_id }) => {
const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
if (!agent_id) {
return null;
}
if (agent_id === EPHEMERAL_AGENT_ID) {
return loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
}
const agent = await getAgent({
id: agent_id,
});
@@ -100,9 +153,11 @@ const updateAgent = async (searchParameter, updateData) => {
*/
const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => {
const searchParameter = { id: agent_id };
let agent = await getAgent(searchParameter);
if (!agent) {
throw new Error('Agent not found for adding resource file');
}
const fileIdsPath = `tool_resources.${tool_resource}.file_ids`;
await Agent.updateOne(
{
id: agent_id,
@@ -115,7 +170,12 @@ const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => {
},
);
const updateData = { $addToSet: { [fileIdsPath]: file_id } };
const updateData = {
$addToSet: {
tools: tool_resource,
[fileIdsPath]: file_id,
},
};
const updatedAgent = await updateAgent(searchParameter, updateData);
if (updatedAgent) {
@@ -126,16 +186,17 @@ const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => {
};
/**
* Removes multiple resource files from an agent in a single update.
* Removes multiple resource files from an agent using atomic operations.
* @param {object} params
* @param {string} params.agent_id
* @param {Array<{tool_resource: string, file_id: string}>} params.files
* @returns {Promise<Agent>} The updated agent.
* @throws {Error} If the agent is not found or update fails.
*/
const removeAgentResourceFiles = async ({ agent_id, files }) => {
const searchParameter = { id: agent_id };
// associate each tool resource with the respective file ids array
// Group files to remove by resource
const filesByResource = files.reduce((acc, { tool_resource, file_id }) => {
if (!acc[tool_resource]) {
acc[tool_resource] = [];
@@ -144,42 +205,35 @@ const removeAgentResourceFiles = async ({ agent_id, files }) => {
return acc;
}, {});
// build the update aggregation pipeline wich removes file ids from tool resources array
// and eventually deletes empty tool resources
const updateData = [];
Object.entries(filesByResource).forEach(([resource, fileIds]) => {
const toolResourcePath = `tool_resources.${resource}`;
const fileIdsPath = `${toolResourcePath}.file_ids`;
// file ids removal stage
updateData.push({
$set: {
[fileIdsPath]: {
$filter: {
input: `$${fileIdsPath}`,
cond: { $not: [{ $in: ['$$this', fileIds] }] },
},
},
},
});
// empty tool resource deletion stage
updateData.push({
$set: {
[toolResourcePath]: {
$cond: [{ $eq: [`$${fileIdsPath}`, []] }, '$$REMOVE', `$${toolResourcePath}`],
},
},
});
});
// return the updated agent or throw if no agent matches
const updatedAgent = await updateAgent(searchParameter, updateData);
if (updatedAgent) {
return updatedAgent;
} else {
throw new Error('Agent not found for removing resource files');
// Step 1: Atomically remove file IDs using $pull
const pullOps = {};
const resourcesToCheck = new Set();
for (const [resource, fileIds] of Object.entries(filesByResource)) {
const fileIdsPath = `tool_resources.${resource}.file_ids`;
pullOps[fileIdsPath] = { $in: fileIds };
resourcesToCheck.add(resource);
}
const updatePullData = { $pull: pullOps };
const agentAfterPull = await Agent.findOneAndUpdate(searchParameter, updatePullData, {
new: true,
}).lean();
if (!agentAfterPull) {
// Agent might have been deleted concurrently, or never existed.
// Check if it existed before trying to throw.
const agentExists = await getAgent(searchParameter);
if (!agentExists) {
throw new Error('Agent not found for removing resource files');
}
// If it existed but findOneAndUpdate returned null, something else went wrong.
throw new Error('Failed to update agent during file removal (pull step)');
}
// Return the agent state directly after the $pull operation.
// Skipping the $unset step for now to simplify and test core $pull atomicity.
// Empty arrays might remain, but the removal itself should be correct.
return agentAfterPull;
};
/**
@@ -254,7 +308,7 @@ const getListAgents = async (searchParameter) => {
* This function also updates the corresponding projects to include or exclude the agent ID.
*
* @param {Object} params - Parameters for updating the agent's projects.
* @param {import('librechat-data-provider').TUser} params.user - Parameters for updating the agent's projects.
* @param {MongoUser} params.user - Parameters for updating the agent's projects.
* @param {string} params.agentId - The ID of the agent to update.
* @param {string[]} [params.projectIds] - Array of project IDs to add to the agent.
* @param {string[]} [params.removeProjectIds] - Array of project IDs to remove from the agent.

View File

@@ -33,6 +33,50 @@ describe('Agent Resource File Operations', () => {
return agent;
};
test('should add tool_resource to tools if missing', async () => {
const agent = await createBasicAgent();
const fileId = uuidv4();
const toolResource = 'file_search';
const updatedAgent = await addAgentResourceFile({
agent_id: agent.id,
tool_resource: toolResource,
file_id: fileId,
});
expect(updatedAgent.tools).toContain(toolResource);
expect(Array.isArray(updatedAgent.tools)).toBe(true);
// Should not duplicate
const count = updatedAgent.tools.filter((t) => t === toolResource).length;
expect(count).toBe(1);
});
test('should not duplicate tool_resource in tools if already present', async () => {
const agent = await createBasicAgent();
const fileId1 = uuidv4();
const fileId2 = uuidv4();
const toolResource = 'file_search';
// First add
await addAgentResourceFile({
agent_id: agent.id,
tool_resource: toolResource,
file_id: fileId1,
});
// Second add (should not duplicate)
const updatedAgent = await addAgentResourceFile({
agent_id: agent.id,
tool_resource: toolResource,
file_id: fileId2,
});
expect(updatedAgent.tools).toContain(toolResource);
expect(Array.isArray(updatedAgent.tools)).toBe(true);
const count = updatedAgent.tools.filter((t) => t === toolResource).length;
expect(count).toBe(1);
});
test('should handle concurrent file additions', async () => {
const agent = await createBasicAgent();
const fileIds = Array.from({ length: 10 }, () => uuidv4());
@@ -157,4 +201,134 @@ describe('Agent Resource File Operations', () => {
expect(updatedAgent.tool_resources[tool].file_ids).toHaveLength(5);
});
});
test('should handle concurrent duplicate additions', async () => {
const agent = await createBasicAgent();
const fileId = uuidv4();
// Concurrent additions of the same file
const additionPromises = Array.from({ length: 5 }).map(() =>
addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: fileId,
}),
);
await Promise.all(additionPromises);
const updatedAgent = await Agent.findOne({ id: agent.id });
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
// Should only contain one instance of the fileId
expect(updatedAgent.tool_resources.test_tool.file_ids).toHaveLength(1);
expect(updatedAgent.tool_resources.test_tool.file_ids[0]).toBe(fileId);
});
test('should handle concurrent add and remove of the same file', async () => {
const agent = await createBasicAgent();
const fileId = uuidv4();
// First, ensure the file exists (or test might be trivial if remove runs first)
await addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: fileId,
});
// Concurrent add (which should be ignored) and remove
const operations = [
addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: fileId,
}),
removeAgentResourceFiles({
agent_id: agent.id,
files: [{ tool_resource: 'test_tool', file_id: fileId }],
}),
];
await Promise.all(operations);
const updatedAgent = await Agent.findOne({ id: agent.id });
// The final state should ideally be that the file is removed,
// but the key point is consistency (not duplicated or error state).
// Depending on execution order, the file might remain if the add operation's
// findOneAndUpdate runs after the remove operation completes.
// A more robust check might be that the length is <= 1.
// Given the remove uses an update pipeline, it might be more likely to win.
// The final state depends on race condition timing (add or remove might "win").
// The critical part is that the state is consistent (no duplicates, no errors).
// Assert that the fileId is either present exactly once or not present at all.
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
const finalFileIds = updatedAgent.tool_resources.test_tool.file_ids;
const count = finalFileIds.filter((id) => id === fileId).length;
expect(count).toBeLessThanOrEqual(1); // Should be 0 or 1, never more
// Optional: Check overall length is consistent with the count
if (count === 0) {
expect(finalFileIds).toHaveLength(0);
} else {
expect(finalFileIds).toHaveLength(1);
expect(finalFileIds[0]).toBe(fileId);
}
});
test('should handle concurrent duplicate removals', async () => {
const agent = await createBasicAgent();
const fileId = uuidv4();
// Add the file first
await addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: fileId,
});
// Concurrent removals of the same file
const removalPromises = Array.from({ length: 5 }).map(() =>
removeAgentResourceFiles({
agent_id: agent.id,
files: [{ tool_resource: 'test_tool', file_id: fileId }],
}),
);
await Promise.all(removalPromises);
const updatedAgent = await Agent.findOne({ id: agent.id });
// Check if the array is empty or the tool resource itself is removed
const fileIds = updatedAgent.tool_resources?.test_tool?.file_ids ?? [];
expect(fileIds).toHaveLength(0);
expect(fileIds).not.toContain(fileId);
});
test('should handle concurrent removals of different files', async () => {
const agent = await createBasicAgent();
const fileIds = Array.from({ length: 10 }, () => uuidv4());
// Add all files first
await Promise.all(
fileIds.map((fileId) =>
addAgentResourceFile({
agent_id: agent.id,
tool_resource: 'test_tool',
file_id: fileId,
}),
),
);
// Concurrently remove all files
const removalPromises = fileIds.map((fileId) =>
removeAgentResourceFiles({
agent_id: agent.id,
files: [{ tool_resource: 'test_tool', file_id: fileId }],
}),
);
await Promise.all(removalPromises);
const updatedAgent = await Agent.findOne({ id: agent.id });
// Check if the array is empty or the tool resource itself is removed
const finalFileIds = updatedAgent.tool_resources?.test_tool?.file_ids ?? [];
expect(finalFileIds).toHaveLength(0);
});
});

View File

@@ -88,11 +88,13 @@ module.exports = {
*/
saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => {
try {
if (metadata && metadata?.context) {
if (metadata?.context) {
logger.debug(`[saveConvo] ${metadata.context}`);
}
const messages = await getMessages({ conversationId }, '_id');
const update = { ...convo, messages, user: req.user.id };
if (newConversationId) {
update.conversationId = newConversationId;
}
@@ -148,75 +150,102 @@ module.exports = {
throw new Error('Failed to save conversations in bulk.');
}
},
getConvosByPage: async (user, pageNumber = 1, pageSize = 25, isArchived = false, tags) => {
const query = { user };
getConvosByCursor: async (
user,
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
) => {
const filters = [{ user }];
if (isArchived) {
query.isArchived = true;
filters.push({ isArchived: true });
} else {
query.$or = [{ isArchived: false }, { isArchived: { $exists: false } }];
}
if (Array.isArray(tags) && tags.length > 0) {
query.tags = { $in: tags };
filters.push({ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] });
}
query.$and = [{ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] }];
if (Array.isArray(tags) && tags.length > 0) {
filters.push({ tags: { $in: tags } });
}
filters.push({ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] });
if (search) {
try {
const meiliResults = await Conversation.meiliSearch(search);
const matchingIds = Array.isArray(meiliResults.hits)
? meiliResults.hits.map((result) => result.conversationId)
: [];
if (!matchingIds.length) {
return { conversations: [], nextCursor: null };
}
filters.push({ conversationId: { $in: matchingIds } });
} catch (error) {
logger.error('[getConvosByCursor] Error during meiliSearch', error);
return { message: 'Error during meiliSearch' };
}
}
if (cursor) {
filters.push({ updatedAt: { $lt: new Date(cursor) } });
}
const query = filters.length === 1 ? filters[0] : { $and: filters };
try {
const totalConvos = (await Conversation.countDocuments(query)) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);
const convos = await Conversation.find(query)
.sort({ updatedAt: -1 })
.skip((pageNumber - 1) * pageSize)
.limit(pageSize)
.select(
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
)
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
.limit(limit + 1)
.lean();
return { conversations: convos, pages: totalPages, pageNumber, pageSize };
let nextCursor = null;
if (convos.length > limit) {
const lastConvo = convos.pop();
nextCursor = lastConvo.updatedAt.toISOString();
}
return { conversations: convos, nextCursor };
} catch (error) {
logger.error('[getConvosByPage] Error getting conversations', error);
logger.error('[getConvosByCursor] Error getting conversations', error);
return { message: 'Error getting conversations' };
}
},
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 25) => {
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
try {
if (!convoIds || convoIds.length === 0) {
return { conversations: [], pages: 1, pageNumber, pageSize };
if (!convoIds?.length) {
return { conversations: [], nextCursor: null, convoMap: {} };
}
const conversationIds = convoIds.map((convo) => convo.conversationId);
const results = await Conversation.find({
user,
conversationId: { $in: conversationIds },
$or: [{ expiredAt: { $exists: false } }, { expiredAt: null }],
}).lean();
results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
let filtered = results;
if (cursor && cursor !== 'start') {
const cursorDate = new Date(cursor);
filtered = results.filter((convo) => new Date(convo.updatedAt) < cursorDate);
}
const limited = filtered.slice(0, limit + 1);
let nextCursor = null;
if (limited.length > limit) {
const lastConvo = limited.pop();
nextCursor = lastConvo.updatedAt.toISOString();
}
const cache = {};
const convoMap = {};
const promises = [];
convoIds.forEach((convo) =>
promises.push(
Conversation.findOne({
user,
conversationId: convo.conversationId,
$or: [{ expiredAt: { $exists: false } }, { expiredAt: null }],
}).lean(),
),
);
const results = (await Promise.all(promises)).filter(Boolean);
results.forEach((convo, i) => {
const page = Math.floor(i / pageSize) + 1;
if (!cache[page]) {
cache[page] = [];
}
cache[page].push(convo);
limited.forEach((convo) => {
convoMap[convo.conversationId] = convo;
});
const totalPages = Math.ceil(results.length / pageSize);
cache.pages = totalPages;
cache.pageSize = pageSize;
return {
cache,
conversations: cache[pageNumber] || [],
pages: totalPages || 1,
pageNumber,
pageSize,
convoMap,
};
return { conversations: limited, nextCursor, convoMap };
} catch (error) {
logger.error('[getConvosQueried] Error getting conversations', error);
return { message: 'Error fetching conversations' };
@@ -257,10 +286,26 @@ module.exports = {
* logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } }
*/
deleteConvos: async (user, filter) => {
let toRemove = await Conversation.find({ ...filter, user }).select('conversationId');
const ids = toRemove.map((instance) => instance.conversationId);
let deleteCount = await Conversation.deleteMany({ ...filter, user });
deleteCount.messages = await deleteMessages({ conversationId: { $in: ids } });
return deleteCount;
try {
const userFilter = { ...filter, user };
const conversations = await Conversation.find(userFilter).select('conversationId');
const conversationIds = conversations.map((c) => c.conversationId);
if (!conversationIds.length) {
throw new Error('Conversation not found or already deleted.');
}
const deleteConvoResult = await Conversation.deleteMany(userFilter);
const deleteMessagesResult = await deleteMessages({
conversationId: { $in: conversationIds },
});
return { ...deleteConvoResult, messages: deleteMessagesResult };
} catch (error) {
logger.error('[deleteConvos] Error deleting conversations and messages', error);
throw error;
}
},
};

View File

@@ -1,4 +1,5 @@
const mongoose = require('mongoose');
const { EToolResources } = require('librechat-data-provider');
const { fileSchema } = require('@librechat/data-schemas');
const { logger } = require('~/config');
@@ -8,7 +9,7 @@ const File = mongoose.model('File', fileSchema);
* Finds a file by its file_id with additional query options.
* @param {string} file_id - The unique identifier of the file.
* @param {object} options - Query options for filtering, projection, etc.
* @returns {Promise<IMongoFile>} A promise that resolves to the file document or null.
* @returns {Promise<MongoFile>} A promise that resolves to the file document or null.
*/
const findFileById = async (file_id, options = {}) => {
return await File.findOne({ file_id, ...options }).lean();
@@ -20,7 +21,7 @@ const findFileById = async (file_id, options = {}) => {
* @param {Object} [_sortOptions] - Optional sort parameters.
* @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results.
* Default excludes the 'text' field.
* @returns {Promise<Array<IMongoFile>>} A promise that resolves to an array of file documents.
* @returns {Promise<Array<MongoFile>>} A promise that resolves to an array of file documents.
*/
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
const sortOptions = { updatedAt: -1, ..._sortOptions };
@@ -30,9 +31,10 @@ const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
/**
* Retrieves tool files (files that are embedded or have a fileIdentifier) from an array of file IDs
* @param {string[]} fileIds - Array of file_id strings to search for
* @returns {Promise<Array<IMongoFile>>} Files that match the criteria
* @param {Set<EToolResources>} toolResourceSet - Optional filter for tool resources
* @returns {Promise<Array<MongoFile>>} Files that match the criteria
*/
const getToolFilesByIds = async (fileIds) => {
const getToolFilesByIds = async (fileIds, toolResourceSet) => {
if (!fileIds || !fileIds.length) {
return [];
}
@@ -40,9 +42,19 @@ const getToolFilesByIds = async (fileIds) => {
try {
const filter = {
file_id: { $in: fileIds },
$or: [{ embedded: true }, { 'metadata.fileIdentifier': { $exists: true } }],
};
if (toolResourceSet.size) {
filter.$or = [];
}
if (toolResourceSet.has(EToolResources.file_search)) {
filter.$or.push({ embedded: true });
}
if (toolResourceSet.has(EToolResources.execute_code)) {
filter.$or.push({ 'metadata.fileIdentifier': { $exists: true } });
}
const selectFields = { text: 0 };
const sortOptions = { updatedAt: -1 };
@@ -55,9 +67,9 @@ const getToolFilesByIds = async (fileIds) => {
/**
* Creates a new file with a TTL of 1 hour.
* @param {IMongoFile} data - The file data to be created, must contain file_id.
* @param {MongoFile} data - The file data to be created, must contain file_id.
* @param {boolean} disableTTL - Whether to disable the TTL.
* @returns {Promise<IMongoFile>} A promise that resolves to the created file document.
* @returns {Promise<MongoFile>} A promise that resolves to the created file document.
*/
const createFile = async (data, disableTTL) => {
const fileData = {
@@ -77,8 +89,8 @@ const createFile = async (data, disableTTL) => {
/**
* Updates a file identified by file_id with new data and removes the TTL.
* @param {IMongoFile} data - The data to update, must contain file_id.
* @returns {Promise<IMongoFile>} A promise that resolves to the updated file document.
* @param {MongoFile} data - The data to update, must contain file_id.
* @returns {Promise<MongoFile>} A promise that resolves to the updated file document.
*/
const updateFile = async (data) => {
const { file_id, ...update } = data;
@@ -91,8 +103,8 @@ const updateFile = async (data) => {
/**
* Increments the usage of a file identified by file_id.
* @param {IMongoFile} data - The data to update, must contain file_id and the increment value for usage.
* @returns {Promise<IMongoFile>} A promise that resolves to the updated file document.
* @param {MongoFile} data - The data to update, must contain file_id and the increment value for usage.
* @returns {Promise<MongoFile>} A promise that resolves to the updated file document.
*/
const updateFileUsage = async (data) => {
const { file_id, inc = 1 } = data;
@@ -106,7 +118,7 @@ const updateFileUsage = async (data) => {
/**
* Deletes a file identified by file_id.
* @param {string} file_id - The unique identifier of the file to delete.
* @returns {Promise<IMongoFile>} A promise that resolves to the deleted file document or null.
* @returns {Promise<MongoFile>} A promise that resolves to the deleted file document or null.
*/
const deleteFile = async (file_id) => {
return await File.findOneAndDelete({ file_id }).lean();
@@ -115,7 +127,7 @@ const deleteFile = async (file_id) => {
/**
* Deletes a file identified by a filter.
* @param {object} filter - The filter criteria to apply.
* @returns {Promise<IMongoFile>} A promise that resolves to the deleted file document or null.
* @returns {Promise<MongoFile>} A promise that resolves to the deleted file document or null.
*/
const deleteFileByFilter = async (filter) => {
return await File.findOneAndDelete(filter).lean();

View File

@@ -61,6 +61,14 @@ async function saveMessage(req, params, metadata) {
update.expiredAt = null;
}
if (update.tokenCount != null && isNaN(update.tokenCount)) {
logger.warn(
`Resetting invalid \`tokenCount\` for message \`${params.messageId}\`: ${update.tokenCount}`,
);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
update.tokenCount = 0;
}
const message = await Message.findOneAndUpdate(
{ messageId: params.messageId, user: req.user.id },
update,
@@ -97,7 +105,9 @@ async function saveMessage(req, params, metadata) {
};
} catch (findError) {
// If the findOne also fails, log it but don't crash
logger.warn(`Could not retrieve existing message with ID ${params.messageId}: ${findError.message}`);
logger.warn(
`Could not retrieve existing message with ID ${params.messageId}: ${findError.message}`,
);
return {
...params,
messageId: params.messageId,

View File

@@ -4,13 +4,8 @@ const {
SystemRoles,
roleDefaults,
PermissionTypes,
permissionsSchema,
removeNullishValues,
agentPermissionsSchema,
promptPermissionsSchema,
runCodePermissionsSchema,
bookmarkPermissionsSchema,
multiConvoPermissionsSchema,
temporaryChatPermissionsSchema,
} = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const { roleSchema } = require('@librechat/data-schemas');
@@ -20,15 +15,16 @@ const Role = mongoose.model('Role', roleSchema);
/**
* Retrieve a role by name and convert the found role document to a plain object.
* If the role with the given name doesn't exist and the name is a system defined role, create it and return the lean version.
* If the role with the given name doesn't exist and the name is a system defined role,
* create it and return the lean version.
*
* @param {string} roleName - The name of the role to find or create.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<Object>} A plain object representing the role document.
*/
const getRoleByName = async function (roleName, fieldsToSelect = null) {
const cache = getLogStores(CacheKeys.ROLES);
try {
const cache = getLogStores(CacheKeys.ROLES);
const cachedRole = await cache.get(roleName);
if (cachedRole) {
return cachedRole;
@@ -40,8 +36,7 @@ const getRoleByName = async function (roleName, fieldsToSelect = null) {
let role = await query.lean().exec();
if (!role && SystemRoles[roleName]) {
role = roleDefaults[roleName];
role = await new Role(role).save();
role = await new Role(roleDefaults[roleName]).save();
await cache.set(roleName, role);
return role.toObject();
}
@@ -60,8 +55,8 @@ const getRoleByName = async function (roleName, fieldsToSelect = null) {
* @returns {Promise<TRole>} Updated role document.
*/
const updateRoleByName = async function (roleName, updates) {
const cache = getLogStores(CacheKeys.ROLES);
try {
const cache = getLogStores(CacheKeys.ROLES);
const role = await Role.findOneAndUpdate(
{ name: roleName },
{ $set: updates },
@@ -77,29 +72,20 @@ const updateRoleByName = async function (roleName, updates) {
}
};
const permissionSchemas = {
[PermissionTypes.AGENTS]: agentPermissionsSchema,
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema,
[PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema,
[PermissionTypes.TEMPORARY_CHAT]: temporaryChatPermissionsSchema,
[PermissionTypes.RUN_CODE]: runCodePermissionsSchema,
};
/**
* Updates access permissions for a specific role and multiple permission types.
* @param {SystemRoles} roleName - The role to update.
* @param {string} roleName - The role to update.
* @param {Object.<PermissionTypes, Object.<Permissions, boolean>>} permissionsUpdate - Permissions to update and their values.
*/
async function updateAccessPermissions(roleName, permissionsUpdate) {
// Filter and clean the permission updates based on our schema definition.
const updates = {};
for (const [permissionType, permissions] of Object.entries(permissionsUpdate)) {
if (permissionSchemas[permissionType]) {
if (permissionsSchema.shape && permissionsSchema.shape[permissionType]) {
updates[permissionType] = removeNullishValues(permissions);
}
}
if (Object.keys(updates).length === 0) {
if (!Object.keys(updates).length) {
return;
}
@@ -109,26 +95,75 @@ async function updateAccessPermissions(roleName, permissionsUpdate) {
return;
}
const updatedPermissions = {};
const currentPermissions = role.permissions || {};
const updatedPermissions = { ...currentPermissions };
let hasChanges = false;
const unsetFields = {};
const permissionTypes = Object.keys(permissionsSchema.shape || {});
for (const permType of permissionTypes) {
if (role[permType] && typeof role[permType] === 'object') {
logger.info(
`Migrating '${roleName}' role from old schema: found '${permType}' at top level`,
);
updatedPermissions[permType] = {
...updatedPermissions[permType],
...role[permType],
};
unsetFields[permType] = 1;
hasChanges = true;
}
}
// Process the current updates
for (const [permissionType, permissions] of Object.entries(updates)) {
const currentPermissions = role[permissionType] || {};
updatedPermissions[permissionType] = { ...currentPermissions };
const currentTypePermissions = currentPermissions[permissionType] || {};
updatedPermissions[permissionType] = { ...currentTypePermissions };
for (const [permission, value] of Object.entries(permissions)) {
if (currentPermissions[permission] !== value) {
if (currentTypePermissions[permission] !== value) {
updatedPermissions[permissionType][permission] = value;
hasChanges = true;
logger.info(
`Updating '${roleName}' role ${permissionType} '${permission}' permission from ${currentPermissions[permission]} to: ${value}`,
`Updating '${roleName}' role permission '${permissionType}' '${permission}' from ${currentTypePermissions[permission]} to: ${value}`,
);
}
}
}
if (hasChanges) {
await updateRoleByName(roleName, updatedPermissions);
const updateObj = { permissions: updatedPermissions };
if (Object.keys(unsetFields).length > 0) {
logger.info(
`Unsetting old schema fields for '${roleName}' role: ${Object.keys(unsetFields).join(', ')}`,
);
try {
await Role.updateOne(
{ name: roleName },
{
$set: updateObj,
$unset: unsetFields,
},
);
const cache = getLogStores(CacheKeys.ROLES);
const updatedRole = await Role.findOne({ name: roleName }).select('-__v').lean().exec();
await cache.set(roleName, updatedRole);
logger.info(`Updated role '${roleName}' and removed old schema fields`);
} catch (updateError) {
logger.error(`Error during role migration update: ${updateError.message}`);
throw updateError;
}
} else {
// Standard update if no migration needed
await updateRoleByName(roleName, updateObj);
}
logger.info(`Updated '${roleName}' role permissions`);
} else {
logger.info(`No changes needed for '${roleName}' role permissions`);
@@ -146,34 +181,111 @@ async function updateAccessPermissions(roleName, permissionsUpdate) {
* @returns {Promise<void>}
*/
const initializeRoles = async function () {
const defaultRoles = [SystemRoles.ADMIN, SystemRoles.USER];
for (const roleName of defaultRoles) {
for (const roleName of [SystemRoles.ADMIN, SystemRoles.USER]) {
let role = await Role.findOne({ name: roleName });
const defaultPerms = roleDefaults[roleName].permissions;
if (!role) {
// Create new role if it doesn't exist
// Create new role if it doesn't exist.
role = new Role(roleDefaults[roleName]);
} else {
// Add missing permission types
let isUpdated = false;
for (const permType of Object.values(PermissionTypes)) {
if (!role[permType]) {
role[permType] = roleDefaults[roleName][permType];
isUpdated = true;
// Ensure role.permissions is defined.
role.permissions = role.permissions || {};
// For each permission type in defaults, add it if missing.
for (const permType of Object.keys(defaultPerms)) {
if (role.permissions[permType] == null) {
role.permissions[permType] = defaultPerms[permType];
}
}
if (isUpdated) {
await role.save();
}
}
await role.save();
}
};
/**
* Migrates roles from old schema to new schema structure.
* This can be called directly to fix existing roles.
*
* @param {string} [roleName] - Optional specific role to migrate. If not provided, migrates all roles.
* @returns {Promise<number>} Number of roles migrated.
*/
const migrateRoleSchema = async function (roleName) {
try {
// Get roles to migrate
let roles;
if (roleName) {
const role = await Role.findOne({ name: roleName });
roles = role ? [role] : [];
} else {
roles = await Role.find({});
}
logger.info(`Migrating ${roles.length} roles to new schema structure`);
let migratedCount = 0;
for (const role of roles) {
const permissionTypes = Object.keys(permissionsSchema.shape || {});
const unsetFields = {};
let hasOldSchema = false;
// Check for old schema fields
for (const permType of permissionTypes) {
if (role[permType] && typeof role[permType] === 'object') {
hasOldSchema = true;
// Ensure permissions object exists
role.permissions = role.permissions || {};
// Migrate permissions from old location to new
role.permissions[permType] = {
...role.permissions[permType],
...role[permType],
};
// Mark field for removal
unsetFields[permType] = 1;
}
}
if (hasOldSchema) {
try {
logger.info(`Migrating role '${role.name}' from old schema structure`);
// Simple update operation
await Role.updateOne(
{ _id: role._id },
{
$set: { permissions: role.permissions },
$unset: unsetFields,
},
);
// Refresh cache
const cache = getLogStores(CacheKeys.ROLES);
const updatedRole = await Role.findById(role._id).lean().exec();
await cache.set(role.name, updatedRole);
migratedCount++;
logger.info(`Migrated role '${role.name}'`);
} catch (error) {
logger.error(`Failed to migrate role '${role.name}': ${error.message}`);
}
}
}
logger.info(`Migration complete: ${migratedCount} roles migrated`);
return migratedCount;
} catch (error) {
logger.error(`Role schema migration failed: ${error.message}`);
throw error;
}
};
module.exports = {
Role,
getRoleByName,
initializeRoles,
updateRoleByName,
updateAccessPermissions,
migrateRoleSchema,
};

View File

@@ -2,22 +2,21 @@ const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
SystemRoles,
PermissionTypes,
roleDefaults,
Permissions,
roleDefaults,
PermissionTypes,
} = require('librechat-data-provider');
const { updateAccessPermissions, initializeRoles } = require('~/models/Role');
const { Role, getRoleByName, updateAccessPermissions, initializeRoles } = require('~/models/Role');
const getLogStores = require('~/cache/getLogStores');
const { Role } = require('~/models/Role');
// Mock the cache
jest.mock('~/cache/getLogStores', () => {
return jest.fn().mockReturnValue({
jest.mock('~/cache/getLogStores', () =>
jest.fn().mockReturnValue({
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
});
});
}),
);
let mongoServer;
@@ -41,10 +40,12 @@ describe('updateAccessPermissions', () => {
it('should update permissions when changes are needed', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
permissions: {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
},
}).save();
@@ -56,8 +57,8 @@ describe('updateAccessPermissions', () => {
},
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: true,
@@ -67,10 +68,12 @@ describe('updateAccessPermissions', () => {
it('should not update permissions when no changes are needed', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
permissions: {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
},
}).save();
@@ -82,8 +85,8 @@ describe('updateAccessPermissions', () => {
},
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
@@ -92,11 +95,8 @@ describe('updateAccessPermissions', () => {
it('should handle non-existent roles', async () => {
await updateAccessPermissions('NON_EXISTENT_ROLE', {
[PermissionTypes.PROMPTS]: {
CREATE: true,
},
[PermissionTypes.PROMPTS]: { CREATE: true },
});
const role = await Role.findOne({ name: 'NON_EXISTENT_ROLE' });
expect(role).toBeNull();
});
@@ -104,21 +104,21 @@ describe('updateAccessPermissions', () => {
it('should update only specified permissions', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
permissions: {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: {
SHARED_GLOBAL: true,
},
[PermissionTypes.PROMPTS]: { SHARED_GLOBAL: true },
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: true,
@@ -128,21 +128,21 @@ describe('updateAccessPermissions', () => {
it('should handle partial updates', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
permissions: {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: {
USE: false,
},
[PermissionTypes.PROMPTS]: { USE: false },
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARED_GLOBAL: false,
@@ -152,13 +152,9 @@ describe('updateAccessPermissions', () => {
it('should update multiple permission types at once', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
[PermissionTypes.BOOKMARKS]: {
USE: true,
permissions: {
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
[PermissionTypes.BOOKMARKS]: { USE: true },
},
}).save();
@@ -167,24 +163,20 @@ describe('updateAccessPermissions', () => {
[PermissionTypes.BOOKMARKS]: { USE: false },
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARED_GLOBAL: true,
});
expect(updatedRole[PermissionTypes.BOOKMARKS]).toEqual({
USE: false,
});
expect(updatedRole.permissions[PermissionTypes.BOOKMARKS]).toEqual({ USE: false });
});
it('should handle updates for a single permission type', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
permissions: {
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
},
}).save();
@@ -192,8 +184,8 @@ describe('updateAccessPermissions', () => {
[PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARED_GLOBAL: true,
@@ -203,33 +195,25 @@ describe('updateAccessPermissions', () => {
it('should update MULTI_CONVO permissions', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.MULTI_CONVO]: {
USE: false,
permissions: {
[PermissionTypes.MULTI_CONVO]: { USE: false },
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: {
USE: true,
},
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.MULTI_CONVO]).toEqual({
USE: true,
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
});
it('should update MULTI_CONVO permissions along with other permission types', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
[PermissionTypes.MULTI_CONVO]: {
USE: false,
permissions: {
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false },
[PermissionTypes.MULTI_CONVO]: { USE: false },
},
}).save();
@@ -238,35 +222,29 @@ describe('updateAccessPermissions', () => {
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: true,
});
expect(updatedRole[PermissionTypes.MULTI_CONVO]).toEqual({
USE: true,
});
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
});
it('should not update MULTI_CONVO permissions when no changes are needed', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.MULTI_CONVO]: {
USE: true,
permissions: {
[PermissionTypes.MULTI_CONVO]: { USE: true },
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: {
USE: true,
},
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.MULTI_CONVO]).toEqual({
USE: true,
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
});
});
@@ -278,65 +256,69 @@ describe('initializeRoles', () => {
it('should create default roles if they do not exist', async () => {
await initializeRoles();
const adminRole = await Role.findOne({ name: SystemRoles.ADMIN }).lean();
const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();
const adminRole = await getRoleByName(SystemRoles.ADMIN);
const userRole = await getRoleByName(SystemRoles.USER);
expect(adminRole).toBeTruthy();
expect(userRole).toBeTruthy();
// Check if all permission types exist
// Check if all permission types exist in the permissions field
Object.values(PermissionTypes).forEach((permType) => {
expect(adminRole[permType]).toBeDefined();
expect(userRole[permType]).toBeDefined();
expect(adminRole.permissions[permType]).toBeDefined();
expect(userRole.permissions[permType]).toBeDefined();
});
// Check if permissions match defaults (example for ADMIN role)
expect(adminRole[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBe(true);
expect(adminRole[PermissionTypes.BOOKMARKS].USE).toBe(true);
expect(adminRole[PermissionTypes.AGENTS].CREATE).toBe(true);
// Example: Check default values for ADMIN role
expect(adminRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBe(true);
expect(adminRole.permissions[PermissionTypes.BOOKMARKS].USE).toBe(true);
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBe(true);
});
it('should not modify existing permissions for existing roles', async () => {
const customUserRole = {
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: true,
[Permissions.SHARED_GLOBAL]: true,
},
[PermissionTypes.BOOKMARKS]: {
[Permissions.USE]: false,
permissions: {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: true,
[Permissions.SHARED_GLOBAL]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
},
};
await new Role(customUserRole).save();
await initializeRoles();
const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(userRole[PermissionTypes.PROMPTS]).toEqual(customUserRole[PermissionTypes.PROMPTS]);
expect(userRole[PermissionTypes.BOOKMARKS]).toEqual(customUserRole[PermissionTypes.BOOKMARKS]);
expect(userRole[PermissionTypes.AGENTS]).toBeDefined();
const userRole = await getRoleByName(SystemRoles.USER);
expect(userRole.permissions[PermissionTypes.PROMPTS]).toEqual(
customUserRole.permissions[PermissionTypes.PROMPTS],
);
expect(userRole.permissions[PermissionTypes.BOOKMARKS]).toEqual(
customUserRole.permissions[PermissionTypes.BOOKMARKS],
);
expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
});
it('should add new permission types to existing roles', async () => {
const partialUserRole = {
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: roleDefaults[SystemRoles.USER][PermissionTypes.PROMPTS],
[PermissionTypes.BOOKMARKS]: roleDefaults[SystemRoles.USER][PermissionTypes.BOOKMARKS],
permissions: {
[PermissionTypes.PROMPTS]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS],
[PermissionTypes.BOOKMARKS]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.BOOKMARKS],
},
};
await new Role(partialUserRole).save();
await initializeRoles();
const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(userRole[PermissionTypes.AGENTS]).toBeDefined();
expect(userRole[PermissionTypes.AGENTS].CREATE).toBeDefined();
expect(userRole[PermissionTypes.AGENTS].USE).toBeDefined();
expect(userRole[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
const userRole = await getRoleByName(SystemRoles.USER);
expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
expect(userRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
expect(userRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
expect(userRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
});
it('should handle multiple runs without duplicating or modifying data', async () => {
@@ -349,72 +331,73 @@ describe('initializeRoles', () => {
expect(adminRoles).toHaveLength(1);
expect(userRoles).toHaveLength(1);
const adminRole = adminRoles[0].toObject();
const userRole = userRoles[0].toObject();
// Check if all permission types exist
const adminPerms = adminRoles[0].toObject().permissions;
const userPerms = userRoles[0].toObject().permissions;
Object.values(PermissionTypes).forEach((permType) => {
expect(adminRole[permType]).toBeDefined();
expect(userRole[permType]).toBeDefined();
expect(adminPerms[permType]).toBeDefined();
expect(userPerms[permType]).toBeDefined();
});
});
it('should update roles with missing permission types from roleDefaults', async () => {
const partialAdminRole = {
name: SystemRoles.ADMIN,
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
permissions: {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
[PermissionTypes.BOOKMARKS]:
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.BOOKMARKS],
},
[PermissionTypes.BOOKMARKS]: roleDefaults[SystemRoles.ADMIN][PermissionTypes.BOOKMARKS],
};
await new Role(partialAdminRole).save();
await initializeRoles();
const adminRole = await Role.findOne({ name: SystemRoles.ADMIN }).lean();
expect(adminRole[PermissionTypes.PROMPTS]).toEqual(partialAdminRole[PermissionTypes.PROMPTS]);
expect(adminRole[PermissionTypes.AGENTS]).toBeDefined();
expect(adminRole[PermissionTypes.AGENTS].CREATE).toBeDefined();
expect(adminRole[PermissionTypes.AGENTS].USE).toBeDefined();
expect(adminRole[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
const adminRole = await getRoleByName(SystemRoles.ADMIN);
expect(adminRole.permissions[PermissionTypes.PROMPTS]).toEqual(
partialAdminRole.permissions[PermissionTypes.PROMPTS],
);
expect(adminRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined();
expect(adminRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined();
expect(adminRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined();
});
it('should include MULTI_CONVO permissions when creating default roles', async () => {
await initializeRoles();
const adminRole = await Role.findOne({ name: SystemRoles.ADMIN }).lean();
const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();
const adminRole = await getRoleByName(SystemRoles.ADMIN);
const userRole = await getRoleByName(SystemRoles.USER);
expect(adminRole[PermissionTypes.MULTI_CONVO]).toBeDefined();
expect(userRole[PermissionTypes.MULTI_CONVO]).toBeDefined();
// Check if MULTI_CONVO permissions match defaults
expect(adminRole[PermissionTypes.MULTI_CONVO].USE).toBe(
roleDefaults[SystemRoles.ADMIN][PermissionTypes.MULTI_CONVO].USE,
expect(adminRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
expect(userRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
expect(adminRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.MULTI_CONVO].USE,
);
expect(userRole[PermissionTypes.MULTI_CONVO].USE).toBe(
roleDefaults[SystemRoles.USER][PermissionTypes.MULTI_CONVO].USE,
expect(userRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.MULTI_CONVO].USE,
);
});
it('should add MULTI_CONVO permissions to existing roles without them', async () => {
const partialUserRole = {
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: roleDefaults[SystemRoles.USER][PermissionTypes.PROMPTS],
[PermissionTypes.BOOKMARKS]: roleDefaults[SystemRoles.USER][PermissionTypes.BOOKMARKS],
permissions: {
[PermissionTypes.PROMPTS]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS],
[PermissionTypes.BOOKMARKS]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.BOOKMARKS],
},
};
await new Role(partialUserRole).save();
await initializeRoles();
const userRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(userRole[PermissionTypes.MULTI_CONVO]).toBeDefined();
expect(userRole[PermissionTypes.MULTI_CONVO].USE).toBeDefined();
const userRole = await getRoleByName(SystemRoles.USER);
expect(userRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
expect(userRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBeDefined();
});
});

View File

@@ -52,6 +52,14 @@ function anonymizeMessages(messages, newConvoId) {
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,
@@ -61,6 +69,7 @@ function anonymizeMessages(messages, newConvoId) {
model: message.model?.startsWith('asst_')
? anonymizeAssistantId(message.model)
: message.model,
attachments: anonymizedAttachments,
};
});
}

View File

@@ -76,10 +76,15 @@ const tokenValues = Object.assign(
'4k': { prompt: 1.5, completion: 2 },
'16k': { prompt: 3, completion: 4 },
'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 },
'o1-mini': { prompt: 1.1, completion: 4.4 },
'o1-preview': { prompt: 15, completion: 60 },
o1: { prompt: 15, completion: 60 },
'gpt-4.1-nano': { prompt: 0.1, completion: 0.4 },
'gpt-4.1-mini': { prompt: 0.4, completion: 1.6 },
'gpt-4.1': { prompt: 2, completion: 8 },
'gpt-4.5': { prompt: 75, completion: 150 },
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-4o': { prompt: 2.5, completion: 10 },
@@ -106,9 +111,15 @@ const tokenValues = Object.assign(
/* cohere doesn't have rates for the older command models,
so this was from https://artificialanalysis.ai/models/command-light/providers */
command: { prompt: 0.38, completion: 0.38 },
gemma: { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemma-2': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemma-3': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemma-3-27b': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 },
'gemini-2.0-flash': { prompt: 0.1, completion: 0.7 },
'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 },
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
'gemini-2.5-flash': { prompt: 0.15, completion: 3.5 },
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
@@ -122,6 +133,10 @@ const tokenValues = Object.assign(
'grok-2-1212': { prompt: 2.0, completion: 10.0 },
'grok-2-latest': { prompt: 2.0, completion: 10.0 },
'grok-2': { prompt: 2.0, completion: 10.0 },
'grok-3-mini-fast': { prompt: 0.4, completion: 4 },
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
'grok-3-fast': { prompt: 5.0, completion: 25.0 },
'grok-3': { prompt: 3.0, completion: 15.0 },
'grok-beta': { prompt: 5.0, completion: 15.0 },
'mistral-large': { prompt: 2.0, completion: 6.0 },
'pixtral-large': { prompt: 2.0, completion: 6.0 },
@@ -170,6 +185,14 @@ const getValueKey = (model, endpoint) => {
return 'gpt-3.5-turbo-1106';
} else if (modelName.includes('gpt-3.5')) {
return '4k';
} else if (modelName.includes('o4-mini')) {
return 'o4-mini';
} else if (modelName.includes('o4')) {
return 'o4';
} else if (modelName.includes('o3-mini')) {
return 'o3-mini';
} else if (modelName.includes('o3')) {
return 'o3';
} else if (modelName.includes('o1-preview')) {
return 'o1-preview';
} else if (modelName.includes('o1-mini')) {
@@ -178,6 +201,12 @@ const getValueKey = (model, endpoint) => {
return 'o1';
} else if (modelName.includes('gpt-4.5')) {
return 'gpt-4.5';
} else if (modelName.includes('gpt-4.1-nano')) {
return 'gpt-4.1-nano';
} else if (modelName.includes('gpt-4.1-mini')) {
return 'gpt-4.1-mini';
} else if (modelName.includes('gpt-4.1')) {
return 'gpt-4.1';
} else if (modelName.includes('gpt-4o-2024-05-13')) {
return 'gpt-4o-2024-05-13';
} else if (modelName.includes('gpt-4o-mini')) {

View File

@@ -60,6 +60,30 @@ describe('getValueKey', () => {
expect(getValueKey('gpt-4.5-0125')).toBe('gpt-4.5');
});
it('should return "gpt-4.1" for model type of "gpt-4.1"', () => {
expect(getValueKey('gpt-4.1-preview')).toBe('gpt-4.1');
expect(getValueKey('gpt-4.1-2024-08-06')).toBe('gpt-4.1');
expect(getValueKey('gpt-4.1-2024-08-06-0718')).toBe('gpt-4.1');
expect(getValueKey('openai/gpt-4.1')).toBe('gpt-4.1');
expect(getValueKey('openai/gpt-4.1-2024-08-06')).toBe('gpt-4.1');
expect(getValueKey('gpt-4.1-turbo')).toBe('gpt-4.1');
expect(getValueKey('gpt-4.1-0125')).toBe('gpt-4.1');
});
it('should return "gpt-4.1-mini" for model type of "gpt-4.1-mini"', () => {
expect(getValueKey('gpt-4.1-mini-preview')).toBe('gpt-4.1-mini');
expect(getValueKey('gpt-4.1-mini-2024-08-06')).toBe('gpt-4.1-mini');
expect(getValueKey('openai/gpt-4.1-mini')).toBe('gpt-4.1-mini');
expect(getValueKey('gpt-4.1-mini-0125')).toBe('gpt-4.1-mini');
});
it('should return "gpt-4.1-nano" for model type of "gpt-4.1-nano"', () => {
expect(getValueKey('gpt-4.1-nano-preview')).toBe('gpt-4.1-nano');
expect(getValueKey('gpt-4.1-nano-2024-08-06')).toBe('gpt-4.1-nano');
expect(getValueKey('openai/gpt-4.1-nano')).toBe('gpt-4.1-nano');
expect(getValueKey('gpt-4.1-nano-0125')).toBe('gpt-4.1-nano');
});
it('should return "gpt-4o" for model type of "gpt-4o"', () => {
expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o');
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o');
@@ -141,6 +165,15 @@ describe('getMultiplier', () => {
);
});
it('should return correct multipliers for o4-mini and o3', () => {
['o4-mini', 'o3'].forEach((model) => {
const prompt = getMultiplier({ model, tokenType: 'prompt' });
const completion = getMultiplier({ model, tokenType: 'completion' });
expect(prompt).toBe(tokenValues[model].prompt);
expect(completion).toBe(tokenValues[model].completion);
});
});
it('should return defaultRate if tokenType is provided but not found in tokenValues', () => {
expect(getMultiplier({ valueKey: '8k', tokenType: 'unknownType' })).toBe(defaultRate);
});
@@ -185,6 +218,52 @@ describe('getMultiplier', () => {
);
});
it('should return the correct multiplier for gpt-4.1', () => {
const valueKey = getValueKey('gpt-4.1-2024-08-06');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4.1'].prompt);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-4.1'].completion,
);
expect(getMultiplier({ model: 'gpt-4.1-preview', tokenType: 'prompt' })).toBe(
tokenValues['gpt-4.1'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-4.1', tokenType: 'completion' })).toBe(
tokenValues['gpt-4.1'].completion,
);
});
it('should return the correct multiplier for gpt-4.1-mini', () => {
const valueKey = getValueKey('gpt-4.1-mini-2024-08-06');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(
tokenValues['gpt-4.1-mini'].prompt,
);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-4.1-mini'].completion,
);
expect(getMultiplier({ model: 'gpt-4.1-mini-preview', tokenType: 'prompt' })).toBe(
tokenValues['gpt-4.1-mini'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-4.1-mini', tokenType: 'completion' })).toBe(
tokenValues['gpt-4.1-mini'].completion,
);
});
it('should return the correct multiplier for gpt-4.1-nano', () => {
const valueKey = getValueKey('gpt-4.1-nano-2024-08-06');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(
tokenValues['gpt-4.1-nano'].prompt,
);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-4.1-nano'].completion,
);
expect(getMultiplier({ model: 'gpt-4.1-nano-preview', tokenType: 'prompt' })).toBe(
tokenValues['gpt-4.1-nano'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-4.1-nano', tokenType: 'completion' })).toBe(
tokenValues['gpt-4.1-nano'].completion,
);
});
it('should return the correct multiplier for gpt-4o-mini', () => {
const valueKey = getValueKey('gpt-4o-mini-2024-07-18');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(
@@ -348,9 +427,11 @@ describe('getCacheMultiplier', () => {
it('should derive the valueKey from the model if not provided', () => {
expect(getCacheMultiplier({ cacheType: 'write', model: 'claude-3-5-sonnet-20240620' })).toBe(
3.75,
cacheTokenValues['claude-3-5-sonnet'].write,
);
expect(getCacheMultiplier({ cacheType: 'read', model: 'claude-3-haiku-20240307' })).toBe(
cacheTokenValues['claude-3-haiku'].read,
);
expect(getCacheMultiplier({ cacheType: 'read', model: 'claude-3-haiku-20240307' })).toBe(0.03);
});
it('should return null if only model or cacheType is missing', () => {
@@ -371,10 +452,10 @@ describe('getCacheMultiplier', () => {
};
expect(
getCacheMultiplier({ model: 'custom-model', cacheType: 'write', endpointTokenConfig }),
).toBe(5);
).toBe(endpointTokenConfig['custom-model'].write);
expect(
getCacheMultiplier({ model: 'custom-model', cacheType: 'read', endpointTokenConfig }),
).toBe(1);
).toBe(endpointTokenConfig['custom-model'].read);
});
it('should return null if model is not found in endpointTokenConfig', () => {
@@ -395,18 +476,21 @@ describe('getCacheMultiplier', () => {
model: 'bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0',
cacheType: 'write',
}),
).toBe(3.75);
).toBe(cacheTokenValues['claude-3-5-sonnet'].write);
expect(
getCacheMultiplier({
model: 'bedrock/anthropic.claude-3-haiku-20240307-v1:0',
cacheType: 'read',
}),
).toBe(0.03);
).toBe(cacheTokenValues['claude-3-haiku'].read);
});
});
describe('Google Model Tests', () => {
const googleModels = [
'gemini-2.5-pro-preview-05-06',
'gemini-2.5-flash-preview-04-17',
'gemini-2.5-exp',
'gemini-2.0-flash-lite-preview-02-05',
'gemini-2.0-flash-001',
'gemini-2.0-flash-exp',
@@ -444,6 +528,9 @@ describe('Google Model Tests', () => {
it('should map to the correct model keys', () => {
const expected = {
'gemini-2.5-pro-preview-05-06': 'gemini-2.5-pro',
'gemini-2.5-flash-preview-04-17': 'gemini-2.5-flash',
'gemini-2.5-exp': 'gemini-2.5',
'gemini-2.0-flash-lite-preview-02-05': 'gemini-2.0-flash-lite',
'gemini-2.0-flash-001': 'gemini-2.0-flash',
'gemini-2.0-flash-exp': 'gemini-2.0-flash',
@@ -488,24 +575,92 @@ describe('Grok Model Tests - Pricing', () => {
test('should return correct prompt and completion rates for Grok vision models', () => {
const models = ['grok-2-vision-1212', 'grok-2-vision', 'grok-2-vision-latest'];
models.forEach((model) => {
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(2.0);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(10.0);
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
tokenValues['grok-2-vision'].prompt,
);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
tokenValues['grok-2-vision'].completion,
);
});
});
test('should return correct prompt and completion rates for Grok text models', () => {
const models = ['grok-2-1212', 'grok-2', 'grok-2-latest'];
models.forEach((model) => {
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(2.0);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(10.0);
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(tokenValues['grok-2'].prompt);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
tokenValues['grok-2'].completion,
);
});
});
test('should return correct prompt and completion rates for Grok beta models', () => {
expect(getMultiplier({ model: 'grok-vision-beta', tokenType: 'prompt' })).toBe(5.0);
expect(getMultiplier({ model: 'grok-vision-beta', tokenType: 'completion' })).toBe(15.0);
expect(getMultiplier({ model: 'grok-beta', tokenType: 'prompt' })).toBe(5.0);
expect(getMultiplier({ model: 'grok-beta', tokenType: 'completion' })).toBe(15.0);
expect(getMultiplier({ model: 'grok-vision-beta', tokenType: 'prompt' })).toBe(
tokenValues['grok-vision-beta'].prompt,
);
expect(getMultiplier({ model: 'grok-vision-beta', tokenType: 'completion' })).toBe(
tokenValues['grok-vision-beta'].completion,
);
expect(getMultiplier({ model: 'grok-beta', tokenType: 'prompt' })).toBe(
tokenValues['grok-beta'].prompt,
);
expect(getMultiplier({ model: 'grok-beta', tokenType: 'completion' })).toBe(
tokenValues['grok-beta'].completion,
);
});
test('should return correct prompt and completion rates for Grok 3 models', () => {
expect(getMultiplier({ model: 'grok-3', tokenType: 'prompt' })).toBe(
tokenValues['grok-3'].prompt,
);
expect(getMultiplier({ model: 'grok-3', tokenType: 'completion' })).toBe(
tokenValues['grok-3'].completion,
);
expect(getMultiplier({ model: 'grok-3-fast', tokenType: 'prompt' })).toBe(
tokenValues['grok-3-fast'].prompt,
);
expect(getMultiplier({ model: 'grok-3-fast', tokenType: 'completion' })).toBe(
tokenValues['grok-3-fast'].completion,
);
expect(getMultiplier({ model: 'grok-3-mini', tokenType: 'prompt' })).toBe(
tokenValues['grok-3-mini'].prompt,
);
expect(getMultiplier({ model: 'grok-3-mini', tokenType: 'completion' })).toBe(
tokenValues['grok-3-mini'].completion,
);
expect(getMultiplier({ model: 'grok-3-mini-fast', tokenType: 'prompt' })).toBe(
tokenValues['grok-3-mini-fast'].prompt,
);
expect(getMultiplier({ model: 'grok-3-mini-fast', tokenType: 'completion' })).toBe(
tokenValues['grok-3-mini-fast'].completion,
);
});
test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => {
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe(
tokenValues['grok-3'].prompt,
);
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'completion' })).toBe(
tokenValues['grok-3'].completion,
);
expect(getMultiplier({ model: 'xai/grok-3-fast', tokenType: 'prompt' })).toBe(
tokenValues['grok-3-fast'].prompt,
);
expect(getMultiplier({ model: 'xai/grok-3-fast', tokenType: 'completion' })).toBe(
tokenValues['grok-3-fast'].completion,
);
expect(getMultiplier({ model: 'xai/grok-3-mini', tokenType: 'prompt' })).toBe(
tokenValues['grok-3-mini'].prompt,
);
expect(getMultiplier({ model: 'xai/grok-3-mini', tokenType: 'completion' })).toBe(
tokenValues['grok-3-mini'].completion,
);
expect(getMultiplier({ model: 'xai/grok-3-mini-fast', tokenType: 'prompt' })).toBe(
tokenValues['grok-3-mini-fast'].prompt,
);
expect(getMultiplier({ model: 'xai/grok-3-mini-fast', tokenType: 'completion' })).toBe(
tokenValues['grok-3-mini-fast'].completion,
);
});
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.7.7",
"version": "v0.7.8",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -39,17 +39,16 @@
"@aws-sdk/s3-request-presigner": "^3.758.0",
"@azure/identity": "^4.7.0",
"@azure/search-documents": "^12.0.0",
"@azure/storage-blob": "^12.26.0",
"@azure/storage-blob": "^12.27.0",
"@google/generative-ai": "^0.23.0",
"@googleapis/youtube": "^20.0.0",
"@keyv/mongo": "^2.1.8",
"@keyv/redis": "^2.8.1",
"@langchain/community": "^0.3.34",
"@langchain/core": "^0.3.40",
"@langchain/google-genai": "^0.1.11",
"@langchain/google-vertexai": "^0.2.2",
"@keyv/redis": "^4.3.3",
"@langchain/community": "^0.3.42",
"@langchain/core": "^0.3.55",
"@langchain/google-genai": "^0.2.8",
"@langchain/google-vertexai": "^0.2.8",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.3.95",
"@librechat/agents": "^2.4.317",
"@librechat/data-schemas": "*",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
@@ -76,8 +75,8 @@
"ioredis": "^5.3.2",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.0",
"keyv": "^4.5.4",
"keyv-file": "^0.2.0",
"keyv": "^5.3.2",
"keyv-file": "^5.1.2",
"klona": "^2.0.6",
"librechat-data-provider": "*",
"librechat-mcp": "*",
@@ -91,7 +90,7 @@
"nanoid": "^3.3.7",
"nodemailer": "^6.9.15",
"ollama": "^0.5.0",
"openai": "^4.47.1",
"openai": "^4.96.2",
"openai-chat-tokens": "^0.2.8",
"openid-client": "^5.4.2",
"passport": "^0.6.0",
@@ -117,6 +116,6 @@
"jest": "^29.7.0",
"mongodb-memory-server": "^10.1.3",
"nodemon": "^3.0.3",
"supertest": "^7.0.0"
"supertest": "^7.1.0"
}
}

387
api/server/cleanup.js Normal file
View File

@@ -0,0 +1,387 @@
const { logger } = require('~/config');
// WeakMap to hold temporary data associated with requests
const requestDataMap = new WeakMap();
const FinalizationRegistry = global.FinalizationRegistry || null;
/**
* FinalizationRegistry to clean up client objects when they are garbage collected.
* This is used to prevent memory leaks and ensure that client objects are
* properly disposed of when they are no longer needed.
* The registry holds a weak reference to the client object and a cleanup
* callback that is called when the client object is garbage collected.
* The callback can be used to perform any necessary cleanup operations,
* such as removing event listeners or freeing up resources.
*/
const clientRegistry = FinalizationRegistry
? new FinalizationRegistry((heldValue) => {
try {
// This will run when the client is garbage collected
if (heldValue && heldValue.userId) {
logger.debug(`[FinalizationRegistry] Cleaning up client for user ${heldValue.userId}`);
} else {
logger.debug('[FinalizationRegistry] Cleaning up client');
}
} catch (e) {
// Ignore errors
}
})
: null;
/**
* Cleans up the client object by removing references to its properties.
* This is useful for preventing memory leaks and ensuring that the client
* and its properties can be garbage collected when it is no longer needed.
*/
function disposeClient(client) {
if (!client) {
return;
}
try {
if (client.user) {
client.user = null;
}
if (client.apiKey) {
client.apiKey = null;
}
if (client.azure) {
client.azure = null;
}
if (client.conversationId) {
client.conversationId = null;
}
if (client.responseMessageId) {
client.responseMessageId = null;
}
if (client.message_file_map) {
client.message_file_map = null;
}
if (client.clientName) {
client.clientName = null;
}
if (client.sender) {
client.sender = null;
}
if (client.model) {
client.model = null;
}
if (client.maxContextTokens) {
client.maxContextTokens = null;
}
if (client.contextStrategy) {
client.contextStrategy = null;
}
if (client.currentDateString) {
client.currentDateString = null;
}
if (client.inputTokensKey) {
client.inputTokensKey = null;
}
if (client.outputTokensKey) {
client.outputTokensKey = null;
}
if (client.skipSaveUserMessage !== undefined) {
client.skipSaveUserMessage = null;
}
if (client.visionMode) {
client.visionMode = null;
}
if (client.continued !== undefined) {
client.continued = null;
}
if (client.fetchedConvo !== undefined) {
client.fetchedConvo = null;
}
if (client.previous_summary) {
client.previous_summary = null;
}
if (client.metadata) {
client.metadata = null;
}
if (client.isVisionModel) {
client.isVisionModel = null;
}
if (client.isChatCompletion !== undefined) {
client.isChatCompletion = null;
}
if (client.contextHandlers) {
client.contextHandlers = null;
}
if (client.augmentedPrompt) {
client.augmentedPrompt = null;
}
if (client.systemMessage) {
client.systemMessage = null;
}
if (client.azureEndpoint) {
client.azureEndpoint = null;
}
if (client.langchainProxy) {
client.langchainProxy = null;
}
if (client.isOmni !== undefined) {
client.isOmni = null;
}
if (client.runManager) {
client.runManager = null;
}
// Properties specific to AnthropicClient
if (client.message_start) {
client.message_start = null;
}
if (client.message_delta) {
client.message_delta = null;
}
if (client.isClaude3 !== undefined) {
client.isClaude3 = null;
}
if (client.useMessages !== undefined) {
client.useMessages = null;
}
if (client.isLegacyOutput !== undefined) {
client.isLegacyOutput = null;
}
if (client.supportsCacheControl !== undefined) {
client.supportsCacheControl = null;
}
// Properties specific to GoogleClient
if (client.serviceKey) {
client.serviceKey = null;
}
if (client.project_id) {
client.project_id = null;
}
if (client.client_email) {
client.client_email = null;
}
if (client.private_key) {
client.private_key = null;
}
if (client.access_token) {
client.access_token = null;
}
if (client.reverseProxyUrl) {
client.reverseProxyUrl = null;
}
if (client.authHeader) {
client.authHeader = null;
}
if (client.isGenerativeModel !== undefined) {
client.isGenerativeModel = null;
}
// Properties specific to OpenAIClient
if (client.ChatGPTClient) {
client.ChatGPTClient = null;
}
if (client.completionsUrl) {
client.completionsUrl = null;
}
if (client.shouldSummarize !== undefined) {
client.shouldSummarize = null;
}
if (client.isOllama !== undefined) {
client.isOllama = null;
}
if (client.FORCE_PROMPT !== undefined) {
client.FORCE_PROMPT = null;
}
if (client.isChatGptModel !== undefined) {
client.isChatGptModel = null;
}
if (client.isUnofficialChatGptModel !== undefined) {
client.isUnofficialChatGptModel = null;
}
if (client.useOpenRouter !== undefined) {
client.useOpenRouter = null;
}
if (client.startToken) {
client.startToken = null;
}
if (client.endToken) {
client.endToken = null;
}
if (client.userLabel) {
client.userLabel = null;
}
if (client.chatGptLabel) {
client.chatGptLabel = null;
}
if (client.modelLabel) {
client.modelLabel = null;
}
if (client.modelOptions) {
client.modelOptions = null;
}
if (client.defaultVisionModel) {
client.defaultVisionModel = null;
}
if (client.maxPromptTokens) {
client.maxPromptTokens = null;
}
if (client.maxResponseTokens) {
client.maxResponseTokens = null;
}
if (client.run) {
// Break circular references in run
if (client.run.Graph) {
client.run.Graph.resetValues();
client.run.Graph.handlerRegistry = null;
client.run.Graph.runId = null;
client.run.Graph.tools = null;
client.run.Graph.signal = null;
client.run.Graph.config = null;
client.run.Graph.toolEnd = null;
client.run.Graph.toolMap = null;
client.run.Graph.provider = null;
client.run.Graph.streamBuffer = null;
client.run.Graph.clientOptions = null;
client.run.Graph.graphState = null;
if (client.run.Graph.boundModel?.client) {
client.run.Graph.boundModel.client = null;
}
client.run.Graph.boundModel = null;
client.run.Graph.systemMessage = null;
client.run.Graph.reasoningKey = null;
client.run.Graph.messages = null;
client.run.Graph.contentData = null;
client.run.Graph.stepKeyIds = null;
client.run.Graph.contentIndexMap = null;
client.run.Graph.toolCallStepIds = null;
client.run.Graph.messageIdsByStepKey = null;
client.run.Graph.messageStepHasToolCalls = null;
client.run.Graph.prelimMessageIdsByStepKey = null;
client.run.Graph.currentTokenType = null;
client.run.Graph.lastToken = null;
client.run.Graph.tokenTypeSwitch = null;
client.run.Graph.indexTokenCountMap = null;
client.run.Graph.currentUsage = null;
client.run.Graph.tokenCounter = null;
client.run.Graph.maxContextTokens = null;
client.run.Graph.pruneMessages = null;
client.run.Graph.lastStreamCall = null;
client.run.Graph.startIndex = null;
client.run.Graph = null;
}
if (client.run.handlerRegistry) {
client.run.handlerRegistry = null;
}
if (client.run.graphRunnable) {
if (client.run.graphRunnable.channels) {
client.run.graphRunnable.channels = null;
}
if (client.run.graphRunnable.nodes) {
client.run.graphRunnable.nodes = null;
}
if (client.run.graphRunnable.lc_kwargs) {
client.run.graphRunnable.lc_kwargs = null;
}
if (client.run.graphRunnable.builder?.nodes) {
client.run.graphRunnable.builder.nodes = null;
client.run.graphRunnable.builder = null;
}
client.run.graphRunnable = null;
}
client.run = null;
}
if (client.sendMessage) {
client.sendMessage = null;
}
if (client.savedMessageIds) {
client.savedMessageIds.clear();
client.savedMessageIds = null;
}
if (client.currentMessages) {
client.currentMessages = null;
}
if (client.streamHandler) {
client.streamHandler = null;
}
if (client.contentParts) {
client.contentParts = null;
}
if (client.abortController) {
client.abortController = null;
}
if (client.collectedUsage) {
client.collectedUsage = null;
}
if (client.indexTokenCountMap) {
client.indexTokenCountMap = null;
}
if (client.agentConfigs) {
client.agentConfigs = null;
}
if (client.artifactPromises) {
client.artifactPromises = null;
}
if (client.usage) {
client.usage = null;
}
if (typeof client.dispose === 'function') {
client.dispose();
}
if (client.options) {
if (client.options.req) {
client.options.req = null;
}
if (client.options.res) {
client.options.res = null;
}
if (client.options.attachments) {
client.options.attachments = null;
}
if (client.options.agent) {
client.options.agent = null;
}
}
client.options = null;
} catch (e) {
// Ignore errors during disposal
}
}
function processReqData(data = {}, context) {
let {
abortKey,
userMessage,
userMessagePromise,
responseMessageId,
promptTokens,
conversationId,
userMessageId,
} = context;
for (const 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 (key === 'abortKey') {
abortKey = data[key];
} else if (!conversationId && key === 'conversationId') {
conversationId = data[key];
}
}
return {
abortKey,
userMessage,
userMessagePromise,
responseMessageId,
promptTokens,
conversationId,
userMessageId,
};
}
module.exports = {
disposeClient,
requestDataMap,
clientRegistry,
processReqData,
};

View File

@@ -1,5 +1,15 @@
const { getResponseSender, Constants } = require('librechat-data-provider');
const { createAbortController, handleAbortError } = require('~/server/middleware');
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');
@@ -14,90 +24,162 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
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' : '',
modelsConfig: endpointOption?.modelsConfig ? 'exists' : '',
});
let userMessage;
let userMessagePromise;
let promptTokens;
let userMessageId;
let responseMessageId;
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 newConvo = !conversationId;
const user = req.user.id;
const initialConversationId = conversationId;
const newConvo = !initialConversationId;
const userId = req.user.id;
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 reqDataContext = {
userMessage,
userMessagePromise,
responseMessageId,
promptTokens,
conversationId,
userMessageId,
};
let getText;
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 {
const { client } = await initializeClient({ req, res, endpointOption });
const { onProgress: progressCallback, getPartialText } = createOnProgress();
({ client } = await initializeClient({ req, res, endpointOption }));
if (clientRegistry && client) {
clientRegistry.register(client, { userId }, client);
}
getText = client.getStreamText != null ? client.getStreamText.bind(client) : getPartialText;
if (client) {
requestDataMap.set(req, { client });
}
const getAbortData = () => ({
sender,
conversationId,
userMessagePromise,
messageId: responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId,
text: getText(),
userMessage,
promptTokens,
});
clientRef = new WeakRef(client);
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
getAbortData = () => {
const currentClient = clientRef?.deref();
const currentText =
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
res.on('close', () => {
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) {
return;
} else if (abortController.signal.aborted) {
return;
} else if (abortController.requestCompleted) {
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,
user: userId,
parentMessageId,
conversationId,
conversationId: reqDataContext.conversationId,
overrideParentMessageId,
getReqData,
getReqData: updateReqData,
onStart,
abortController,
progressCallback,
progressOptions: {
res,
// parentMessageId: overrideParentMessageId || userMessageId,
},
};
@@ -105,59 +187,95 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
let response = await client.sendMessage(text, messageOptions);
response.endpoint = endpointOption.endpoint;
const { conversation = {} } = await client.responsePromise;
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';
if (client.options.attachments) {
userMessage.files = client.options.attachments;
conversation.model = endpointOption.modelOptions.model;
delete userMessage.image_urls;
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: userMessage,
responseMessage: response,
requestMessage: latestUserMessage,
responseMessage: finalResponseMessage,
});
res.end();
if (!client.savedMessageIds.has(response.messageId)) {
if (client?.savedMessageIds && !client.savedMessageIds.has(response.messageId)) {
await saveMessage(
req,
{ ...response, user },
{ ...finalResponseMessage, user: userId },
{ context: 'api/server/controllers/AskController.js - response end' },
);
}
}
if (!client.skipSaveUserMessage) {
await saveMessage(req, userMessage, {
context: 'api/server/controllers/AskController.js - don\'t skip saving user message',
if (!client?.skipSaveUserMessage && latestUserMessage) {
await saveMessage(req, latestUserMessage, {
context: "api/server/controllers/AskController.js - don't skip saving user message",
});
}
if (addTitle && parentMessageId === Constants.NO_PARENT && newConvo) {
if (typeof addTitle === 'function' && parentMessageId === Constants.NO_PARENT && newConvo) {
addTitle(req, {
text,
response,
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) {
const partialText = getText && getText();
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,
messageId: responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
}).catch((err) => {
logger.error('[AskController] Error in `handleAbortError`', err);
});
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();
});
}
};

View File

@@ -1,5 +1,15 @@
const { getResponseSender } = require('librechat-data-provider');
const { createAbortController, handleAbortError } = require('~/server/middleware');
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');
@@ -17,6 +27,11 @@ const EditController = async (req, res, next, initializeClient) => {
overrideParentMessageId = null,
} = req.body;
let client = null;
let abortKey = null;
let cleanupHandlers = [];
let clientRef = null; // Declare clientRef here
logger.debug('[EditController]', {
text,
generation,
@@ -26,123 +41,205 @@ const EditController = async (req, res, next, initializeClient) => {
modelsConfig: endpointOption.modelsConfig ? 'exists' : '',
});
let userMessage;
let userMessagePromise;
let promptTokens;
let userMessage = null;
let userMessagePromise = null;
let promptTokens = null;
let getAbortData = null;
const sender = getResponseSender({
...endpointOption,
model: endpointOption.modelOptions.model,
modelDisplayLabel,
});
const userMessageId = parentMessageId;
const user = req.user.id;
const userId = req.user.id;
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];
}
}
let reqDataContext = { userMessage, userMessagePromise, responseMessageId, promptTokens };
const updateReqData = (data = {}) => {
reqDataContext = processReqData(data, reqDataContext);
abortKey = reqDataContext.abortKey;
userMessage = reqDataContext.userMessage;
userMessagePromise = reqDataContext.userMessagePromise;
responseMessageId = reqDataContext.responseMessageId;
promptTokens = reqDataContext.promptTokens;
};
const { onProgress: progressCallback, getPartialText } = createOnProgress({
let { onProgress: progressCallback, getPartialText } = createOnProgress({
generation,
});
let getText;
const performCleanup = () => {
logger.debug('[EditController] 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;
if (requestDataMap.has(req)) {
requestDataMap.delete(req);
}
logger.debug('[EditController] Cleanup completed');
};
try {
const { client } = await initializeClient({ req, res, endpointOption });
({ client } = await initializeClient({ req, res, endpointOption }));
getText = client.getStreamText != null ? client.getStreamText.bind(client) : getPartialText;
if (clientRegistry && client) {
clientRegistry.register(client, { userId }, client);
}
const getAbortData = () => ({
conversationId,
userMessagePromise,
messageId: responseMessageId,
sender,
parentMessageId: overrideParentMessageId ?? userMessageId,
text: getText(),
userMessage,
promptTokens,
});
if (client) {
requestDataMap.set(req, { client });
}
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
clientRef = new WeakRef(client);
res.on('close', () => {
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('[EditController] Request closed');
if (!abortController) {
return;
} else if (abortController.signal.aborted) {
return;
} else if (abortController.requestCompleted) {
if (!abortController || abortController.signal.aborted || abortController.requestCompleted) {
return;
}
abortController.abort();
logger.debug('[EditController] Request aborted on close');
};
res.on('close', closeHandler);
cleanupHandlers.push(() => {
try {
res.removeListener('close', closeHandler);
} catch (e) {
// Ignore
}
});
let response = await client.sendMessage(text, {
user,
user: userId,
generation,
isContinued,
isEdited: true,
conversationId,
parentMessageId,
responseMessageId,
responseMessageId: reqDataContext.responseMessageId,
overrideParentMessageId,
getReqData,
getReqData: updateReqData,
onStart,
abortController,
progressCallback,
progressOptions: {
res,
// parentMessageId: overrideParentMessageId || userMessageId,
},
});
const { conversation = {} } = await client.responsePromise;
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';
if (client.options.attachments) {
if (client?.options?.attachments && endpointOption?.modelOptions?.model) {
conversation.model = endpointOption.modelOptions.model;
}
if (!abortController.signal.aborted) {
const finalUserMessage = reqDataContext.userMessage;
const finalResponseMessage = { ...response };
sendMessage(res, {
final: true,
conversation,
title: conversation.title,
requestMessage: userMessage,
responseMessage: response,
requestMessage: finalUserMessage,
responseMessage: finalResponseMessage,
});
res.end();
await saveMessage(
req,
{ ...response, user },
{ ...finalResponseMessage, user: userId },
{ context: 'api/server/controllers/EditController.js - response end' },
);
}
performCleanup();
} catch (error) {
const partialText = getText();
logger.error('[EditController] Error handling request', error);
let partialText = '';
try {
const currentClient = clientRef?.deref();
partialText =
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
} catch (getTextError) {
logger.error('[EditController] Error calling getText() during error handling', getTextError);
}
handleAbortError(res, req, error, {
sender,
partialText,
conversationId,
messageId: responseMessageId,
messageId: reqDataContext.responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
}).catch((err) => {
logger.error('[EditController] Error in `handleAbortError`', err);
});
userMessageId,
})
.catch((err) => {
logger.error('[EditController] Error in `handleAbortError` during catch block', err);
})
.finally(() => {
performCleanup();
});
}
};

View File

@@ -1,5 +1,5 @@
const { CacheKeys, AuthType } = require('librechat-data-provider');
const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs');
const { getToolkitKey } = require('~/server/services/ToolService');
const { getCustomConfig } = require('~/server/services/Config');
const { availableTools } = require('~/app/clients/tools');
const { getMCPManager } = require('~/config');
@@ -69,7 +69,7 @@ const getAvailablePluginsController = async (req, res) => {
);
}
let plugins = await addOpenAPISpecs(authenticatedPlugins);
let plugins = authenticatedPlugins;
if (includedTools.length > 0) {
plugins = plugins.filter((plugin) => includedTools.includes(plugin.pluginKey));
@@ -105,11 +105,11 @@ const getAvailableTools = async (req, res) => {
return;
}
const pluginManifest = availableTools;
let pluginManifest = availableTools;
const customConfig = await getCustomConfig();
if (customConfig?.mcpServers != null) {
const mcpManager = await getMCPManager();
await mcpManager.loadManifestTools(pluginManifest);
const mcpManager = getMCPManager();
pluginManifest = await mcpManager.loadManifestTools(pluginManifest);
}
/** @type {TPlugin[]} */
@@ -128,7 +128,7 @@ const getAvailableTools = async (req, res) => {
(plugin) =>
toolDefinitions[plugin.pluginKey] !== undefined ||
(plugin.toolkit === true &&
Object.keys(toolDefinitions).some((key) => key.startsWith(`${plugin.pluginKey}_`))),
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey)),
);
await cache.set(CacheKeys.TOOLS, tools);

View File

@@ -14,15 +14,6 @@ const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { saveBase64Image } = require('~/server/services/Files/process');
const { logger, sendEvent } = require('~/config');
/** @typedef {import('@librechat/agents').Graph} Graph */
/** @typedef {import('@librechat/agents').EventHandler} EventHandler */
/** @typedef {import('@librechat/agents').ModelEndData} ModelEndData */
/** @typedef {import('@librechat/agents').ToolEndData} ToolEndData */
/** @typedef {import('@librechat/agents').ToolEndCallback} ToolEndCallback */
/** @typedef {import('@librechat/agents').ChatModelStreamHandler} ChatModelStreamHandler */
/** @typedef {import('@librechat/agents').ContentAggregatorResult['aggregateContent']} ContentAggregator */
/** @typedef {import('@librechat/agents').GraphEvents} GraphEvents */
class ModelEndHandler {
/**
* @param {Array<UsageMetadata>} collectedUsage
@@ -38,7 +29,7 @@ class ModelEndHandler {
* @param {string} event
* @param {ModelEndData | undefined} data
* @param {Record<string, unknown> | undefined} metadata
* @param {Graph} graph
* @param {StandardGraph} graph
* @returns
*/
handle(event, data, metadata, graph) {
@@ -61,7 +52,10 @@ class ModelEndHandler {
}
this.collectedUsage.push(usage);
if (!graph.clientOptions?.disableStreaming) {
const streamingDisabled = !!(
graph.clientOptions?.disableStreaming || graph?.boundModel?.disableStreaming
);
if (!streamingDisabled) {
return;
}
if (!data.output.content) {
@@ -246,7 +240,11 @@ function createToolEndCallback({ req, res, artifactPromises }) {
if (output.artifact.content) {
/** @type {FormattedContent[]} */
const content = output.artifact.content;
for (const part of content) {
for (let i = 0; i < content.length; i++) {
const part = content[i];
if (!part) {
continue;
}
if (part.type !== 'image_url') {
continue;
}
@@ -254,8 +252,10 @@ function createToolEndCallback({ req, res, artifactPromises }) {
artifactPromises.push(
(async () => {
const filename = `${output.name}_${output.tool_call_id}_img_${nanoid()}`;
const file_id = output.artifact.file_ids?.[i];
const file = await saveBase64Image(url, {
req,
file_id,
filename,
endpoint: metadata.provider,
context: FileContext.image_generation,

View File

@@ -20,11 +20,9 @@ const {
const {
Constants,
VisionModes,
openAISchema,
ContentTypes,
EModelEndpoint,
KnownEndpoints,
anthropicSchema,
isAgentsEndpoint,
AgentCapabilities,
bedrockInputSchema,
@@ -35,6 +33,7 @@ const { addCacheControl, createContextHandlers } = require('~/app/clients/prompt
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
const Tokenizer = require('~/server/services/Tokenizer');
const BaseClient = require('~/app/clients/BaseClient');
const { logger, sendEvent } = require('~/config');
@@ -43,21 +42,43 @@ const { createRun } = require('./run');
/** @typedef {import('@librechat/agents').MessageContentComplex} MessageContentComplex */
/** @typedef {import('@langchain/core/runnables').RunnableConfig} RunnableConfig */
const providerParsers = {
[EModelEndpoint.openAI]: openAISchema.parse,
[EModelEndpoint.azureOpenAI]: openAISchema.parse,
[EModelEndpoint.anthropic]: anthropicSchema.parse,
[EModelEndpoint.bedrock]: bedrockInputSchema.parse,
/**
* @param {ServerRequest} req
* @param {Agent} agent
* @param {string} endpoint
*/
const payloadParser = ({ req, agent, endpoint }) => {
if (isAgentsEndpoint(endpoint)) {
return { model: undefined };
} else if (endpoint === EModelEndpoint.bedrock) {
return bedrockInputSchema.parse(agent.model_parameters);
}
return req.body.endpointOption.model_parameters;
};
const legacyContentEndpoints = new Set([KnownEndpoints.groq, KnownEndpoints.deepseek]);
const noSystemModelRegex = [/\bo1\b/gi];
const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
// const { processMemory, memoryInstructions } = require('~/server/services/Endpoints/agents/memory');
// const { getFormattedMemories } = require('~/models/Memory');
// const { getCurrentDateTime } = require('~/utils');
function createTokenCounter(encoding) {
return (message) => {
const countTokens = (text) => Tokenizer.getTokenCount(text, encoding);
return getTokenCountForMessage(message, countTokens);
};
}
function logToolError(graph, error, toolId) {
logger.error(
'[api/server/controllers/agents/client.js #chatCompletion] Tool Error',
error,
toolId,
);
}
class AgentClient extends BaseClient {
constructor(options = {}) {
super(null, options);
@@ -127,19 +148,13 @@ class AgentClient extends BaseClient {
* @param {MongoFile[]} attachments
*/
checkVisionRequest(attachments) {
logger.info(
'[api/server/controllers/agents/client.js #checkVisionRequest] not implemented',
attachments,
);
// if (!attachments) {
// return;
// }
// const availableModels = this.options.modelsConfig?.[this.options.endpoint];
// if (!availableModels) {
// return;
// }
// let visionRequestDetected = false;
// for (const file of attachments) {
// if (file?.type?.includes('image')) {
@@ -150,13 +165,11 @@ class AgentClient extends BaseClient {
// if (!visionRequestDetected) {
// return;
// }
// this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
// if (this.isVisionModel) {
// delete this.modelOptions.stop;
// return;
// }
// for (const model of availableModels) {
// if (!validateVisionModel({ model, availableModels })) {
// continue;
@@ -166,42 +179,31 @@ class AgentClient extends BaseClient {
// delete this.modelOptions.stop;
// return;
// }
// if (!availableModels.includes(this.defaultVisionModel)) {
// return;
// }
// if (!validateVisionModel({ model: this.defaultVisionModel, availableModels })) {
// return;
// }
// this.modelOptions.model = this.defaultVisionModel;
// this.isVisionModel = true;
// delete this.modelOptions.stop;
}
getSaveOptions() {
const parseOptions = providerParsers[this.options.endpoint];
let runOptions =
this.options.endpoint === EModelEndpoint.agents
? {
model: undefined,
// TODO:
// would need to be override settings; otherwise, model needs to be undefined
// model: this.override.model,
// instructions: this.override.instructions,
// additional_instructions: this.override.additional_instructions,
}
: {};
if (parseOptions) {
try {
runOptions = parseOptions(this.options.agent.model_parameters);
} catch (error) {
logger.error(
'[api/server/controllers/agents/client.js #getSaveOptions] Error parsing options',
error,
);
}
// TODO:
// would need to be override settings; otherwise, model needs to be undefined
// model: this.override.model,
// instructions: this.override.instructions,
// additional_instructions: this.override.additional_instructions,
let runOptions = {};
try {
runOptions = payloadParser(this.options);
} catch (error) {
logger.error(
'[api/server/controllers/agents/client.js #getSaveOptions] Error parsing options',
error,
);
}
return removeNullishValues(
@@ -352,7 +354,9 @@ class AgentClient extends BaseClient {
this.contextHandlers?.processFile(file);
continue;
}
if (file.metadata?.fileIdentifier) {
continue;
}
// orderedMessages[i].tokenCount += this.calculateImageTokenCost({
// width: file.width,
// height: file.height,
@@ -539,6 +543,10 @@ class AgentClient extends BaseClient {
}
async chatCompletion({ payload, abortController = null }) {
/** @type {Partial<RunnableConfig> & { version: 'v1' | 'v2'; run_id?: string; streamMode: string }} */
let config;
/** @type {ReturnType<createRun>} */
let run;
try {
if (!abortController) {
abortController = new AbortController();
@@ -636,11 +644,11 @@ class AgentClient extends BaseClient {
/** @type {TCustomConfig['endpoints']['agents']} */
const agentsEConfig = this.options.req.app.locals[EModelEndpoint.agents];
/** @type {Partial<RunnableConfig> & { version: 'v1' | 'v2'; run_id?: string; streamMode: string }} */
const config = {
config = {
configurable: {
thread_id: this.conversationId,
last_agent_index: this.agentConfigs?.size ?? 0,
user_id: this.user ?? this.options.req.user?.id,
hide_sequential_outputs: this.options.agent.hide_sequential_outputs,
},
recursionLimit: agentsEConfig?.recursionLimit,
@@ -655,19 +663,10 @@ class AgentClient extends BaseClient {
this.indexTokenCountMap,
toolSet,
);
if (legacyContentEndpoints.has(this.options.agent.endpoint)) {
if (legacyContentEndpoints.has(this.options.agent.endpoint?.toLowerCase())) {
initialMessages = formatContentStrings(initialMessages);
}
/** @type {ReturnType<createRun>} */
let run;
const countTokens = ((text) => this.getTokenCount(text)).bind(this);
/** @type {(message: BaseMessage) => number} */
const tokenCounter = (message) => {
return getTokenCountForMessage(message, countTokens);
};
/**
*
* @param {Agent} agent
@@ -719,12 +718,14 @@ class AgentClient extends BaseClient {
}
if (noSystemMessages === true && systemContent?.length) {
let latestMessage = _messages.pop().content;
const latestMessageContent = _messages.pop().content;
if (typeof latestMessage !== 'string') {
latestMessage = latestMessage[0].text;
latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
_messages.push(new HumanMessage({ content: latestMessageContent }));
} else {
const text = [systemContent, latestMessageContent].join('\n');
_messages.push(new HumanMessage(text));
}
latestMessage = [systemContent, latestMessage].join('\n');
_messages.push(new HumanMessage(latestMessage));
}
let messages = _messages;
@@ -771,21 +772,18 @@ class AgentClient extends BaseClient {
run.Graph.contentData = contentData;
}
const encoding = this.getEncoding();
await run.processStream({ messages }, config, {
keepContent: i !== 0,
tokenCounter,
tokenCounter: createTokenCounter(encoding),
indexTokenCountMap: currentIndexCountMap,
maxContextTokens: agent.maxContextTokens,
callbacks: {
[Callback.TOOL_ERROR]: (graph, error, toolId) => {
logger.error(
'[api/server/controllers/agents/client.js #chatCompletion] Tool Error',
error,
toolId,
);
},
[Callback.TOOL_ERROR]: logToolError,
},
});
config.signal = null;
};
await runAgent(this.options.agent, initialMessages);
@@ -813,6 +811,8 @@ class AgentClient extends BaseClient {
break;
}
}
const encoding = this.getEncoding();
const tokenCounter = createTokenCounter(encoding);
for (const [agentId, agent] of this.agentConfigs) {
if (abortController.signal.aborted === true) {
break;
@@ -921,19 +921,21 @@ class AgentClient extends BaseClient {
* @param {string} params.text
* @param {string} params.conversationId
*/
async titleConvo({ text }) {
async titleConvo({ text, abortController }) {
if (!this.run) {
throw new Error('Run not initialized');
}
const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator();
const endpoint = this.options.agent.endpoint;
const { req, res } = this.options;
/** @type {import('@librechat/agents').ClientOptions} */
const clientOptions = {
let clientOptions = {
maxTokens: 75,
};
let endpointConfig = this.options.req.app.locals[this.options.agent.endpoint];
let endpointConfig = req.app.locals[endpoint];
if (!endpointConfig) {
try {
endpointConfig = await getCustomEndpointConfig(this.options.agent.endpoint);
endpointConfig = await getCustomEndpointConfig(endpoint);
} catch (err) {
logger.error(
'[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config',
@@ -948,12 +950,35 @@ class AgentClient extends BaseClient {
) {
clientOptions.model = endpointConfig.titleModel;
}
if (
endpoint === EModelEndpoint.azureOpenAI &&
clientOptions.model &&
this.options.agent.model_parameters.model !== clientOptions.model
) {
clientOptions =
(
await initOpenAI({
req,
res,
optionsOnly: true,
overrideModel: clientOptions.model,
overrideEndpoint: endpoint,
endpointOption: {
model_parameters: clientOptions,
},
})
)?.llmConfig ?? clientOptions;
}
if (/\b(o\d)\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
delete clientOptions.maxTokens;
}
try {
const titleResult = await this.run.generateTitle({
inputText: text,
contentParts: this.contentParts,
clientOptions,
chainOptions: {
signal: abortController.signal,
callbacks: [
{
handleLLMEnd,
@@ -979,7 +1004,7 @@ class AgentClient extends BaseClient {
};
});
this.recordCollectedUsage({
await this.recordCollectedUsage({
model: clientOptions.model,
context: 'title',
collectedUsage,

View File

@@ -1,5 +1,10 @@
const { Constants } = require('librechat-data-provider');
const { createAbortController, handleAbortError } = require('~/server/middleware');
const {
handleAbortError,
createAbortController,
cleanupAbortController,
} = require('~/server/middleware');
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
const { sendMessage } = require('~/server/utils');
const { saveMessage } = require('~/models');
const { logger } = require('~/config');
@@ -14,16 +19,22 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
} = req.body;
let sender;
let abortKey;
let userMessage;
let promptTokens;
let userMessageId;
let responseMessageId;
let userMessagePromise;
let getAbortData;
let client = null;
// Initialize as an array
let cleanupHandlers = [];
const newConvo = !conversationId;
const user = req.user.id;
const userId = req.user.id;
const getReqData = (data = {}) => {
// Create handler to avoid capturing the entire parent scope
let getReqData = (data = {}) => {
for (let key in data) {
if (key === 'userMessage') {
userMessage = data[key];
@@ -36,30 +47,96 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
promptTokens = data[key];
} else if (key === 'sender') {
sender = data[key];
} else if (key === 'abortKey') {
abortKey = data[key];
} else if (!conversationId && key === 'conversationId') {
conversationId = data[key];
}
}
};
// Create a function to handle final cleanup
const performCleanup = () => {
logger.debug('[AgentController] Performing cleanup');
// Make sure cleanupHandlers is an array before iterating
if (Array.isArray(cleanupHandlers)) {
// Execute all cleanup handlers
for (const handler of cleanupHandlers) {
try {
if (typeof handler === 'function') {
handler();
}
} catch (e) {
// Ignore cleanup errors
}
}
}
// Clean up abort controller
if (abortKey) {
logger.debug('[AgentController] Cleaning up abort controller');
cleanupAbortController(abortKey);
}
// Dispose client properly
if (client) {
disposeClient(client);
}
// Clear all references
client = null;
getReqData = null;
userMessage = null;
getAbortData = null;
endpointOption.agent = null;
endpointOption = null;
cleanupHandlers = null;
userMessagePromise = null;
// Clear request data map
if (requestDataMap.has(req)) {
requestDataMap.delete(req);
}
logger.debug('[AgentController] Cleanup completed');
};
try {
/** @type {{ client: TAgentClient }} */
const { client } = await initializeClient({ req, res, endpointOption });
const result = await initializeClient({ req, res, endpointOption });
client = result.client;
const getAbortData = () => ({
sender,
userMessage,
promptTokens,
conversationId,
userMessagePromise,
messageId: responseMessageId,
content: client.getContentParts(),
parentMessageId: overrideParentMessageId ?? userMessageId,
});
// Register client with finalization registry if available
if (clientRegistry) {
clientRegistry.register(client, { userId }, client);
}
// Store request data in WeakMap keyed by req object
requestDataMap.set(req, { client });
// Use WeakRef to allow GC but still access content if it exists
const contentRef = new WeakRef(client.contentParts || []);
// Minimize closure scope - only capture small primitives and WeakRef
getAbortData = () => {
// Dereference WeakRef each time
const content = contentRef.deref();
return {
sender,
content: content || [],
userMessage,
promptTokens,
conversationId,
userMessagePromise,
messageId: responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId,
};
};
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
res.on('close', () => {
// Simple handler to avoid capturing scope
const closeHandler = () => {
logger.debug('[AgentController] Request closed');
if (!abortController) {
return;
@@ -71,10 +148,19 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
abortController.abort();
logger.debug('[AgentController] Request aborted on close');
};
res.on('close', closeHandler);
cleanupHandlers.push(() => {
try {
res.removeListener('close', closeHandler);
} catch (e) {
// Ignore
}
});
const messageOptions = {
user,
user: userId,
onStart,
getReqData,
conversationId,
@@ -83,69 +169,104 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
overrideParentMessageId,
progressOptions: {
res,
// parentMessageId: overrideParentMessageId || userMessageId,
},
};
let response = await client.sendMessage(text, messageOptions);
response.endpoint = endpointOption.endpoint;
const { conversation = {} } = await client.responsePromise;
// Extract what we need and immediately break reference
const messageId = response.messageId;
const endpoint = endpointOption.endpoint;
response.endpoint = endpoint;
// Store database promise locally
const databasePromise = response.databasePromise;
delete response.databasePromise;
// Resolve database-related data
const { conversation: convoData = {} } = await databasePromise;
const conversation = { ...convoData };
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
if (req.body.files && client.options.attachments) {
// Process files if needed
if (req.body.files && client.options?.attachments) {
userMessage.files = [];
const messageFiles = new Set(req.body.files.map((file) => file.file_id));
for (let attachment of client.options.attachments) {
if (messageFiles.has(attachment.file_id)) {
userMessage.files.push(attachment);
userMessage.files.push({ ...attachment });
}
}
delete userMessage.image_urls;
}
// Only send if not aborted
if (!abortController.signal.aborted) {
// Create a new response object with minimal copies
const finalResponse = { ...response };
sendMessage(res, {
final: true,
conversation,
title: conversation.title,
requestMessage: userMessage,
responseMessage: response,
responseMessage: finalResponse,
});
res.end();
if (!client.savedMessageIds.has(response.messageId)) {
// Save the message if needed
if (client.savedMessageIds && !client.savedMessageIds.has(messageId)) {
await saveMessage(
req,
{ ...response, user },
{ ...finalResponse, user: userId },
{ context: 'api/server/controllers/agents/request.js - response end' },
);
}
}
// Save user message if needed
if (!client.skipSaveUserMessage) {
await saveMessage(req, userMessage, {
context: 'api/server/controllers/agents/request.js - don\'t skip saving user message',
});
}
// Add title if needed - extract minimal data
if (addTitle && parentMessageId === Constants.NO_PARENT && newConvo) {
addTitle(req, {
text,
response,
response: { ...response },
client,
});
})
.then(() => {
logger.debug('[AgentController] Title generation started');
})
.catch((err) => {
logger.error('[AgentController] Error in title generation', err);
})
.finally(() => {
logger.debug('[AgentController] Title generation completed');
performCleanup();
});
} else {
performCleanup();
}
} catch (error) {
// Handle error without capturing much scope
handleAbortError(res, req, error, {
conversationId,
sender,
messageId: responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
}).catch((err) => {
logger.error('[api/server/controllers/agents/request] Error in `handleAbortError`', err);
});
userMessageId,
})
.catch((err) => {
logger.error('[api/server/controllers/agents/request] Error in `handleAbortError`', err);
})
.finally(() => {
performCleanup();
});
}
};

View File

@@ -11,6 +11,13 @@ const { providerEndpointMap, KnownEndpoints } = require('librechat-data-provider
* @typedef {import('@librechat/agents').IState} IState
*/
const customProviders = new Set([
Providers.XAI,
Providers.OLLAMA,
Providers.DEEPSEEK,
Providers.OPENROUTER,
]);
/**
* Creates a new Run instance with custom handlers and configuration.
*
@@ -43,8 +50,11 @@ async function createRun({
agent.model_parameters,
);
/** Resolves Mistral type strictness due to new OpenAI usage field */
if (agent.endpoint?.toLowerCase().includes(KnownEndpoints.mistral)) {
/** Resolves issues with new OpenAI usage field */
if (
customProviders.has(agent.provider) ||
(agent.provider === Providers.OPENAI && agent.endpoint !== agent.provider)
) {
llmConfig.streamUsage = false;
llmConfig.usage = true;
}

View File

@@ -119,7 +119,7 @@ const chatV1 = async (req, res) => {
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === EModelEndpoint.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);
@@ -379,8 +379,8 @@ const chatV1 = async (req, res) => {
body.additional_instructions ? `${body.additional_instructions}\n` : ''
}The user has uploaded ${imageCount} image${pluralized}.
Use the \`${ImageVisionTool.function.name}\` tool to retrieve ${
plural ? '' : 'a '
}detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
plural ? '' : 'a '
}detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
return files;
};
@@ -576,6 +576,8 @@ const chatV1 = async (req, res) => {
thread_id,
model: assistant_id,
endpoint,
spec: endpointOption.spec,
iconURL: endpointOption.iconURL,
};
sendMessage(res, {

View File

@@ -428,6 +428,8 @@ const chatV2 = async (req, res) => {
thread_id,
model: assistant_id,
endpoint,
spec: endpointOption.spec,
iconURL: endpointOption.iconURL,
};
sendMessage(res, {

View File

@@ -88,8 +88,8 @@ const startServer = async () => {
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/user', routes.user);
app.use('/api/search', routes.search);
app.use('/api/ask', routes.ask);
app.use('/api/search', routes.search);
app.use('/api/edit', routes.edit);
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);

View File

@@ -1,3 +1,4 @@
// abortMiddleware.js
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const { sendMessage, sendError, countTokens, isEnabled } = require('~/server/utils');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
@@ -8,6 +9,68 @@ const { saveMessage, getConvo } = require('~/models');
const { abortRun } = require('./abortRun');
const { logger } = require('~/config');
const abortDataMap = new WeakMap();
function cleanupAbortController(abortKey) {
if (!abortControllers.has(abortKey)) {
return false;
}
const { abortController } = abortControllers.get(abortKey);
if (!abortController) {
abortControllers.delete(abortKey);
return true;
}
// 1. Check if this controller has any composed signals and clean them up
try {
// This creates a temporary composed signal to use for cleanup
const composedSignal = AbortSignal.any([abortController.signal]);
// Get all event types - in practice, AbortSignal typically only uses 'abort'
const eventTypes = ['abort'];
// First, execute a dummy listener removal to handle potential composed signals
for (const eventType of eventTypes) {
const dummyHandler = () => {};
composedSignal.addEventListener(eventType, dummyHandler);
composedSignal.removeEventListener(eventType, dummyHandler);
const listeners = composedSignal.listeners?.(eventType) || [];
for (const listener of listeners) {
composedSignal.removeEventListener(eventType, listener);
}
}
} catch (e) {
logger.debug(`Error cleaning up composed signals: ${e}`);
}
// 2. Abort the controller if not already aborted
if (!abortController.signal.aborted) {
abortController.abort();
}
// 3. Remove from registry
abortControllers.delete(abortKey);
// 4. Clean up any data stored in the WeakMap
if (abortDataMap.has(abortController)) {
abortDataMap.delete(abortController);
}
// 5. Clean up function references on the controller
if (abortController.getAbortData) {
abortController.getAbortData = null;
}
if (abortController.abortCompletion) {
abortController.abortCompletion = null;
}
return true;
}
async function abortMessage(req, res) {
let { abortKey, endpoint } = req.body;
@@ -29,24 +92,24 @@ async function abortMessage(req, res) {
if (!abortController) {
return res.status(204).send({ message: 'Request not found' });
}
const finalEvent = await abortController.abortCompletion();
const finalEvent = await abortController.abortCompletion?.();
logger.debug(
`[abortMessage] ID: ${req.user.id} | ${req.user.email} | Aborted request: ` +
JSON.stringify({ abortKey }),
);
abortControllers.delete(abortKey);
cleanupAbortController(abortKey);
if (res.headersSent && finalEvent) {
return sendMessage(res, finalEvent);
}
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(finalEvent));
}
const handleAbort = () => {
return async (req, res) => {
const handleAbort = function () {
return async function (req, res) {
try {
if (isEnabled(process.env.LIMIT_CONCURRENT_MESSAGES)) {
await clearPendingReq({ userId: req.user.id });
@@ -62,8 +125,48 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
const abortController = new AbortController();
const { endpointOption } = req.body;
// Store minimal data in WeakMap to avoid circular references
abortDataMap.set(abortController, {
getAbortDataFn: getAbortData,
userId: req.user.id,
endpoint: endpointOption.endpoint,
iconURL: endpointOption.iconURL,
model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model,
});
// Replace the direct function reference with a wrapper that uses WeakMap
abortController.getAbortData = function () {
return getAbortData();
const data = abortDataMap.get(this);
if (!data || typeof data.getAbortDataFn !== 'function') {
return {};
}
try {
const result = data.getAbortDataFn();
// Create a copy without circular references
const cleanResult = { ...result };
// If userMessagePromise exists, break its reference to client
if (
cleanResult.userMessagePromise &&
typeof cleanResult.userMessagePromise.then === 'function'
) {
// Create a new promise that fulfills with the same result but doesn't reference the original
const originalPromise = cleanResult.userMessagePromise;
cleanResult.userMessagePromise = new Promise((resolve, reject) => {
originalPromise.then(
(result) => resolve({ ...result }),
(error) => reject(error),
);
});
}
return cleanResult;
} catch (err) {
logger.error('[abortController.getAbortData] Error:', err);
return {};
}
};
/**
@@ -74,6 +177,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
sendMessage(res, { message: userMessage, created: true });
const abortKey = userMessage?.conversationId ?? req.user.id;
getReqData({ abortKey });
const prevRequest = abortControllers.get(abortKey);
const { overrideUserMessageId } = req?.body ?? {};
@@ -81,34 +185,74 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
const data = prevRequest.abortController.getAbortData();
getReqData({ userMessage: data?.userMessage });
const addedAbortKey = `${abortKey}:${responseMessageId}`;
abortControllers.set(addedAbortKey, { abortController, ...endpointOption });
res.on('finish', function () {
abortControllers.delete(addedAbortKey);
});
// Store minimal options
const minimalOptions = {
endpoint: endpointOption.endpoint,
iconURL: endpointOption.iconURL,
model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model,
};
abortControllers.set(addedAbortKey, { abortController, ...minimalOptions });
// Use a simple function for cleanup to avoid capturing context
const cleanupHandler = () => {
try {
cleanupAbortController(addedAbortKey);
} catch (e) {
// Ignore cleanup errors
}
};
res.on('finish', cleanupHandler);
return;
}
abortControllers.set(abortKey, { abortController, ...endpointOption });
// Store minimal options
const minimalOptions = {
endpoint: endpointOption.endpoint,
iconURL: endpointOption.iconURL,
model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model,
};
res.on('finish', function () {
abortControllers.delete(abortKey);
});
abortControllers.set(abortKey, { abortController, ...minimalOptions });
// Use a simple function for cleanup to avoid capturing context
const cleanupHandler = () => {
try {
cleanupAbortController(abortKey);
} catch (e) {
// Ignore cleanup errors
}
};
res.on('finish', cleanupHandler);
};
// Define abortCompletion without capturing the entire parent scope
abortController.abortCompletion = async function () {
abortController.abort();
this.abort();
// Get data from WeakMap
const ctrlData = abortDataMap.get(this);
if (!ctrlData || !ctrlData.getAbortDataFn) {
return { final: true, conversation: {}, title: 'New Chat' };
}
// Get abort data using stored function
const { conversationId, userMessage, userMessagePromise, promptTokens, ...responseData } =
getAbortData();
ctrlData.getAbortDataFn();
const completionTokens = await countTokens(responseData?.text ?? '');
const user = req.user.id;
const user = ctrlData.userId;
const responseMessage = {
...responseData,
conversationId,
finish_reason: 'incomplete',
endpoint: endpointOption.endpoint,
iconURL: endpointOption.iconURL,
model: endpointOption.modelOptions?.model ?? endpointOption.model_parameters?.model,
endpoint: ctrlData.endpoint,
iconURL: ctrlData.iconURL,
model: ctrlData.modelOptions?.model ?? ctrlData.model_parameters?.model,
unfinished: false,
error: false,
isCreatedByUser: false,
@@ -130,10 +274,12 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
if (userMessagePromise) {
const resolved = await userMessagePromise;
conversation = resolved?.conversation;
// Break reference to promise
resolved.conversation = null;
}
if (!conversation) {
conversation = await getConvo(req.user.id, conversationId);
conversation = await getConvo(user, conversationId);
}
return {
@@ -165,7 +311,7 @@ const handleAbortError = async (res, req, error, data) => {
} else {
logger.error('[handleAbortError] AI response error; aborting request:', error);
}
const { sender, conversationId, messageId, parentMessageId, partialText } = data;
const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data;
if (error.stack && error.stack.includes('google')) {
logger.warn(
@@ -198,10 +344,10 @@ const handleAbortError = async (res, req, error, data) => {
parentMessageId,
text: errorText,
user: req.user.id,
shouldSaveMessage: true,
spec: endpointOption?.spec,
iconURL: endpointOption?.iconURL,
modelLabel: endpointOption?.modelLabel,
shouldSaveMessage: userMessageId != null,
model: endpointOption?.modelOptions?.model || req.body?.model,
};
@@ -218,11 +364,12 @@ const handleAbortError = async (res, req, error, data) => {
};
}
// Create a simple callback without capturing parent scope
const callback = async () => {
if (abortControllers.has(conversationId)) {
const { abortController } = abortControllers.get(conversationId);
abortController.abort();
abortControllers.delete(conversationId);
try {
cleanupAbortController(conversationId);
} catch (e) {
// Ignore cleanup errors
}
};
@@ -243,6 +390,7 @@ const handleAbortError = async (res, req, error, data) => {
module.exports = {
handleAbort,
createAbortController,
handleAbortError,
createAbortController,
cleanupAbortController,
};

View File

@@ -1,6 +1,11 @@
const { parseCompactConvo, EModelEndpoint, isAgentsEndpoint } = require('librechat-data-provider');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const {
parseCompactConvo,
EModelEndpoint,
isAgentsEndpoint,
EndpointURLs,
} = 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');
@@ -77,8 +82,9 @@ async function buildEndpointOption(req, res, next) {
}
try {
const isAgents = isAgentsEndpoint(endpoint);
const endpointFn = buildFunction[endpointType ?? endpoint];
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;
// TODO: use object params

View File

@@ -1,4 +1,4 @@
const Keyv = require('keyv');
const { Keyv } = require('keyv');
const uap = require('ua-parser-js');
const { ViolationTypes } = require('librechat-data-provider');
const { isEnabled, removePorts } = require('~/server/utils');

View File

@@ -1,4 +1,4 @@
const { Time } = require('librechat-data-provider');
const { Time, CacheKeys } = require('librechat-data-provider');
const clearPendingReq = require('~/cache/clearPendingReq');
const { logViolation, getLogStores } = require('~/cache');
const { isEnabled } = require('~/server/utils');
@@ -25,7 +25,7 @@ const {
* @throws {Error} Throws an error if the user exceeds the concurrent request limit.
*/
const concurrentLimiter = async (req, res, next) => {
const namespace = 'pending_req';
const namespace = CacheKeys.PENDING_REQ;
const cache = getLogStores(namespace);
if (!cache) {
return next();

View File

@@ -1,10 +1,9 @@
const Keyv = require('keyv');
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { ViolationTypes } = require('librechat-data-provider');
const ioredisClient = require('~/cache/ioredisClient');
const logViolation = require('~/cache/logViolation');
const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const { logger } = require('~/config');
const getEnvironmentVariables = () => {
@@ -67,11 +66,9 @@ const createImportLimiters = () => {
},
};
if (isEnabled(process.env.USE_REDIS)) {
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
logger.debug('Using Redis for import rate limiters.');
const keyv = new Keyv({ store: keyvRedis });
const client = keyv.opts.store.redis;
const sendCommand = (...args) => client.call(...args);
const sendCommand = (...args) => ioredisClient.call(...args);
const ipStore = new RedisStore({
sendCommand,
prefix: 'import_ip_limiter:',

View File

@@ -1,8 +1,7 @@
const Keyv = require('keyv');
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { removePorts, isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const ioredisClient = require('~/cache/ioredisClient');
const { logViolation } = require('~/cache');
const { logger } = require('~/config');
@@ -31,13 +30,10 @@ const limiterOptions = {
keyGenerator: removePorts,
};
if (isEnabled(process.env.USE_REDIS)) {
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
logger.debug('Using Redis for login rate limiter.');
const keyv = new Keyv({ store: keyvRedis });
const client = keyv.opts.store.redis;
const sendCommand = (...args) => client.call(...args);
const store = new RedisStore({
sendCommand,
sendCommand: (...args) => ioredisClient.call(...args),
prefix: 'login_limiter:',
});
limiterOptions.store = store;

View File

@@ -1,9 +1,8 @@
const Keyv = require('keyv');
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const denyRequest = require('~/server/middleware/denyRequest');
const ioredisClient = require('~/cache/ioredisClient');
const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const { logViolation } = require('~/cache');
const { logger } = require('~/config');
@@ -63,11 +62,9 @@ const userLimiterOptions = {
},
};
if (isEnabled(process.env.USE_REDIS)) {
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
logger.debug('Using Redis for message rate limiters.');
const keyv = new Keyv({ store: keyvRedis });
const client = keyv.opts.store.redis;
const sendCommand = (...args) => client.call(...args);
const sendCommand = (...args) => ioredisClient.call(...args);
const ipStore = new RedisStore({
sendCommand,
prefix: 'message_ip_limiter:',

View File

@@ -1,8 +1,7 @@
const Keyv = require('keyv');
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { removePorts, isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const ioredisClient = require('~/cache/ioredisClient');
const { logViolation } = require('~/cache');
const { logger } = require('~/config');
@@ -31,13 +30,10 @@ const limiterOptions = {
keyGenerator: removePorts,
};
if (isEnabled(process.env.USE_REDIS)) {
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
logger.debug('Using Redis for register rate limiter.');
const keyv = new Keyv({ store: keyvRedis });
const client = keyv.opts.store.redis;
const sendCommand = (...args) => client.call(...args);
const store = new RedisStore({
sendCommand,
sendCommand: (...args) => ioredisClient.call(...args),
prefix: 'register_limiter:',
});
limiterOptions.store = store;

View File

@@ -1,9 +1,8 @@
const Keyv = require('keyv');
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { ViolationTypes } = require('librechat-data-provider');
const { removePorts, isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const ioredisClient = require('~/cache/ioredisClient');
const { logViolation } = require('~/cache');
const { logger } = require('~/config');
@@ -36,13 +35,10 @@ const limiterOptions = {
keyGenerator: removePorts,
};
if (isEnabled(process.env.USE_REDIS)) {
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
logger.debug('Using Redis for reset password rate limiter.');
const keyv = new Keyv({ store: keyvRedis });
const client = keyv.opts.store.redis;
const sendCommand = (...args) => client.call(...args);
const store = new RedisStore({
sendCommand,
sendCommand: (...args) => ioredisClient.call(...args),
prefix: 'reset_password_limiter:',
});
limiterOptions.store = store;

View File

@@ -1,10 +1,9 @@
const Keyv = require('keyv');
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { ViolationTypes } = require('librechat-data-provider');
const ioredisClient = require('~/cache/ioredisClient');
const logViolation = require('~/cache/logViolation');
const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const { logger } = require('~/config');
const getEnvironmentVariables = () => {
@@ -67,11 +66,9 @@ const createSTTLimiters = () => {
},
};
if (isEnabled(process.env.USE_REDIS)) {
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
logger.debug('Using Redis for STT rate limiters.');
const keyv = new Keyv({ store: keyvRedis });
const client = keyv.opts.store.redis;
const sendCommand = (...args) => client.call(...args);
const sendCommand = (...args) => ioredisClient.call(...args);
const ipStore = new RedisStore({
sendCommand,
prefix: 'stt_ip_limiter:',

View File

@@ -1,10 +1,9 @@
const Keyv = require('keyv');
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { ViolationTypes } = require('librechat-data-provider');
const ioredisClient = require('~/cache/ioredisClient');
const logViolation = require('~/cache/logViolation');
const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const { logger } = require('~/config');
const handler = async (req, res) => {
@@ -29,13 +28,10 @@ const limiterOptions = {
},
};
if (isEnabled(process.env.USE_REDIS)) {
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
logger.debug('Using Redis for tool call rate limiter.');
const keyv = new Keyv({ store: keyvRedis });
const client = keyv.opts.store.redis;
const sendCommand = (...args) => client.call(...args);
const store = new RedisStore({
sendCommand,
sendCommand: (...args) => ioredisClient.call(...args),
prefix: 'tool_call_limiter:',
});
limiterOptions.store = store;

View File

@@ -1,10 +1,9 @@
const Keyv = require('keyv');
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { ViolationTypes } = require('librechat-data-provider');
const ioredisClient = require('~/cache/ioredisClient');
const logViolation = require('~/cache/logViolation');
const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const { logger } = require('~/config');
const getEnvironmentVariables = () => {
@@ -67,11 +66,9 @@ const createTTSLimiters = () => {
},
};
if (isEnabled(process.env.USE_REDIS)) {
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
logger.debug('Using Redis for TTS rate limiters.');
const keyv = new Keyv({ store: keyvRedis });
const client = keyv.opts.store.redis;
const sendCommand = (...args) => client.call(...args);
const sendCommand = (...args) => ioredisClient.call(...args);
const ipStore = new RedisStore({
sendCommand,
prefix: 'tts_ip_limiter:',

View File

@@ -1,10 +1,9 @@
const Keyv = require('keyv');
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { ViolationTypes } = require('librechat-data-provider');
const ioredisClient = require('~/cache/ioredisClient');
const logViolation = require('~/cache/logViolation');
const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const { logger } = require('~/config');
const getEnvironmentVariables = () => {
@@ -72,11 +71,9 @@ const createFileLimiters = () => {
},
};
if (isEnabled(process.env.USE_REDIS)) {
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
logger.debug('Using Redis for file upload rate limiters.');
const keyv = new Keyv({ store: keyvRedis });
const client = keyv.opts.store.redis;
const sendCommand = (...args) => client.call(...args);
const sendCommand = (...args) => ioredisClient.call(...args);
const ipStore = new RedisStore({
sendCommand,
prefix: 'file_upload_ip_limiter:',

View File

@@ -1,9 +1,8 @@
const Keyv = require('keyv');
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { ViolationTypes } = require('librechat-data-provider');
const { removePorts, isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const ioredisClient = require('~/cache/ioredisClient');
const { logViolation } = require('~/cache');
const { logger } = require('~/config');
@@ -36,13 +35,10 @@ const limiterOptions = {
keyGenerator: removePorts,
};
if (isEnabled(process.env.USE_REDIS)) {
if (isEnabled(process.env.USE_REDIS) && ioredisClient) {
logger.debug('Using Redis for verify email rate limiter.');
const keyv = new Keyv({ store: keyvRedis });
const client = keyv.opts.store.redis;
const sendCommand = (...args) => client.call(...args);
const store = new RedisStore({
sendCommand,
sendCommand: (...args) => ioredisClient.call(...args),
prefix: 'verify_email_limiter:',
});
limiterOptions.store = store;

View File

@@ -1,39 +1,41 @@
const axios = require('axios');
const { ErrorTypes } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const denyRequest = require('./denyRequest');
const { logger } = require('~/config');
async function moderateText(req, res, next) {
if (process.env.OPENAI_MODERATION === 'true') {
try {
const { text } = req.body;
if (!isEnabled(process.env.OPENAI_MODERATION)) {
return next();
}
try {
const { text } = req.body;
const response = await axios.post(
process.env.OPENAI_MODERATION_REVERSE_PROXY || 'https://api.openai.com/v1/moderations',
{
input: text,
const response = await axios.post(
process.env.OPENAI_MODERATION_REVERSE_PROXY || 'https://api.openai.com/v1/moderations',
{
input: text,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_MODERATION_API_KEY}`,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_MODERATION_API_KEY}`,
},
},
);
},
);
const results = response.data.results;
const flagged = results.some((result) => result.flagged);
const results = response.data.results;
const flagged = results.some((result) => result.flagged);
if (flagged) {
const type = ErrorTypes.MODERATION;
const errorMessage = { type };
return await denyRequest(req, res, errorMessage);
}
} catch (error) {
logger.error('Error in moderateText:', error);
const errorMessage = 'error in moderation check';
if (flagged) {
const type = ErrorTypes.MODERATION;
const errorMessage = { type };
return await denyRequest(req, res, errorMessage);
}
} catch (error) {
logger.error('Error in moderateText:', error);
const errorMessage = 'error in moderation check';
return await denyRequest(req, res, errorMessage);
}
next();
}

View File

@@ -17,9 +17,9 @@ const checkAccess = async (user, permissionType, permissions, bodyProps = {}, ch
}
const role = await getRoleByName(user.role);
if (role && role[permissionType]) {
if (role && role.permissions && role.permissions[permissionType]) {
const hasAnyPermission = permissions.some((permission) => {
if (role[permissionType][permission]) {
if (role.permissions[permissionType][permission]) {
return true;
}

View File

@@ -1,5 +1,6 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { CacheKeys } = require('librechat-data-provider');
const { getAccessToken } = require('~/server/services/TokenService');
const { logger, getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
@@ -19,8 +20,8 @@ const JWT_SECRET = process.env.JWT_SECRET;
router.get('/:action_id/oauth/callback', async (req, res) => {
const { action_id } = req.params;
const { code, state } = req.query;
const flowManager = await getFlowStateManager(getLogStores);
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
let identifier = action_id;
try {
let decodedState;

View File

@@ -58,7 +58,7 @@ router.post('/:agent_id', async (req, res) => {
}
let { domain } = metadata;
domain = await domainParser(req, domain, true);
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
@@ -164,7 +164,7 @@ router.delete('/:agent_id/:action_id', async (req, res) => {
return true;
});
domain = await domainParser(req, domain, true);
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });

View File

@@ -2,7 +2,7 @@ const express = require('express');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
setHeaders,
handleAbort,
moderateText,
// validateModel,
generateCheckAccess,
validateConvoAccess,
@@ -14,28 +14,37 @@ const addTitle = require('~/server/services/Endpoints/agents/title');
const router = express.Router();
router.post('/abort', handleAbort());
router.use(moderateText);
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
router.use(checkAgentAccess);
router.use(validateConvoAccess);
router.use(buildEndpointOption);
router.use(setHeaders);
const controller = async (req, res, next) => {
await AgentController(req, res, next, initializeClient, addTitle);
};
/**
* @route POST /
* @route POST / (regular endpoint)
* @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,
checkAgentAccess,
validateConvoAccess,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await AgentController(req, res, next, initializeClient, addTitle);
},
);
router.post('/', controller);
/**
* @route POST /:endpoint (ephemeral agents)
* @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('/:endpoint', controller);
module.exports = router;

View File

@@ -1,21 +1,40 @@
const express = require('express');
const router = express.Router();
const {
uaParser,
checkBan,
requireJwtAuth,
// concurrentLimiter,
// messageIpLimiter,
// messageUserLimiter,
messageIpLimiter,
concurrentLimiter,
messageUserLimiter,
} = require('~/server/middleware');
const { isEnabled } = require('~/server/utils');
const { v1 } = require('./v1');
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);
router.use('/', v1);
router.use('/chat', chat);
const chatRouter = express.Router();
if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) {
chatRouter.use(concurrentLimiter);
}
if (isEnabled(LIMIT_MESSAGE_IP)) {
chatRouter.use(messageIpLimiter);
}
if (isEnabled(LIMIT_MESSAGE_USER)) {
chatRouter.use(messageUserLimiter);
}
chatRouter.use('/', chat);
router.use('/chat', chatRouter);
module.exports = router;

View File

@@ -1,4 +1,4 @@
const Keyv = require('keyv');
const { Keyv } = require('keyv');
const { KeyvFile } = require('keyv-file');
const { logger } = require('~/config');

View File

@@ -11,8 +11,6 @@ const {
const router = express.Router();
router.post('/abort', handleAbort());
router.post(
'/',
validateEndpoint,

View File

@@ -3,7 +3,6 @@ const AskController = require('~/server/controllers/AskController');
const { initializeClient } = require('~/server/services/Endpoints/custom');
const { addTitle } = require('~/server/services/Endpoints/openAI');
const {
handleAbort,
setHeaders,
validateModel,
validateEndpoint,
@@ -12,8 +11,6 @@ const {
const router = express.Router();
router.post('/abort', handleAbort());
router.post(
'/',
validateEndpoint,

View File

@@ -3,7 +3,6 @@ const AskController = require('~/server/controllers/AskController');
const { initializeClient, addTitle } = require('~/server/services/Endpoints/google');
const {
setHeaders,
handleAbort,
validateModel,
validateEndpoint,
buildEndpointOption,
@@ -11,8 +10,6 @@ const {
const router = express.Router();
router.post('/abort', handleAbort());
router.post(
'/',
validateEndpoint,

View File

@@ -20,7 +20,6 @@ const { logger } = require('~/config');
const router = express.Router();
router.use(moderateText);
router.post('/abort', handleAbort());
router.post(
'/',
@@ -196,7 +195,8 @@ router.post(
logger.debug('[/ask/gptPlugins]', response);
const { conversation = {} } = await client.responsePromise;
const { conversation = {} } = await response.databasePromise;
delete response.databasePromise;
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';

View File

@@ -1,10 +1,4 @@
const express = require('express');
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 {
uaParser,
@@ -15,6 +9,12 @@ const {
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 ?? {};

View File

@@ -12,7 +12,6 @@ const {
const router = express.Router();
router.use(moderateText);
router.post('/abort', handleAbort());
router.post(
'/',

View File

@@ -36,7 +36,7 @@ router.post('/:assistant_id', async (req, res) => {
}
let { domain } = metadata;
domain = await domainParser(req, domain, true);
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
@@ -172,7 +172,7 @@ router.delete('/:assistant_id/:action_id/:model', async (req, res) => {
return true;
});
domain = await domainParser(req, domain, true);
domain = await domainParser(domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });

View File

@@ -4,6 +4,7 @@ const router = express.Router();
const {
setHeaders,
handleAbort,
moderateText,
// validateModel,
// validateEndpoint,
buildEndpointOption,
@@ -12,7 +13,7 @@ const { initializeClient } = require('~/server/services/Endpoints/bedrock');
const AgentController = require('~/server/controllers/agents/request');
const addTitle = require('~/server/services/Endpoints/agents/title');
router.post('/abort', handleAbort());
router.use(moderateText);
/**
* @route POST /

View File

@@ -1,19 +1,35 @@
const express = require('express');
const router = express.Router();
const {
uaParser,
checkBan,
requireJwtAuth,
// concurrentLimiter,
// messageIpLimiter,
// messageUserLimiter,
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

@@ -82,6 +82,7 @@ router.get('/', async function (req, res) {
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
instanceProjectId: instanceProject._id.toString(),
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
};
if (ldap) {

View File

@@ -1,16 +1,17 @@
const multer = require('multer');
const express = require('express');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
const { getConvosByCursor, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
const { storage, importFileFilter } = require('~/server/routes/files/multer');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { importConversations } = require('~/server/utils/import');
const { createImportLimiters } = require('~/server/middleware');
const { deleteToolCalls } = require('~/models/ToolCall');
const { isEnabled, sleep } = require('~/server/utils');
const getLogStores = require('~/cache/getLogStores');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
const assistantClients = {
[EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'),
[EModelEndpoint.assistants]: require('~/server/services/Endpoints/assistants'),
@@ -20,28 +21,30 @@ const router = express.Router();
router.use(requireJwtAuth);
router.get('/', async (req, res) => {
let pageNumber = req.query.pageNumber || 1;
pageNumber = parseInt(pageNumber, 10);
const limit = parseInt(req.query.limit, 10) || 25;
const cursor = req.query.cursor;
const isArchived = isEnabled(req.query.isArchived);
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
const order = req.query.order || 'desc';
if (isNaN(pageNumber) || pageNumber < 1) {
return res.status(400).json({ error: 'Invalid page number' });
}
let pageSize = req.query.pageSize || 25;
pageSize = parseInt(pageSize, 10);
if (isNaN(pageSize) || pageSize < 1) {
return res.status(400).json({ error: 'Invalid page size' });
}
const isArchived = req.query.isArchived === 'true';
let tags;
if (req.query.tags) {
tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
} else {
tags = undefined;
}
res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived, tags));
try {
const result = await getConvosByCursor(req.user.id, {
cursor,
limit,
isArchived,
tags,
search,
order,
});
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: 'Error fetching conversations' });
}
});
router.get('/:conversationId', async (req, res) => {
@@ -76,22 +79,28 @@ router.post('/gen_title', async (req, res) => {
}
});
router.post('/clear', async (req, res) => {
router.delete('/', async (req, res) => {
let filter = {};
const { conversationId, source, thread_id, endpoint } = req.body.arg;
if (conversationId) {
filter = { conversationId };
// Prevent deletion of all conversations
if (!conversationId && !source && !thread_id && !endpoint) {
return res.status(400).json({
error: 'no parameters provided',
});
}
if (source === 'button' && !conversationId) {
if (conversationId) {
filter = { conversationId };
} else if (source === 'button') {
return res.status(200).send('No conversationId provided');
}
if (
typeof endpoint != 'undefined' &&
typeof endpoint !== 'undefined' &&
Object.prototype.propertyIsEnumerable.call(assistantClients, endpoint)
) {
/** @type {{ openai: OpenAI}} */
/** @type {{ openai: OpenAI }} */
const { openai } = await assistantClients[endpoint].initializeClient({ req, res });
try {
const response = await openai.beta.threads.del(thread_id);
@@ -101,9 +110,6 @@ router.post('/clear', async (req, res) => {
}
}
// for debugging deletion source
// logger.debug('source:', source);
try {
const dbResponse = await deleteConvos(req.user.id, filter);
await deleteToolCalls(req.user.id, filter.conversationId);
@@ -114,6 +120,17 @@ router.post('/clear', async (req, res) => {
}
});
router.delete('/all', async (req, res) => {
try {
const dbResponse = await deleteConvos(req.user.id, {});
await deleteToolCalls(req.user.id);
res.status(201).json(dbResponse);
} catch (error) {
logger.error('Error clearing conversations', error);
res.status(500).send('Error clearing conversations');
}
});
router.post('/update', async (req, res) => {
const update = req.body.arg;

View File

@@ -3,7 +3,6 @@ const EditController = require('~/server/controllers/EditController');
const { initializeClient } = require('~/server/services/Endpoints/anthropic');
const {
setHeaders,
handleAbort,
validateModel,
validateEndpoint,
buildEndpointOption,
@@ -11,8 +10,6 @@ const {
const router = express.Router();
router.post('/abort', handleAbort());
router.post(
'/',
validateEndpoint,

View File

@@ -12,8 +12,6 @@ const {
const router = express.Router();
router.post('/abort', handleAbort());
router.post(
'/',
validateEndpoint,

View File

@@ -3,7 +3,6 @@ const EditController = require('~/server/controllers/EditController');
const { initializeClient } = require('~/server/services/Endpoints/google');
const {
setHeaders,
handleAbort,
validateModel,
validateEndpoint,
buildEndpointOption,
@@ -11,8 +10,6 @@ const {
const router = express.Router();
router.post('/abort', handleAbort());
router.post(
'/',
validateEndpoint,

View File

@@ -2,7 +2,6 @@ const express = require('express');
const { getResponseSender } = require('librechat-data-provider');
const {
setHeaders,
handleAbort,
moderateText,
validateModel,
handleAbortError,
@@ -19,7 +18,6 @@ const { logger } = require('~/config');
const router = express.Router();
router.use(moderateText);
router.post('/abort', handleAbort());
router.post(
'/',
@@ -173,7 +171,8 @@ router.post(
logger.debug('[/edit/gptPlugins] CLIENT RESPONSE', response);
const { conversation = {} } = await client.responsePromise;
const { conversation = {} } = await response.databasePromise;
delete response.databasePromise;
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';

View File

@@ -2,7 +2,6 @@ const express = require('express');
const EditController = require('~/server/controllers/EditController');
const { initializeClient } = require('~/server/services/Endpoints/openAI');
const {
handleAbort,
setHeaders,
validateModel,
validateEndpoint,
@@ -12,7 +11,6 @@ const {
const router = express.Router();
router.use(moderateText);
router.post('/abort', handleAbort());
router.post(
'/',

View File

@@ -21,6 +21,7 @@ const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
const { getFiles, batchUpdateFiles } = require('~/models/File');
const { getAssistant } = require('~/models/Assistant');
const { getAgent } = require('~/models/Agent');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
@@ -94,7 +95,7 @@ router.delete('/', async (req, res) => {
});
}
/* Handle entity unlinking even if no valid files to delete */
/* Handle agent unlinking even if no valid files to delete */
if (req.body.agent_id && req.body.tool_resource && dbFiles.length === 0) {
const agent = await getAgent({
id: req.body.agent_id,
@@ -104,7 +105,21 @@ router.delete('/', async (req, res) => {
const agentFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
await processDeleteRequest({ req, files: agentFiles });
res.status(200).json({ message: 'File associations removed successfully' });
res.status(200).json({ message: 'File associations removed successfully from agent' });
return;
}
/* Handle assistant unlinking even if no valid files to delete */
if (req.body.assistant_id && req.body.tool_resource && dbFiles.length === 0) {
const assistant = await getAssistant({
id: req.body.assistant_id,
});
const toolResourceFiles = assistant.tool_resources?.[req.body.tool_resource]?.file_ids ?? [];
const assistantFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
await processDeleteRequest({ req, files: assistantFiles });
res.status(200).json({ message: 'File associations removed successfully from assistant' });
return;
}

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